Read a stopped container instead of claiming there is nothing to read
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m55s
Build App (Preview) / build-linux (pull_request) Successful in 4m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m53s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m55s
Build App (Preview) / build-linux (pull_request) Successful in 4m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m53s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
A project that was merely stopped reported "This project has no container or snapshot image yet, so there is nothing to compare against the base image" — with its container sitting right there — and Update stayed disabled. Start it and the checks passed, which is the tell: the staleness probe had only two sources, a *running* container via `docker exec` or the project's snapshot image. The snapshot is not a checkpoint. `commit_container_snapshot` runs only before a container is destroyed (a config-change recreate) or inside a migration, never on stop, so a project in daily use for a year can have no snapshot at all — and five of the six projects on the box that reported this had none. Absence of a snapshot was being read as absence of anything to inspect. So probe the stopped container directly: commit its writable layer to a throwaway image, probe that, drop it. A stopped container now also outranks the snapshot, for the same reason a running one already did — the snapshot lags it by everything installed since the last commit. `pick_probe_source` is the whole decision and is unit-tested; the message it used to emit now describes only the case it is true of, no container and no snapshot. Two things found on the way, both documented in CLAUDE.md: `bollard` never hands back the image id from a commit — its `Commit` model deserialises "ID" while the daemon sends "Id" — so the probe image has to be tagged, and a tagged image is dangling-proof and therefore invisible to `sweep_orphaned_snapshots`, `reap_stale_migration_pins` and `scrub_secrets_from_snapshots` alike. Without a reaper of its own a crashed probe would leak a multi-gigabyte image that nothing could ever reclaim, so `reap_probe_images` runs at startup beside `reap_probe_containers`, age-gated for the same reason that one is: `reference=` is daemon-wide and a second instance's live probe matches the glob. It removes by tag, never by image id: a force removal by id untags an image everywhere, which is how a first draft of the reaper test deleted an unrelated `alpine:latest`. Names are unique per call rather than stable per container, because container ids do not survive a recreate and two overlapping probes would otherwise fight over one tag. Verified against the container that reported the bug: 13,365 paths and an apt delta of cmake, ffmpeg, libobs-dev, qt6-base-dev and nine more — the migration payload the Update flow could not see. 546 unit tests plus three live-Docker tests pass; no new clippy warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
This commit is contained in:
@@ -92,8 +92,72 @@ fn pick_recorded_lineage(
|
||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||
/// be called on demand, not polled.
|
||||
/// Reported as `probe_error` when there is genuinely nothing to read: no
|
||||
/// container, stopped or otherwise, and no snapshot image.
|
||||
///
|
||||
/// It used to be reported for a *stopped* container too, which was simply
|
||||
/// untrue — the container was sitting right there — and it disabled Update on
|
||||
/// exactly the long-lived projects that had never been recreated and so had no
|
||||
/// snapshot to fall back on.
|
||||
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
|
||||
|
||||
/// Where [`get_container_staleness`] reads the project's *current* filesystem
|
||||
/// from, in descending order of how current the answer is.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ProbeSource {
|
||||
/// `docker exec` into the live container. The only source that includes
|
||||
/// everything installed since the last commit *in this session*.
|
||||
RunningContainer,
|
||||
/// Commit the stopped container's writable layer to a throwaway image and
|
||||
/// probe that. Exactly as current as the container, which is what makes it
|
||||
/// preferable to the snapshot — see below.
|
||||
StoppedContainer,
|
||||
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
|
||||
Snapshot,
|
||||
/// Nothing to read: no container, no snapshot.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Pick the probe source. `container_running` is `None` when the project has no
|
||||
/// container at all, `Some(false)` when it has a stopped one.
|
||||
///
|
||||
/// **A stopped container outranks the snapshot.** The snapshot image is not a
|
||||
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
|
||||
/// config-change recreate) or inside a migration, so a project that has never
|
||||
/// hit either has *no snapshot at all*, however long it has been in use, and
|
||||
/// one that has is stale by everything installed since. The container's
|
||||
/// writable layer is the truth in both cases. This is the same argument
|
||||
/// [`mig::manifest_from_container`] already makes for the running case; it does
|
||||
/// not stop applying when the container is stopped.
|
||||
///
|
||||
/// Getting this wrong is what made a stopped, never-recreated project report
|
||||
/// "no container or snapshot image yet" — with its container sitting right
|
||||
/// there — and left Update disabled on the projects that most needed it.
|
||||
fn pick_probe_source(container_running: Option<bool>, snapshot_exists: bool) -> ProbeSource {
|
||||
match (container_running, snapshot_exists) {
|
||||
(Some(true), _) => ProbeSource::RunningContainer,
|
||||
(Some(false), _) => ProbeSource::StoppedContainer,
|
||||
(None, true) => ProbeSource::Snapshot,
|
||||
(None, false) => ProbeSource::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
|
||||
/// on demand, not polled.
|
||||
///
|
||||
/// **Not read-only, despite only reporting.** The stopped-container path commits
|
||||
/// a throwaway image and force-removes it, which makes this a writer of a
|
||||
/// `triple-c-probe-*` image and puts it in the class of thing
|
||||
/// [`crate::project_lock`] exists for — and it takes no claim. That is
|
||||
/// deliberate: this is what the migration banner calls to decide whether to
|
||||
/// offer an update, including while a migration is in flight, so refusing it
|
||||
/// under a claim would blank the banner exactly when it has the most to say.
|
||||
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
|
||||
/// migration can remove the container out from under the commit, and the result
|
||||
/// is a `probe_error` the user can retry, never a damaged container or a
|
||||
/// mislabelled image. Two overlapping probes cannot collide either, because
|
||||
/// probe image names are unique per call; see
|
||||
/// [`crate::docker::container::get_probe_image_name`].
|
||||
#[tauri::command]
|
||||
pub async fn get_container_staleness(
|
||||
project_id: String,
|
||||
@@ -145,16 +209,25 @@ pub async fn get_container_staleness(
|
||||
};
|
||||
|
||||
// ── Probes ───────────────────────────────────────────────────────────
|
||||
let running = match &container_id {
|
||||
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
|
||||
None => false,
|
||||
let container_running = match &container_id {
|
||||
Some(id) => Some(docker::is_container_running(id).await.unwrap_or(false)),
|
||||
None => None,
|
||||
};
|
||||
let from_manifest = if running {
|
||||
mig::manifest_from_container(container_id.as_ref().unwrap()).await
|
||||
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
} else {
|
||||
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
|
||||
let snapshot_exists = docker::image_exists(&snapshot_image).await.unwrap_or(false);
|
||||
let from_manifest = match (
|
||||
pick_probe_source(container_running, snapshot_exists),
|
||||
&container_id,
|
||||
) {
|
||||
(ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await,
|
||||
(ProbeSource::StoppedContainer, Some(id)) => {
|
||||
mig::manifest_from_stopped_container(id).await
|
||||
}
|
||||
(ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await,
|
||||
// `container_running` is `Some` exactly when `container_id` is, so the
|
||||
// two arms above are the only ones those variants can reach. This arm
|
||||
// is `ProbeSource::Nothing` — and now *only* that: it used to also
|
||||
// swallow every stopped container, which is the bug.
|
||||
(_, _) => Err(NOTHING_TO_PROBE.to_string()),
|
||||
};
|
||||
|
||||
let (from_manifest, base_manifest) = match from_manifest {
|
||||
@@ -1964,6 +2037,42 @@ mod tests {
|
||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_probed_rather_than_reported_missing() {
|
||||
// The regression: a container that exists but is stopped, with no
|
||||
// snapshot ever taken, read as "nothing to compare against".
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(false), false),
|
||||
ProbeSource::StoppedContainer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
|
||||
// The snapshot lags the container by everything installed since the
|
||||
// last commit, in both states.
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(true), true),
|
||||
ProbeSource::RunningContainer
|
||||
);
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(false), true),
|
||||
ProbeSource::StoppedContainer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
|
||||
assert_eq!(pick_probe_source(None, true), ProbeSource::Snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
|
||||
// The one case the "no container or snapshot image yet" message may
|
||||
// still describe.
|
||||
assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
|
||||
Reference in New Issue
Block a user