Stop Docker disk growth, and fix the Claude Code settings that never worked
Two independent sets of fixes.
## Disk: stop the growth, no UI this round
The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.
* **Scrub the writable layer before every commit** (`docker/container.rs`,
`SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
are still free to drop is before the commit that captures them. Measured on
one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
`/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
`/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
mounts. Failure is a log line — a scrub must never block a snapshot.
* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
anywhere, so containers ran on the daemon's unbounded `json-file` default.
Deliberately *not* wired into `container_needs_recreation`: participating
would recreate every project once, and a recreation costs a commit, which is
the thing being fixed. Picked up on the next natural recreation.
* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
pair provably could not match one — ~11.9 GB observed stranded. Stamping
`triple-c.managed=true` is the whole fix; the sweep needed no change.
`create_container` writes the new `triple-c.base` key explicitly empty, or
Docker's label inheritance plus `docker commit` would make every snapshot
claim to be a base image. `force: false` stays, and now says why.
* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
(a probe pins an image the unforced sweep then refuses), pins second, sweep
last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
the report away — `reclaimed_bytes`, `failed` and `unavailable` included.
* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
age-reaped by scanning the tag pattern rather than trusting the state file —
`migration_store::load` reports an unparseable record as absent, which
stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
record aside so `has_record` is trustworthy. A pin whose migration is still
awaiting confirmation is never reaped at any age. The probe container's
removal was a plain statement after an await, so a dropped future (an app quit
mid-migration) leaked a container pinning a multi-gigabyte image; it is a
`Drop` guard now, with `reap_probe_containers` for the case where the process
itself dies.
* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
directory, and the task runner appended uncapped `claude -p` output.
* **Fix the delete copy.** It said "the container, config volume, and stored
credentials"; it removes *both* volumes and the snapshot image.
No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.
## Claude Code settings: two invented keys, one inverted default, one sticky bug
Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.
* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
`CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
could never turn the recap *off*. The field is renamed to
`session_recap_disabled` rather than reused: reusing the name with the
opposite meaning would have read every stored `enable_session_recap: false` —
which is every project that never touched the control — as "the user turned
this off".
* **The stickiness, which is the important one.** Keys were emitted only when
non-default, and the entrypoint *merges* into a settings.json on a persisted
volume, so switching a setting off omitted its key, the merge preserved the
stale on-value, and the setting stayed on until a destructive Reset. The fix
already existed in the same file — the sandbox block is emitted
unconditionally for exactly this reason — and is now applied to all five keys.
A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
`awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
because a stand-in value is not neutral: `tui: "default"` pins the classic
renderer where unset lets Claude Code choose, and `viewMode: "default"`
overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
bakes container env into the snapshot image, so a `=1` written once rode it
forever. All four are now emitted on every create, extracted into
`claude_code_env_vars` and unit tested. Two use an empty value for "off"
rather than `0`, because they outrank a setting the user can change from
inside their own container and Triple-C's default must not overrule a
`/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
which the always-emitted key makes both necessary and possible.
`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.
Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -712,13 +712,76 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
||||
let id = created.id;
|
||||
|
||||
let result = run_throwaway_inner(&id).await;
|
||||
// From here on the container's removal is owned by a guard rather than by
|
||||
// the statement that used to sit after the await below. A plain statement
|
||||
// only runs if this future is *polled to completion*: an `Err(...)?` was
|
||||
// already handled, but a **dropped** future — the app quitting mid-flight,
|
||||
// a timeout, any `select!` that loses — skipped it silently and left a
|
||||
// container behind holding a multi-gigabyte base image open. That image is
|
||||
// then unsweepable (removal is deliberately unforced) and there is nothing
|
||||
// in the UI that would ever mention it.
|
||||
let guard = ProbeContainerGuard::new(created.id);
|
||||
|
||||
if let Err(e) = docker
|
||||
let result = run_throwaway_inner(guard.id()).await;
|
||||
|
||||
// The happy path still removes it *synchronously*, so a caller that goes on
|
||||
// to `docker rmi` the image it probed does not race the removal.
|
||||
guard.remove_now().await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Owns the lifetime of a probe container.
|
||||
///
|
||||
/// [`Self::remove_now`] is the normal path and awaits the removal. `Drop` is the
|
||||
/// safety net for the abnormal one: it cannot await, so it hands the removal to
|
||||
/// a detached task. That covers a dropped future while the process lives; it
|
||||
/// cannot cover the process dying, which is what
|
||||
/// [`reap_probe_containers`] is for.
|
||||
struct ProbeContainerGuard {
|
||||
id: String,
|
||||
/// Cleared by `remove_now` so `Drop` does not queue a second removal.
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl ProbeContainerGuard {
|
||||
fn new(id: String) -> Self {
|
||||
Self { id, armed: true }
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
async fn remove_now(mut self) {
|
||||
self.armed = false;
|
||||
remove_probe_container(&self.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProbeContainerGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let id = std::mem::take(&mut self.id);
|
||||
log::warn!("Probe container {} was abandoned; removing it in the background", id);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
remove_probe_container(&id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-remove one probe container. Missing is success — the point is that the
|
||||
/// container is gone.
|
||||
async fn remove_probe_container(id: &str) {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
match docker
|
||||
.remove_container(
|
||||
&id,
|
||||
id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
v: true,
|
||||
@@ -727,10 +790,57 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to remove probe container {}: {}", id, e);
|
||||
Ok(())
|
||||
| Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {}
|
||||
Err(e) => log::warn!("Failed to remove probe container {}: {}", id, e),
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
/// Remove probe containers left behind by a previous run of the app.
|
||||
///
|
||||
/// A probe is labelled [`LABEL_PROBE`] precisely so it stays findable after a
|
||||
/// crash, but until now nothing ever went looking. One leftover probe pins the
|
||||
/// base image it was created from — several gigabytes that
|
||||
/// `sweep_orphaned_snapshots` then reports as "in use" and correctly refuses to
|
||||
/// touch, with no way for the user to find out why.
|
||||
///
|
||||
/// Safe to run at startup: a probe is a short-lived `/bin/sh` with no mounts
|
||||
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
|
||||
/// right now it belongs to this process — and this runs before any migration
|
||||
/// can be started, so there is none to interrupt.
|
||||
pub async fn reap_probe_containers() {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"label".to_string(),
|
||||
vec![format!("{}={}", LABEL_PROBE, PROBE_LABEL_MIGRATION)],
|
||||
)]);
|
||||
|
||||
let containers = match docker
|
||||
.list_containers(Some(bollard::container::ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list leftover probe containers: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for c in containers {
|
||||
if let Some(id) = c.id {
|
||||
log::info!("Removing leftover migration probe container {}", id);
|
||||
remove_probe_container(&id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
||||
@@ -910,6 +1020,145 @@ pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
|
||||
}
|
||||
|
||||
/// How long a rollback pin may sit with no migration record behind it before
|
||||
/// [`reap_stale_migration_pins`] drops the tag.
|
||||
///
|
||||
/// Two weeks, chosen to be far longer than anyone deliberates over a base
|
||||
/// update and far shorter than "forever", which is what it was.
|
||||
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
|
||||
|
||||
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
|
||||
///
|
||||
/// `None` for anything that is not one of ours — a tag that merely *starts*
|
||||
/// with `pre-migration-` but does not carry a parseable timestamp is left alone
|
||||
/// rather than guessed at, because the consequence of guessing wrong is
|
||||
/// deleting the only copy of somebody's system layer.
|
||||
pub fn parse_rollback_tag(tag: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let stamp = tag.strip_prefix("pre-migration-")?;
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
|
||||
Some(naive.and_utc())
|
||||
}
|
||||
|
||||
/// Split `triple-c-snapshot-<projectId>:<tag>` into the project id and the tag.
|
||||
///
|
||||
/// `None` when the reference is not a snapshot repo at all.
|
||||
pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
|
||||
let (repo, tag) = split_image_ref(reference);
|
||||
let project_id = repo.strip_prefix("triple-c-snapshot-")?.to_string();
|
||||
if project_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((project_id, tag))
|
||||
}
|
||||
|
||||
/// Whether a rollback pin is safe to drop, given how old it is and whether the
|
||||
/// project it belongs to still has a migration record.
|
||||
///
|
||||
/// Pure so the decision can be tested without a daemon. The order of the two
|
||||
/// conditions is the point: **a pin whose migration is still awaiting
|
||||
/// confirmation is never reaped at any age**, because it is the only copy of
|
||||
/// the rollback target and the user has not yet said they are happy with the
|
||||
/// new base.
|
||||
pub fn pin_is_reapable(
|
||||
tag: &str,
|
||||
has_migration_record: bool,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
if has_migration_record {
|
||||
return false;
|
||||
}
|
||||
let Some(created) = parse_rollback_tag(tag) else {
|
||||
return false;
|
||||
};
|
||||
(*now - created).num_days() >= STALE_PIN_MAX_AGE_DAYS
|
||||
}
|
||||
|
||||
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
|
||||
/// claims any more, so the images behind them become sweepable.
|
||||
///
|
||||
/// ## Why this scans tags instead of reading the records
|
||||
///
|
||||
/// Every other path to a rollback pin starts from
|
||||
/// `migration_store::load`, and `load` reports an unparseable state file as
|
||||
/// *absent* — so a single corrupt record used to strand a 4–12 GB image that no
|
||||
/// code could ever name again. Confirming or rolling back both remove the
|
||||
/// record and drop the tag together, so a `pre-migration-*` tag with no record
|
||||
/// beside it is by definition one that lost its owner: a crash between the two,
|
||||
/// a record that was deleted by hand, or the corrupt-file case.
|
||||
///
|
||||
/// Scanning the *tag pattern* is the only way to find those. `load` moving a
|
||||
/// corrupt record aside (see `migration_store::load`) is what stops that case
|
||||
/// from being permanently invisible here too.
|
||||
///
|
||||
/// ## Why it only untags
|
||||
///
|
||||
/// Dropping the tag turns the image dangling, and it is already labelled
|
||||
/// `triple-c.managed=true` because `docker commit` created it — so
|
||||
/// `sweep_orphaned_snapshots` collects it on the same pass, under the same two
|
||||
/// safety conditions, with the daemon's "still in use by a container" refusal
|
||||
/// still in front of it. Nothing here calls `docker rmi` on a reachable image.
|
||||
pub async fn reap_stale_migration_pins() -> usize {
|
||||
use bollard::image::ListImagesOptions;
|
||||
|
||||
let Ok(docker) = get_docker() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// `reference` matches against `repo:tag`, so this asks the daemon for
|
||||
// exactly the shape [`rollback_tag`] produces and nothing else.
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec!["triple-c-snapshot-*:pre-migration-*".to_string()],
|
||||
)]);
|
||||
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list rollback pins: {}", e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut reaped = 0usize;
|
||||
|
||||
for summary in images {
|
||||
for reference in &summary.repo_tags {
|
||||
let Some((project_id, tag)) = parse_snapshot_reference(reference) else {
|
||||
continue;
|
||||
};
|
||||
// Filesystem presence, not `load`: a record we cannot parse must
|
||||
// still count as "somebody may want this back".
|
||||
let has_record =
|
||||
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
|
||||
if !pin_is_reapable(&tag, has_record, &now) {
|
||||
continue;
|
||||
}
|
||||
match untag_image(reference).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it for {} days",
|
||||
reference,
|
||||
summary.size as f64 / 1_073_741_824.0,
|
||||
STALE_PIN_MAX_AGE_DAYS,
|
||||
);
|
||||
reaped += 1;
|
||||
}
|
||||
Err(e) => log::warn!("Could not drop stale rollback pin {}: {}", reference, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reaped
|
||||
}
|
||||
|
||||
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
||||
pub fn split_image_ref(image: &str) -> (String, String) {
|
||||
match image.rsplit_once(':') {
|
||||
@@ -1622,4 +1871,73 @@ mod tests {
|
||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
||||
}
|
||||
|
||||
|
||||
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
|
||||
|
||||
fn at(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::NaiveDate::from_ymd_opt(y, m, d)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rollback_tag_round_trips_through_its_parser() {
|
||||
let made = at(2026, 3, 14);
|
||||
assert_eq!(parse_rollback_tag(&rollback_tag(&made)), Some(made));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_rollback_tag_parses() {
|
||||
assert_eq!(parse_rollback_tag("latest"), None);
|
||||
assert_eq!(parse_rollback_tag("pre-migration-"), None);
|
||||
// Looks like ours but carries no timestamp we produced. Guessing here
|
||||
// would mean deleting the only copy of somebody's system layer.
|
||||
assert_eq!(parse_rollback_tag("pre-migration-keepme"), None);
|
||||
assert_eq!(parse_rollback_tag("pre-migration-20260231-000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_reference_yields_its_project_id() {
|
||||
assert_eq!(
|
||||
parse_snapshot_reference("triple-c-snapshot-abc-123:pre-migration-20260101-101500"),
|
||||
Some(("abc-123".to_string(), "pre-migration-20260101-101500".to_string()))
|
||||
);
|
||||
// Not ours: a base image, and a repo that merely shares a prefix.
|
||||
assert_eq!(parse_snapshot_reference("triple-c-sandbox:latest"), None);
|
||||
assert_eq!(parse_snapshot_reference("triple-c-snapshot-:latest"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_awaiting_confirmation_is_never_reaped_at_any_age() {
|
||||
// The one rule that cannot bend: while a migration record exists, this
|
||||
// image is the only copy of the rollback target and the user has not
|
||||
// yet said they are happy on the new base.
|
||||
let ancient = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(&ancient, true, &at(2026, 8, 23)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
|
||||
let made = at(2026, 8, 1);
|
||||
let tag = rollback_tag(&made);
|
||||
assert!(!pin_is_reapable(&tag, false, &at(2026, 8, 2)));
|
||||
assert!(!pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS - 1))
|
||||
));
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_we_cannot_date_is_left_alone() {
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, &at(2026, 8, 23)));
|
||||
assert!(!pin_is_reapable("latest", false, &at(2026, 8, 23)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user