What's wrong
MirrorMaintenanceService.Sweep() (GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs) decides whether a mirror is idle purely from on-disk markers read via IMirrorStore.LastUsedAt/RefsFetchedAt (falling back to directory creation time), and once a mirror looks idle beyond MirrorIdleMaxAge it calls mirrors.Delete(directory) — a recursive filesystem delete (MirrorStore.Delete → Directory.Delete(directory, recursive: true)).
Those markers are only rewritten after a git operation finishes: MirrorFetcher.FetchAsync/CloneAsync call mirrors.MarkFetched(directory) only once git fetch/git clone has succeeded, and BranchStateHandler calls mirrors.MarkUsed(repository.Directory) only after fetcher.EnsureCurrentAsync(...) has already returned. MirrorMaintenanceService has no reference to ISingleFlight and there is no lock, refcount, or other coordination that stops it from reaping a directory that another part of the process is actively reading from or writing to.
Concrete failure scenario
- A mirror hasn't been queried in just under
MirrorIdleMaxAge (default 30 days), so its .last-used / .refs-fetched-at markers are correspondingly old.
- A client request arrives.
MirrorFetcher.EnsureCurrentAsync finds the refs older than RefsTtl (default 30s), becomes the SingleFlight leader for that repository, and starts FetchAsync (git fetch --prune --quiet origin) against the existing mirror.git directory. On a large repository this can legitimately run for close to FetchTimeout (default 2 minutes).
MirrorMaintenanceService.ExecuteAsync ticks (default hourly, MaintenanceInterval) while that fetch is still running. Sweep() reads LastTouched(directory) for this same directory — it still sees the stale, pre-fetch markers, because MarkFetched/MarkUsed are only written once the fetch completes — computes it as idle beyond MirrorIdleMaxAge, and calls Reap(directory), i.e. Directory.Delete(directory, recursive: true), on the very directory the concurrent git fetch process has as its working directory.
- The in-flight fetch fails (or, depending on filesystem/OS timing, partially succeeds against a half-deleted tree). Any follower requests waiting on the same
SingleFlight ticket (MirrorFetcher.FollowAsync) then observe !mirrors.Exists(directory) and are told "No mirror exists for this repository and the request that was creating one did not finish" — even though a fully working mirror existed moments before the sweep ran.
The same class of race, more narrowly, can also hit a request that skipped the fetch path entirely (refs already fresh, EnsureCurrentAsync returns Current immediately with no SingleFlight involvement) but has not yet reached its own mirrors.MarkUsed call: if the sweep reaps the directory in that gap, the subsequent refs.ListAsync/diff git subprocesses run against a directory that disappears underneath them.
Why it matters
This is exactly the class of bug this cache is most exposed to: it hits precisely the mirrors the reaper exists to protect — ones sitting right at the idle-age boundary that are being queried again — and turns a routine maintenance sweep into an intermittent 502/503 (or a mirror that has to be re-cloned from scratch) for a real, legitimate request.
Suggested fix / acceptance criteria
- Give
MirrorMaintenanceService a way to know a directory is currently being fetched/read before reaping it, e.g. have it consult the same ISingleFlight instance MirrorFetcher uses and skip any directory whose flight key currently has an in-flight entry.
- And/or have
MirrorFetcher write a "touched" marker (or call MarkUsed) at the start of a fetch/clone, not only on completion, so Sweep's LastTouched sees an in-progress operation as recent rather than stale.
- Add a test that starts a slow/blocked fetch against a mirror whose markers are already older than
MirrorIdleMaxAge, runs MirrorMaintenanceService.Sweep() concurrently, and asserts the mirror survives and the fetch completes successfully.
What's wrong
MirrorMaintenanceService.Sweep()(GitBranchStateCache/Mirrors/MirrorMaintenanceService.cs) decides whether a mirror is idle purely from on-disk markers read viaIMirrorStore.LastUsedAt/RefsFetchedAt(falling back to directory creation time), and once a mirror looks idle beyondMirrorIdleMaxAgeit callsmirrors.Delete(directory)— a recursive filesystem delete (MirrorStore.Delete→Directory.Delete(directory, recursive: true)).Those markers are only rewritten after a git operation finishes:
MirrorFetcher.FetchAsync/CloneAsynccallmirrors.MarkFetched(directory)only oncegit fetch/git clonehas succeeded, andBranchStateHandlercallsmirrors.MarkUsed(repository.Directory)only afterfetcher.EnsureCurrentAsync(...)has already returned.MirrorMaintenanceServicehas no reference toISingleFlightand there is no lock, refcount, or other coordination that stops it from reaping a directory that another part of the process is actively reading from or writing to.Concrete failure scenario
MirrorIdleMaxAge(default 30 days), so its.last-used/.refs-fetched-atmarkers are correspondingly old.MirrorFetcher.EnsureCurrentAsyncfinds the refs older thanRefsTtl(default 30s), becomes theSingleFlightleader for that repository, and startsFetchAsync(git fetch --prune --quiet origin) against the existingmirror.gitdirectory. On a large repository this can legitimately run for close toFetchTimeout(default 2 minutes).MirrorMaintenanceService.ExecuteAsyncticks (default hourly,MaintenanceInterval) while that fetch is still running.Sweep()readsLastTouched(directory)for this same directory — it still sees the stale, pre-fetch markers, becauseMarkFetched/MarkUsedare only written once the fetch completes — computes it as idle beyondMirrorIdleMaxAge, and callsReap(directory), i.e.Directory.Delete(directory, recursive: true), on the very directory the concurrentgit fetchprocess has as its working directory.SingleFlightticket (MirrorFetcher.FollowAsync) then observe!mirrors.Exists(directory)and are told "No mirror exists for this repository and the request that was creating one did not finish" — even though a fully working mirror existed moments before the sweep ran.The same class of race, more narrowly, can also hit a request that skipped the fetch path entirely (refs already fresh,
EnsureCurrentAsyncreturnsCurrentimmediately with noSingleFlightinvolvement) but has not yet reached its ownmirrors.MarkUsedcall: if the sweep reaps the directory in that gap, the subsequentrefs.ListAsync/diffgitsubprocesses run against a directory that disappears underneath them.Why it matters
This is exactly the class of bug this cache is most exposed to: it hits precisely the mirrors the reaper exists to protect — ones sitting right at the idle-age boundary that are being queried again — and turns a routine maintenance sweep into an intermittent 502/503 (or a mirror that has to be re-cloned from scratch) for a real, legitimate request.
Suggested fix / acceptance criteria
MirrorMaintenanceServicea way to know a directory is currently being fetched/read before reaping it, e.g. have it consult the sameISingleFlightinstanceMirrorFetcheruses and skip any directory whose flight key currently has an in-flight entry.MirrorFetcherwrite a "touched" marker (or callMarkUsed) at the start of a fetch/clone, not only on completion, soSweep'sLastTouchedsees an in-progress operation as recent rather than stale.MirrorIdleMaxAge, runsMirrorMaintenanceService.Sweep()concurrently, and asserts the mirror survives and the fetch completes successfully.