fix(opal-server): memory leak — purge GitPolicyFetcher caches on scope delete + webhook task cleanup (PR2)#923
Conversation
Add an off-by-default diagnostics endpoint so tests can observe the in-memory GitPolicyFetcher cache sizes (repo_locks/repos/repos_last_fetched) and process RSS that the upcoming memory-leak fix eliminates. - debug_stats.py: read-only git_fetcher_cache_stats() helper + a register_internal_stats_route() registrar that mounts GET /internal/git-fetcher-cache-stats only when enabled. - config.py: new OPAL_DEBUG_INTERNAL_STATS flag, default False. - server.py: register the route, gated by the flag, beside /healthcheck. No production behavior change when the flag is off (the default). Also ignore .claude/ so private planning artifacts are never committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A self-contained docker-compose stack (opal-server x2 workers + Redis + Postgres broadcaster + Gitea) plus a pytest harness that reproduces, as tests that fail on master, the git-fetcher memory leak, the offline-repo hang, the slow serial boot, and the broadcaster-disconnect gap. These become the regression gates for the follow-up fixes. - seed/: idempotent Gitea seeding sidecar (N policy repos) + Dockerfile. - docker-compose.yml: 4-service stack, opal-server built from the repo's own docker/Dockerfile (server target), scopes on, Postgres broadcaster. - helpers.py / conftest.py: HTTP + infra helpers and stack fixtures. - test_leak.py / test_resilience.py / test_boot.py: the flagship tests. - README.md: how to run and expected fail-on-master behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the helpers.py surface promised in the plan's file-structure table. Both are now functional and used, not dead code: - make_repo_unreachable(name): returns a git URL on a routable-but-dead TEST-NET-1 host (RFC 5737). test_offline_repo now uses it instead of an inlined literal. - GiteaAdmin: host-side Gitea admin client (list_repos / repo_exists / create_repo / delete_repo), exposed as the `gitea_admin` pytest fixture for tests that need to inspect or stage repos beyond the seed sidecar. Gitea is published on host port 13000 (uncommon, to avoid the usual :3000 clash) so GiteaAdmin can reach it; opal_server and the seed sidecar still use the internal http://gitea:3000. README updated with the helper and port notes. Verified live: GiteaAdmin lists the seeded repos and round-trips create/exists/delete against Gitea over the published port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified test_server_recovers_after_postgres_bounce against the stack: it PASSES on master (~14-19s). On a broadcaster drop the affected worker triggers a graceful shutdown, gunicorn respawns it, and the sibling worker keeps serving HTTP, so the surface recovers within the window — recovery happens via gunicorn's in-container worker supervision, not an external supervisor and not an in-process reconnect. Reframe #5 as a recovery guard (not a known-broken case) in the docstring and README; the prior "FAILS on master / needs external supervisor" wording was wrong. PER-15065's in-process reconnect would avoid the worker churn but recovery already holds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the repo's pinned pre-commit formatters (black 23.1.0, isort 5.12.0, docformatter 1.7.5) over the PR1 files to satisfy the pre-commit CI check. Formatting only — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI `build` job runs `pytest` from the repo root with no path, which recursed into app-tests/git-leak/ and ran the flagship tests — these are designed to FAIL on master (they are the regression gates for PR2-PR5), so they broke the build job. Set `testpaths = packages` so the rootdir run collects only the unit tests under packages/ (matching master's effective behavior, since app-tests/ had no pytest files before). testpaths only applies when pytest is invoked from the rootdir with no args, so `cd app-tests/git-leak && pytest` still collects and runs the test bed. Verified both: root run -> packages only; subdir run -> all 5 flagship tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- stats(): sample the /internal endpoint several times and merge per key with max(). The caches are per-process on a multi-worker server, so a single read can hit an empty (non-leader) worker — max-merge avoids both false negatives (missing a populated leader) and false positives (an `== 0` drain assertion passing only because it hit an empty worker). - test_leak: assert the initial-load `_wait_until` succeeded before deleting / before taking baseline, so the tests can't pass vacuously when load never completed. - refresh_all(): correct the misleading comment — a 404 is a no-op, there is no client-side fallback. - conftest: skip the suite cleanly if docker is unavailable (defense in depth; it's already excluded from the default pytest run via testpaths). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings on the regression gates: - Cross-test contamination: clone paths are keyed by repo URL (source_id = sha256(url)+branch-shard), not scope_id, so scopes from different tests that point at the same seeded repo share one GitPolicyFetcher cache entry. With a session-scoped stack and no teardown, a leftover boot-*/stable-* scope kept those entries alive and would make test_churn's `repos == 0` drain assertion fail on fixed code. OpalServerClient now tracks created scopes and the opal fixture deletes them on teardown (best-effort drain wait, swallows errors so master — where delete never purges — doesn't fail the passing test). - False gate: test_repeat_sync_does_not_grow re-syncs identical scopes, which a path-keyed cache can't grow even on master, so it could never be the leak gate it claimed. Reframed as an honest idempotency guard (passes on master) that points at test_churn_releases_caches as the real leak gate; README's "Expected on master" reclassifies it alongside the postgres-bounce guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iew) Addresses the CHANGES_REQUESTED review on PR #922. Root cause behind most findings: a fresh scope's first sync takes the _clone() branch, which only fills GitPolicyFetcher.repo_locks; repos/repos_last_fetched are filled on a *second* sync. So the load gates on `repos` hung, and the 2-worker per-process caches made `== 0` drain assertions unsafe. Blocking: - Single worker (UVICORN_NUM_WORKERS=1): deterministic per-process cache reads; removes the false `== 0` drain class. - Load gate (CRITICAL + Zivxx HIGH): _load_scopes gates on repo_locks then refresh_all() to force the second sync, so repos/repos_last_fetched are actually populated before any drain/purge assertion. - compose() surfaces captured stdout/stderr on failure. - Seed completeness asserted in conftest; seed script isolates per-repo failures and exits non-zero with a count. Secondary: - test_repeat_sync asserts an RSS bound (count can't grow for any impl); churn asserts all three caches drain + a loose RSS backstop. - blackhole socat sidecar replaces TEST-NET-1 (deterministic hang); offline test saturates the fetch executor with 40 hung clones and recovers via OpalServerClient.hard_reset() (stop -> redis FLUSHALL -> start). - Per-test clean slate deletes all server scopes (fixes orphan-scope leak). - Postgres-bounce proves broadcast recovery (PUT post-bounce, assert sync) and uses `up -d --wait`. - Remove dead 404 branch in refresh_all; boot clock starts at restart; gitea-admin `|| true` -> "already exists"-only guard; README reworded. opal-server: - /internal stats route now takes the JWTAuthenticator dependency (protected when JWT on, no-op in the test bed); unit test asserts enforcement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- snapshot a single /internal stats read per poll in the churn-drain and boot gates (consistent multi-key observation; fewer HTTP round-trips) - document why a 200 from the healthy scope can't be a masked default bundle, and why the stats route is intentionally a sync def - pin alpine/socat and the seed image's pip deps for reproducibility Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-environment-one-big-pr
…repo Ziv (PR review, round 2) caught that the offline-resilience gate can false-PASS in the full-suite run. The "healthy" scope pointed at policy-repo-0000 (list_seeded_repos(1)[0]), but on-disk clones are keyed by URL-hash and survive compose restart/stop/start (opal_server mounts no volume at /opal; only `down -v` wipes them). test_boot/test_leak run first (alphabetical) and already clone every seeded repo, so the healthy scope hit the existing clone via _discover_repository, skipped _clone(), and served 200 without ever touching the saturated fetch executor — the gate that must FAIL on this branch (no PR3 timeout) passed. Fix: seed a reserved repo (policy-repo-healthy-probe) outside the numeric policy-repo-NNNN range that no boot/leak test enumerates, and point the healthy probe at it. A never-cloned repo forces a genuine fresh clone through the starved pool, so the gate fails correctly. The seed- completeness check in conftest now covers the reserved repo too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…view) Follow-up to PR review of the git-leak/resilience test bed: - hard_reset: always restart opal_server + wait_healthy via a finally, so a failed redis FLUSHALL can't leave the server stopped (which would fail every later session-scoped test and, running in a test finally, mask the result). - delete_all_scopes drain: a transient /internal read error no longer counts as a successful drain (was `except: return`); keep polling to the deadline so a not-yet-drained cache can't leak into the next test once PR2 lands. - use stats(samples=1) for the zero-waiting drain/empty polls (the peak-merge only matters for load assertions; this also drops 3x HTTP per poll). - resilience: narrow broad `except Exception` to requests.RequestException (and RuntimeError for wait_healthy timeout) so harness bugs surface instead of masquerading as "never served"/"never recovered". - resilience: collapse `assert opal.stats()` + redundant re-read into one read. - debug_stats_test: assert rss_kb > 0 on Linux (was `>= 0`, which passed for the wrong reason where /proc is absent and RSS reads fall back to 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-environment-one-big-pr
Review notes (planned with the
|
… repo handles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the deleted scope's source before scanning siblings (fixing the loop-variable shadowing bug where the clone path was computed from the last-iterated scope). Drop the cached pygit2.Repository handle via forget_repo and pop repos_last_fetched when no sibling shares the source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…terating) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
d7630de to
8db1b6b
Compare
…emory-leak-fix-gitpolicyfetcher-caches-webhook-task # Conflicts: # app-tests/git-leak/README.md # app-tests/git-leak/conftest.py # app-tests/git-leak/docker-compose.yml # app-tests/git-leak/helpers.py # app-tests/git-leak/seed/seed_gitea.py # app-tests/git-leak/test_boot.py # app-tests/git-leak/test_leak.py # app-tests/git-leak/test_resilience.py # packages/opal-server/opal_server/tests/debug_stats_endpoint_test.py
✅ Deploy Preview for opal-docs canceled.
|
The PR2 purge in ScopesService.delete_scope was dead code: the HTTP
DELETE /scopes/{scope_id} route deleted the scope straight from the
ScopeRepository, so the git-leak churn gate stayed red with all cache
entries intact. Wire the route to the service (keeping delete-of-missing
a 204 no-op), and also drop the source_id-keyed repo_locks entry, which
neither forget_repo (path-keyed) nor the service purge released.
Verified against the app-tests/git-leak bed: test_churn_releases_caches
now passes and test_shared_repo_survives_sibling_scope_delete still
guards the shared-clone case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI fixes: - pre-commit: isort wanted `import pytest` in the top import block of the new test file; it was missed locally because `pre-commit run --all-files` only covers tracked files and the file was untracked when formatters ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Review summary — 3-agent consolidation (python-pro · fastapi-pro · security-auditor)
Reviewed at 8d5cfdf. The two genuinely-fixed bugs — the webhook remove-while-iterating cleanup and the old loop-variable shadowing in delete_scope — are real and correctly fixed (confirmed by reverting each hunk against the new tests). But the core deliverable (purge GitPolicyFetcher caches on scope delete) has correctness gaps that block approval.
Requesting changes — highest severity is CRITICAL.
Blocking
- CRITICAL —
scopes/service.py—delete_scopemutates GitPolicyFetcher's lock-guarded shared state (rmtree+forget_repo/Repository.free()+ cache pops) without acquiringrepo_locks[source_id], racing an in-flightrun_in_executorgit fetch → nativepygit2.Repository.free()use-after-free / clone corruption, plus a lock-identity hazard from poppingrepo_locks. The "serial only — no concurrency introduced" framing does not hold:run_syncisasyncio.get_event_loop().run_in_executor, so the fetch runs on a thread while the loop is free to run the DELETE. - HIGH —
scopes/service.py/scopes/api.py— the purge is process-local. In the default multi-worker topology it only affects the worker serving the DELETE, so the leader's handles (the bulk of the leak) persist until restart. Other scope mutations broadcast to the leader over pubsub; this one doesn't.
Should-fix (MEDIUM)
service.py:191—rmtree(ignore_errors=True)silently orphans deleted tenants' policy clones on any fs failure (newly live via the route wiring).git_fetcher.py:389—forget_reposwallowsfree()exceptions at DEBUG with no context.policy/watcher/task.py:34— finished webhook tasks dropped without retrievingexception().- tests — no coverage for
free()raising or for the delete-vs-fetch race.
Minor (LOW)
service.py:166— the deleted scope is cast toGitPolicyScopeSourceunconditionally while siblings areisinstance-guarded; currently unreachable given the single-member schema, but inconsistent and would raise a confusingAttributeErrorrather than fail fast.service.py:187— the new skip log dropped the sharing scope id that the old message included (minor diagnostic regression).
Per-finding detail is in the inline comments. Review consolidated from three specialist agents; every claim was re-verified against the code.
🤖 Reviewed with Claude Code
The webhook task sweep dropped finished tasks without inspecting t.exception(), and stop() only gathered self._tasks — so an error raised inside trigger() never reached the app logger, surfacing only as asyncio's generic "Task exception was never retrieved" at GC time. Log failed trigger tasks at ERROR during the sweep and include webhook tasks in the stop() gather. Also de-flake the cleanup test: await the freshly scheduled trigger task directly instead of relying on an asyncio.sleep(0) scheduler tick, and add a test asserting the failure of a trigger task is retrieved and logged. Addresses review comments: - #923 (comment) (@zeevmoney) - #923 (comment) (@zeevmoney) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-worker attribution for the git-leak bed: the caches are per-process, so the pid identifies which worker answered and the key lists let the invariant checker compare cache contents against live scopes and disk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A failed fetch left the pre-written stamp in place, so _was_fetched_after() suppressed the next webhook-requested forced refresh and the scope served stale policy until an unrelated force. Stamp the fetch START time, written only on success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d stamp Review follow-up: a completion-time regression would have passed the existing tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd clear partial dirs The recovery path rmtree'd a bad clone but kept its cached pygit2.Repository, so the stale handle re-invalidated every fresh clone (infinite re-clone loop, leaked handle per cycle) — recovery only worked on a cold cache. forget_repo the handle first, clear any pre-existing (partial) dir before cloning, and cache the fresh clone's handle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
git_fetcher_cache_stats() runs on a Starlette worker thread while the GitPolicyFetcher repo_locks/repos/repos_last_fetched dicts are mutated on the event-loop/executor threads; iterating a live dict's keys() across that race can raise "dictionary changed size during iteration", and computing len() and sorted(keys()) as two independent reads of a mutating dict can return a self-contradictory count/keys pair (e.g. repos=1 while listing 2 keys). Fix by snapshotting each cache once via dict.copy() (a single, GIL-atomic C-level op) and deriving both the count and the key list from that same snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_recovery_forgets_stale_cached_handle only checked that the broken handle was evicted from the cache, not that it was free()d — a regression to a bare .pop() would still pass while reintroducing the fd/mmap leak the recovery path is meant to close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… git-leak bed Every bed test now asserts at teardown: no orphan clone dirs (I1), cache keys consistent with disk and live scopes (I2-I4), worker pid-set unchanged (I5 - a crashed worker resets the caches and can fake a clean drain), server liveness (I6). Red gates exempt named invariants via marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up (deferred behind in-flight work): counts merge with max() while pid/key-lists are last-wins, so cross-field consistency is only guaranteed at samples=1; annotate Dict[str, Any] accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seeded random put/refresh bursts with settled end-of-round deletes (plus a ghost delete to keep the 204 no-op path exercised); repo_locks must not exceed live sources at every settle point. Replay any failure with CHURN_SEED=<printed seed>. Two deliberate constraints, both to be lifted when PR3's fleet purge lands (no silent caps): - repoints are excluded: a put on a live scope reuses its existing repo, since a repoint orphans the old source's caches by design today (the red repoint gate covers it). - deletes run only after every live scope has settled: the driver's development run surfaced (deterministically, seed 309006536) that a DELETE racing an in-flight sync loses its purge — the sync re-populates the dead scope's caches and clone dir. Same PR3 class; deterministic red-gate coverage lands in test_repoint_during_inflight_fetch_drains_old_source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… behavior Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unded-return red until PR3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te-path purge) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e loop) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ses the warm-handle branch The previous corruption (rm -rf .git/objects) deleted the directory NODE, so pygit2.discover_repository returned None and recovery went through the repo-not-found -> _clone() branch, never touching the cached-handle (_get_valid_repo -> forget_repo) branch the Bug A fix lives in. Emptying the objects dir's CONTENTS keeps discovery succeeding and forces recovery through the warm cached handle; a new assert on the "Deleting invalid repo" log pins the branch taken. NOTE: this gate is currently RED — a real finding. With objects/ emptied in place, _get_valid_repo() still judges the repo valid (open + remote-URL verification raise no pygit2.GitError), so the invalid-repo recovery branch never fires; the failure surfaces later in make_bundle (_get_current_branch_head -> "SHA ... missing") and the scope serves 500 forever with no re-clone. The corrupted-but-discoverable case is not covered by the landed Bug A fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A clone can be discoverable yet unusable: refs and config intact but the object store gutted (crash mid-gc, disk corruption). _get_valid_repo() judged such a repo valid (open + remote-URL verification raise no GitError), a forced fetch then negotiated "up to date" against the intact refs and downloaded nothing, and every policy read failed in make_bundle with "SHA ... missing" — the scope served 500s forever with no self-heal. Fix: validate that the tracked branch's head object is actually readable FROM DISK before declaring the repo valid. Crucially the check uses a short-lived fresh Repository handle, not the cached warm handle: the warm handle keeps deleted pack files readable through its open mmaps (unlink does not invalidate them) and reports the object as present even after the store is emptied on disk — verified empirically in the test bed, where a cached-handle check never fired. An unreadable head object now routes the repo to the existing invalid-repo recovery branch (forget cached handle -> delete dir -> re-clone, exactly once). Residual: partial corruption deeper in the object graph is not caught (that would need fsck-grade checks); only head-object readability is validated. The system gate added in 52a1fe9 (in-place object-store gutting under a warm handle cache) flips green with this fix, including its "Deleting invalid repo" branch-pin assert: detection warning, one re-clone, stable clone count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…phan sweep) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ks stay meaningful Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…timeout) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d until sweep) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(red until PR3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rn gate docstring Purges are process-local; the LEADER syncs accumulate via its watcher (scopes/task.py), and any bundle-serving worker additionally caches a pygit2 handle. Only the DELETE-serving worker purges, so every accumulation on a different worker outlives the scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion tests Adds 14 matrix rows for the new test_transitions.py/test_boot_states.py/test_remote_transitions.py tests and an Invariants section documenting I1-I6 and the invariant_exempt/allow_worker_restart markers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Once min_pids distinct pids are observed, take one grace sample to ensure freshness, then break. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR2 — Memory leak fix (GitPolicyFetcher caches + webhook task list)
Linear: PER-15156
What
Stops OPAL-server memory growth by releasing the leaked
pygit2.Repositoryhandle when a scope is deleted, and fixes the webhook task-list cleanup bug. Serial only — no concurrency introduced (the delete/fetch race is deferred to PR4).Changes
GitPolicyFetcher.forget_repo(path)(git_fetcher.py) — popsrepos[path]and callsRepository.free()when available (guarded; falls back to GC), releasing OS fds + mmapped pack indexes.ScopesService.delete_scope(scopes/service.py) — captures the deleted scope's source before scanning siblings (fixes a latent loop-variable shadowing bug that computed the clone path from the wrong scope), then removes the clone dir +forget_repo+ popsrepos_last_fetchedandrepo_lockswhen no sibling shares the source.DELETE /scopes/{scope_id}route (scopes/api.py,server.py) — now actually callsScopesService.delete_scope(92c8515). Previously the route deleted the scope straight from theScopeRepository, so the purge above was dead code in a running server — unit tests passed (they call the service directly) while the deployed DELETE path never purged anything.init_scope_routerreceives the service via the factory; deleting a missing scope remains a 204 no-op.BasePolicyWatcherTask._on_webhook(policy/watcher/task.py) — replaces remove-while-iterating (which skipped elements, leaking finished tasks) with a list rebuild.Review-driven fixes (beyond the original plan)
url_shared, but clones are keyed strictly per-source_id(base_dir/git_sources/<source_id>). WithOPAL_SCOPES_REPO_CLONES_SHARDS > 1, same-url/different-branch scopes resolve to different clone dirs — sourl_sharedwould skip deleting a clone that no sibling actually uses. Both gates now usesource_id_shared, with a dedicatedSHARDS>1test.repo_lockspurging ("lock-identity hazard"). The merged git-leak bed's churn gate asserts all three caches drain, and an un-poppedasyncio.Lockper deleted repo is still an unbounded leak — so the purge now popsrepo_locks[source_id]too. The delete path already removes the clone dir out from under any in-flight sync, so the lock pop does not add a new race class; true delete/fetch serialization stays deferred to PR4.Tests
Unit tests (TDD):
forget_repo_test.py(3),delete_scope_cache_purge_test.py(3, incl. the shards>1 divergence case, now seeding/assertingrepo_locksincl. the sibling-survives case),webhook_task_cleanup_test.py(1),delete_scope_route_test.py(2, new) — proves DELETE/scopes/{id}drains the caches through the real route and that delete-of-missing stays 204.Verification (2026-07-12, commit 92c8515)
app-tests/git-leak/, real docker stack, 20 scopes):test_churn_releases_caches✅test_shared_repo_survives_sibling_scope_delete✅test_repeat_sync_rss_stays_bounded✅test_scope_repoint_releases_old_repo_cache(purge on scope update/repoint — out of this PR's scope, currently unowned) andtest_offline_repo_does_not_block_healthy_scopes(PR3's gate).pre-commit run --all-files(pinned black/isort/docformatter): clean; zeroopal_common/opal_clientchanges; no config key added.🤖 Generated with Claude Code
Second wave (2026-07-15, commits
36b020f5..bf53477a): lifecycle test matrix + 3 more fixesFollow-up to the review: we enumerated the scope/clone/cache lifecycle as a state machine and added tests for the transitions and start states the end-state gates never drove (both review findings lived there). The new tests then caught three real bugs, each confirmed by a failing test before its fix:
repos_last_fetchedwas stamped before the fetch, so a raising fetch still looked fresh to_was_fetched_after(), silently skipping the webhook-requested retry. Now stamps the fetch start time, written only on success (6b443515).pygit2.Repositoryand never cached the fresh clone's handle, so the stale handle re-invalidated every fresh clone (infinite re-clone loop, one leaked handle per cycle);_clone()also now clears partial dirs a failed clone leaves behind, which previously wedged every retry (138deeb4).Repository(freed infinally) and routes to recovery (8ccd3ace).New test infrastructure (
app-tests/git-leak/)invariants.py) at every test's teardown: no orphan clone dirs, cache keys consistent with disk + live scopes, worker pid-set stable (a crashed worker resets the caches and could previously fake a clean drain), liveness.pid+ cache keys via atomic snapshots (fixes adictionary changed size during iteration500 under concurrent load and self-inconsistent count/keys responses).Verification (commit
bf53477a)free()vs unlocked bundle-read race window.