Skip to content

Sweep staging directories left by clones that never finished - #61

Merged
matt-edmondson merged 1 commit into
mainfrom
fix/sweep-stale-clone-staging
Sep 29, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
fix/sweep-stale-clone-staging

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #45

Problem

MirrorFetcher.CloneAsync stages each clone into mirror.git.tmp-<guid> and discards it in a finally. That finally never runs when the process is killed mid-clone, for example by an OOM kill, an eviction or a rollout. MirrorStore.Enumerate() only finds directories named exactly mirror.git, and nothing else looks for the staging pattern. So each crash left a partial bare clone on the volume permanently, and the mirror byte metrics never counted it.

Change

  • IMirrorStore.EnumerateStaging() (implemented in MirrorStore) lists staging directories under MirrorRoot.
    • It matches the exact name a clone uses: mirror.git.tmp- followed by 32 hex digits. The prefix alone isn't enough, because a repository path segment may start the same way.
    • CloneAsync now builds its staging name from the same StagingPrefix constant.
  • MirrorMaintenanceService.Sweep now starts by removing staging directories older than FetchTimeout + ProbeTimeout + 10 min. That is the longest a clone can still be running (the clone, then the config step, plus a margin for killing a timed-out process).
    • Age is the later of the creation and last-write times. On a filesystem that can't report creation times, this errs towards keeping the directory.
    • A candidate that holds a mirror is skipped.
    • This sweep runs even when MirrorIdleMaxAge is zero (idle reaping disabled).
    • New log events: 1006 (removed) and 1007 (removal failed).
  • The README's deployment section mentions the sweep.

I chose the age-bounded sweep over a startup-only cleanup because it also catches a staging directory whose DiscardStaging failed while the process stayed up. DiscardStaging's existing comment already promised "until the next sweep". The first sweep runs at startup anyway.

Tests

Six new tests in MirrorMaintenanceServiceTests:

  • a dead clone's staging directory is removed
  • it is removed even with reaping disabled
  • a clone still within FetchTimeout keeps its staging directory
  • a mirror.git.tmp-old repository segment is kept
  • a staging-named directory that holds a mirror is kept
  • EnumerateStaging matches only real staging names

I checked that the tests catch the bug by temporarily disabling the staging sweep: the two removal tests failed, and they pass with the fix. Full suite: 225/225 passed locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_015LsGhhi4Vq9pNq2fr5T75T


Generated by Claude Code

…tch]

A clone stages into mirror.git.tmp-<guid> beside its mirror and discards it
in a finally, which never runs when the process is killed mid-clone (OOM,
eviction, rollout). Nothing else looked for the pattern, so each crash left a
partial bare clone on the volume for good, uncounted by the mirror metrics.

The maintenance sweep now removes staging directories older than
FetchTimeout + ProbeTimeout + ten minutes, the longest a clone can still be
running, whether or not idle-mirror reaping is enabled. Only the exact name a
clone uses (the prefix plus 32 hex digits) is matched, and a candidate that
holds a mirror is skipped, so a repository path segment that happens to share
the prefix is never deleted.

Fixes #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LsGhhi4Vq9pNq2fr5T75T
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 1e8a933 into main Sep 29, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the fix/sweep-stale-clone-staging branch September 29, 2026 21:57
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.

Staging directories (mirror.git.tmp-*) left by a crashed or killed clone are never swept and leak disk permanently

2 participants