Skip to content

fix(cache): a test-temp run lives exactly as long as its owner - #271

Open
ebursztein wants to merge 3 commits into
mainfrom
fix/test-temp-run-leak
Open

ebursztein wants to merge 3 commits into
mainfrom
fix/test-temp-run-leak

Conversation

@ebursztein

Copy link
Copy Markdown
Collaborator

Problem

On 2026-09-28, /var/tmp/capsem-tests/6e48ba3b/ (the test-temp stage) had grown to 42 GB: one run-<pid>/ of about 7.2 GB per release precheck, plus about 1,900 .run-<pid>.lock files going back to 2026-09-04. just cache prune test-temp offered 21 MB. Only just cache clean test-temp --apply got the space back.

Root causes

  1. Nothing removed a run when it ended. gatelaunch creates run-<pid> (the gate's or the bounded wrapper's TMPDIR and pytest --basetemp) and leases .run-<pid>.lock. The lease is dropped at process exit, but the directory and the lock file were left behind every time, on success and on failure. Every launcher did this: capsem-gate, capsem-cache, run-bounded-command.py, and every test that starts one.
  2. Prune treated a dead run like a retained generation. For the ephemeral strategy the planner used the same rules as generational: a run with no lease was reclaimed only after maximum_age_hours (24) or once the stage passed its 200 GiB maximum. Lock files were not matched by managed_globs, so they were never collectable at all.
  3. contained_environment created the run directory before any lease existed. Tests that build a temporary checkout therefore left run-<pid> directories in one new namespace per test. This is where most of the ~350 namespace directories under /var/tmp/capsem-tests came from.

Fix

  • gatelaunch.hold_environment takes the lease first, then creates the run. At exit it hands the run to operations.reclaim_generation, which releases the lease and removes the run and its lease through the same guarded, journaled apply_prune path. A process that is killed leaves a run that no lease holds.
  • In a leased ephemeral stage, an unleased generation is now reported as "no live owner" and is reclaimed whatever its age or size.
  • Inventory attaches each lease file to its generation as a member, so the two are removed together. A lease whose generation is gone becomes a lease_only entry: it is collected as "orphaned lease" and never counts toward maximum_count. plan_clean now also removes a generation's members.
  • apply_prune holds each generation's lease exclusively while removing it. If a reused pid leased the run after the plan was made, the run is kept and reported in ApplyResult.busy. retain_path now waits for that exclusive hold instead of raising, and re-takes a lease file that was unlinked while it waited.
  • test-temp max goes from 200 GiB to 32 GiB, with the reasoning in config/cache.toml. The stage now holds only live runs, and 32 GiB is about 4x the largest run seen.
  • pytest tmp_path_retention_policy = "failed". On this box, the peak scratch for build_system/tests drops from 0.8 GB to 0.3 GB.

Evidence

  • Before: a 6-second wrapper run and a full build_system/tests run each left their run-<pid> behind (the latter 801 MB).
  • After: both contract suites leave no run directory and no lease behind.
  • The prune preview on the real stage now plans 4,765 actions (dead runs and stale locks). Before, it planned almost nothing.
  • I could not reproduce the 7.2 GB size in a plain worktree. Without the gate prefix's node_modules and assets, the contract suites peak at 0.8 GB and 76 MB. With this change, a run no longer outlives its owner whatever its size.

Tests

  • New build_system/tests/cache/test_scratch_runs.py:
    • a dead run and an orphaned lock are pruned while a live run is kept;
    • a pid that re-leases between plan and apply is kept;
    • the owner reclaims its run at the end;
    • an unlinked lease is re-taken;
    • a launched process leaves nothing behind, both on exit 0 and exit 3.
  • New planner tests for "no live owner", "orphaned lease" and clean with members.
  • build_system/tests: the only failures are the 23 environmental ones that also fail on origin/main on this machine (test_mock_server_launcher and test_protocol_fixture_recorder need the capsem-mock-server binary).
  • tests/citadel -n 4 passes: 1255 tests.
  • ruff and capsem-gate lint are clean.

Follow-up (not in this PR)

External namespaces from retired authorities, such as deleted worktrees that exported CAPSEM_CACHE_AUTHORITY, are never inventoried by any other authority. Their dead runs stay behind until someone cleans them by hand.

🤖 Generated with Claude Code

/var/tmp/capsem-tests/<namespace> reached 42 GB: one ~7 GB run-<pid> per
release precheck plus ~1,900 .run-<pid>.lock files back to early September.
`cache prune test-temp` offered 21 MB; only a cold clean reclaimed it.

Two defects, one on each side of the lease:

- Nothing removed a run when it ended. The launcher leased run-<pid> and let
  the lease drop at exit, leaving the directory and its lock file behind on
  success and failure alike. gatelaunch now leases the run before creating it
  and, at exit, removes the run and its lease through
  operations.reclaim_generation -- the same guarded, journaled removal a
  prune uses. A killed process leaves a run no lease holds.
- Prune treated a dead run like a retained generation: it aged for 24 hours
  under a 200 GiB maximum. For a leased ephemeral stage an unleased
  generation now has "no live owner" and is reclaimed whatever its age or
  size. Inventory attaches each lease file to its generation, so both leave
  together, and reports a lease whose generation is gone as lease_only: it is
  collected ("orphaned lease") and never counts toward maximum_count.
  plan_clean now removes a generation's members too.

Removal takes each generation's lease exclusively, so a reused pid that
leased after the plan keeps its directory (ApplyResult.busy). retain_path
waits out that exclusive hold instead of failing, and retakes a lease file
that was unlinked under it.
With dead runs reclaimed on sight, test-temp holds only live runs, so its
maximum is a statement about how much live scratch is reasonable, not a
retention budget. 200 GiB on a 484 GB disk that also carries a 180 GiB Cargo
target bounded nothing: the disk filled first. 32 GiB is about four times the
largest run observed (a 7.2 GB release precheck), enough for a gate plus
several concurrent bounded suites; more than that is a leak that enforcement
should report rather than absorb. Warm stays 8 GiB.
pytest's default retention keeps every tmp_path until the next session wipes
the basetemp, so a run's scratch peaked at the sum of the whole suite (0.8 GB
for build_system/tests alone). With "failed", only failing and in-flight
tests hold their directories, and the failing ones remain for inspection until
the run ends.
@codecov-commenter

codecov-commenter commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 10.52632% with 119 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.3%. Comparing base (f7da631) to head (8c9cc4d).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
build_system/builder/cache/inventory.py 0.0% 32 Missing ⚠️
build_system/builder/cache/leases.py 12.1% 29 Missing ⚠️
build_system/builder/cache/operations.py 14.7% 29 Missing ⚠️
build_system/builder/gatelaunch.py 15.7% 16 Missing ⚠️
build_system/builder/cache/planner.py 0.0% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             main     #271     +/-   ##
=========================================
- Coverage    65.3%    65.3%   -0.1%     
=========================================
  Files        1452     1452             
  Lines      127965   128076    +111     
  Branches    91872    91872             
=========================================
+ Hits        83614    83637     +23     
- Misses      39361    39446     +85     
- Partials     4990     4993      +3     
Flag Coverage Δ
integration 17.2% <ø> (ø)
linux-unit 70.5% <ø> (+<0.1%) ⬆️
mcp-server 93.8% <ø> (ø)
python-sdk 98.7% <ø> (ø)
typescript-sdk 97.8% <ø> (ø)
unit 63.4% <10.5%> (-0.1%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
TypeScript SDK 97.8% <ø> (ø)
Python SDK 98.7% <ø> (ø)
Network 84.4% <ø> (ø)
Security 81.6% <ø> (ø)
Tooling 88.6% <ø> (ø)
Monitoring 87.5% <ø> (-0.1%) ⬇️
Virtualization 64.4% <ø> (ø)
Confined Port Router 76.9% <ø> (ø)
Private Network 79.3% <ø> (ø)
Assets 80.8% <ø> (ø)
Gateway API 96.7% <ø> (ø)
Rust SDK 96.3% <ø> (ø)
Configuration 86.8% <ø> (ø)
Credentials 80.7% <ø> (ø)
Host Foundation 76.3% <ø> (ø)
Core Platform 55.8% <ø> (ø)
Runtime 60.7% <ø> (ø)
Daemon 41.6% <ø> (ø)
Service 71.5% <ø> (ø)
Process 48.5% <ø> (ø)
Admin 63.7% <ø> (ø)
CLI 47.9% <ø> (ø)
MCP Server 93.8% <ø> (ø)
MCP Aggregator 61.8% <ø> (ø)
MCP Builtin 57.4% <ø> (ø)
Gateway 78.9% <ø> (ø)
TUI 68.5% <ø> (ø)
System Tray 53.2% <ø> (ø)
Guard 92.2% <ø> (ø)
UI 86.6% <ø> (ø)
Release Site 15.0% <ø> (ø)
Builder 43.3% <10.5%> (-0.1%) ⬇️
Mock Server 59.0% <ø> (ø)
Bench 47.8% <ø> (+<0.1%) ⬆️
Files with missing lines Coverage Δ
build_system/builder/cache/models.py 91.7% <100.0%> (+0.1%) ⬆️
build_system/builder/cache/planner.py 0.0% <0.0%> (ø)
build_system/builder/gatelaunch.py 27.6% <15.7%> (-1.2%) ⬇️
build_system/builder/cache/leases.py 28.8% <12.1%> (-4.6%) ⬇️
build_system/builder/cache/operations.py 22.1% <14.7%> (-3.3%) ⬇️
build_system/builder/cache/inventory.py 0.0% <0.0%> (ø)

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants