Skip to content

fix(opal-server): memory leak — purge GitPolicyFetcher caches on scope delete + webhook task cleanup (PR2)#923

Open
Zivxx wants to merge 66 commits into
masterfrom
ziv/per-15156-pr2-memory-leak-fix-gitpolicyfetcher-caches-webhook-task
Open

fix(opal-server): memory leak — purge GitPolicyFetcher caches on scope delete + webhook task cleanup (PR2)#923
Zivxx wants to merge 66 commits into
masterfrom
ziv/per-15156-pr2-memory-leak-fix-gitpolicyfetcher-caches-webhook-task

Conversation

@Zivxx

@Zivxx Zivxx commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

PR2 — Memory leak fix (GitPolicyFetcher caches + webhook task list)

Linear: PER-15156

Originally stacked on PR1 (PER-15155); PR1 has since merged (#922) and master is merged into this branch, so the diff now targets master directly. PR1's git-leak bed is the regression gate this PR turns green.

What

Stops OPAL-server memory growth by releasing the leaked pygit2.Repository handle 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) — pops repos[path] and calls Repository.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 + pops repos_last_fetched and repo_locks when no sibling shares the source.
  • DELETE /scopes/{scope_id} route (scopes/api.py, server.py) — now actually calls ScopesService.delete_scope (92c8515). Previously the route deleted the scope straight from the ScopeRepository, 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_router receives 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)

  • The clone-deletion gate originally used url_shared, but clones are keyed strictly per-source_id (base_dir/git_sources/<source_id>). With OPAL_SCOPES_REPO_CLONES_SHARDS > 1, same-url/different-branch scopes resolve to different clone dirs — so url_shared would skip deleting a clone that no sibling actually uses. Both gates now use source_id_shared, with a dedicated SHARDS>1 test.
  • An earlier revision of this PR deferred repo_locks purging ("lock-identity hazard"). The merged git-leak bed's churn gate asserts all three caches drain, and an un-popped asyncio.Lock per deleted repo is still an unbounded leak — so the purge now pops repo_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/asserting repo_locks incl. 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)

  • opal-server unit suite: 81 passed
  • git-leak bed (app-tests/git-leak/, real docker stack, 20 scopes): test_churn_releases_cachestest_shared_repo_survives_sibling_scope_deletetest_repeat_sync_rss_stays_bounded
  • Still red by design: test_scope_repoint_releases_old_repo_cache (purge on scope update/repoint — out of this PR's scope, currently unowned) and test_offline_repo_does_not_block_healthy_scopes (PR3's gate).
  • ⚠️ Correction: an earlier revision of this description claimed the churn gate had been verified green pre-review. That claim was wrong — the DELETE route bypassed the service in every committed revision, so the gate could not have passed as described. It was caught by re-running the merged (hardened) bed; see the run-by-run breakdown in the review notes.
  • pre-commit run --all-files (pinned black/isort/docformatter): clean; zero opal_common/opal_client changes; no config key added.

🤖 Generated with Claude Code


Second wave (2026-07-15, commits 36b020f5..bf53477a): lifecycle test matrix + 3 more fixes

Follow-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:

  1. Failed fetch suppressed the next forced refreshrepos_last_fetched was 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).
  2. Invalid-clone recovery looped forever on a warm handle cache — recovery never dropped the cached pygit2.Repository and 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).
  3. Corrupted-but-discoverable clones never healed — validation only checked "opens + remote matches"; a gutted object store passed, fetch negotiated "up to date" against intact refs, and bundle serving 500'd forever. The warm handle's open mmaps hide the corruption, so detection probes disk truth via a short-lived fresh Repository (freed in finally) and routes to recovery (8ccd3ace).

New test infrastructure (app-tests/git-leak/)

  • Invariant checker (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.
  • 14 new bed tests: green guards (delete/recreate storm, seeded randomized churn, delete-vs-hung-fetch no-crash, warm boot, corruption recovery, upstream force-push / branch-delete characterizations) and 7 documented red gates whose docstrings name what flips them: fetch timeout (bounded DELETE, boot-under-unreachable-remotes), update-path/broadcast purge (repoint-during-fetch, multi-worker drain), orphan sweep (orphan dir, redis-wipe boot, shard-reconfig orphan half). The gate matrix in the bed's README has the full map.
  • The internal stats endpoint (off by default) now reports per-worker pid + cache keys via atomic snapshots (fixes a dictionary changed size during iteration 500 under concurrent load and self-inconsistent count/keys responses).

Verification (commit bf53477a)

  • opal-server unit suite: 96 passed
  • Full git-leak bed (docker, 20 scopes): 12 passed; 9 failed = exactly the documented red gates (the 2 pre-existing + the 7 new), zero unexpected failures, zero cross-test invariant violations.
  • Also confirmed & documented in code comments for the follow-up PR: a DELETE racing its scope's in-flight sync loses the purge (found by the randomized driver, deterministically replayable); the recovery free() vs unlocked bundle-read race window.

Round-3 follow-ups (commits bd6c86d5..bf72ca5f)

All 5 non-blocking round-3 review comments addressed: the webhook failure log now reuses the shared redact_url_in_text helper, the delete purge runs in a finally (an ambiguous store outcome can no longer orphan it), delete_scope is back under the complexity limit via _purge_source_cache_if_unshared, sync_scopes re-checks scope liveness right before each queued sync, and the clone-vanish → default-bundle fallback is regression-tested.

Known limitation (until PR3)

A 204 DELETE is not a durable disk-cleanup guarantee under concurrent sync: a sync that loaded the scope before the delete and reaches it mid-sync can still re-clone the dead scope's repo and re-populate the fetcher caches (bounded, same-tenant, never mis-served — GET /{scope_id}/policy 404s the dead scope). The liveness re-check added here shrinks the window to the sync itself; the full fix (leader-routed purge + scope-liveness check before clone) lands in PR3. Relatedly, a live scope whose clone dir transiently vanishes mid-request is briefly served the default scope's bundle rather than a retryable error; switching that to a 503 is also deferred to PR3.

dshoen619 and others added 6 commits June 23, 2026 12:24
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>
@linear-code

linear-code Bot commented Jun 23, 2026

Copy link
Copy Markdown

PER-15156

dshoen619 and others added 5 commits June 23, 2026 13:24
- 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>
@Zivxx
Zivxx marked this pull request as ready for review June 28, 2026 14:03
dshoen619 and others added 4 commits June 28, 2026 18:20
…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>
@zeevmoney

Copy link
Copy Markdown
Contributor

Review notes (planned with the opal-development skill)

Faithful to the plan, and the source_id_shared gate (instead of the plan's url_shared) is a genuine improvement: with SCOPES_REPO_CLONES_SHARDS > 1, same-URL/different-branch siblings get different source_ids and therefore different clone dirs, so gating clone deletion on the URL would have skipped deleting the deleted scope's own clone — leaking exactly what this PR fixes. The added shards=4 test covers it, and the loop-variable shadowing bug is fixed by capturing the deleted source before the sibling scan. repo_locks correctly left untouched.

Three small notes (none blocking):

  1. Its only effective gate in PR1 (test(opal-server): git leak/resilience test environment (PR1) #922) is test_churn_releases_caches. The sibling test_repeat_sync_does_not_grow is non-gating — caches are source_id-keyed, so a re-sync of identical scopes can't grow the counts for any implementation; it's fine as a loose RSS guard but shouldn't be read as proof of this fix.
  2. forget_repo's except / logger.debug GC-fallback branch is untested — i.e. the path where Repository.free() itself raises. A one-line test (a fake repo whose free() raises, asserting the entry is still popped and no exception escapes) would close it.
  3. Tracking note: deferring the repo_locks purge is the right call (the lock-identity hazard is real), but it does leave a tiny, bounded residual leak — one asyncio.Lock per distinct source ever seen. Worth keeping the structural-cleanup follow-up on the board so it isn't forgotten.

Depends on #922 for its end-to-end gate; suggested merge order is #922 → this.

Zivxx and others added 3 commits July 1, 2026 03:21
… 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>
@Zivxx
Zivxx force-pushed the ziv/per-15156-pr2-memory-leak-fix-gitpolicyfetcher-caches-webhook-task branch from d7630de to 8db1b6b Compare July 1, 2026 00:22
Base automatically changed from david/per-15155-pr1-git-leakresilience-test-environment-one-big-pr to master July 12, 2026 12:22
…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
@netlify

netlify Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploy Preview for opal-docs canceled.

Name Link
🔨 Latest commit bf72ca5
🔍 Latest deploy log https://app.netlify.com/projects/opal-docs/deploys/6a58d72c439cfd0007ffb3d2

Zivxx and others added 2 commits July 12, 2026 16:07
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 zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • CRITICALscopes/service.pydelete_scope mutates GitPolicyFetcher's lock-guarded shared state (rmtree + forget_repo/Repository.free() + cache pops) without acquiring repo_locks[source_id], racing an in-flight run_in_executor git fetch → native pygit2.Repository.free() use-after-free / clone corruption, plus a lock-identity hazard from popping repo_locks. The "serial only — no concurrency introduced" framing does not hold: run_sync is asyncio.get_event_loop().run_in_executor, so the fetch runs on a thread while the loop is free to run the DELETE.
  • HIGHscopes/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:191rmtree(ignore_errors=True) silently orphans deleted tenants' policy clones on any fs failure (newly live via the route wiring).
  • git_fetcher.py:389forget_repo swallows free() exceptions at DEBUG with no context.
  • policy/watcher/task.py:34 — finished webhook tasks dropped without retrieving exception().
  • tests — no coverage for free() raising or for the delete-vs-fetch race.

Minor (LOW)

  • service.py:166 — the deleted scope is cast to GitPolicyScopeSource unconditionally while siblings are isinstance-guarded; currently unreachable given the single-member schema, but inconsistent and would raise a confusing AttributeError rather 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

Comment thread packages/opal-server/opal_server/scopes/service.py Outdated
Comment thread packages/opal-server/opal_server/scopes/api.py
Comment thread packages/opal-server/opal_server/scopes/service.py Outdated
Comment thread packages/opal-server/opal_server/git_fetcher.py Outdated
Comment thread packages/opal-server/opal_server/policy/watcher/task.py
Comment thread packages/opal-server/opal_server/tests/forget_repo_test.py
Zivxx and others added 13 commits July 14, 2026 13:57
…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>

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ bf53477a — 4-reviewer consolidation (python-pro · fastapi-pro · security-auditor · opal-domain)

Re-reviewed after the rework (10 commits since the last review at 8d5cfdf). All 8 prior findings are genuinely resolved or honestly re-scoped — verified in code, not just from commit messages:

  • prior CRITICAL (delete-vs-fetch race) → fixed by the new lock_source (serializes fetch vs delete; the post-acquire is lock re-check closes the lock-identity hazard).
  • prior HIGH (multi-worker) → documented as process-local best-effort deferred to PR3 (but see MEDIUM below — the framing undersells it).
  • prior M1/M2/M3/L1/L2 → all fixed (warning+context on free(); rmtree OSError logged; webhook exceptions retrieved + gathered in stop(); non-git guard; sharing-scope-id restored) and now unit-tested.

However, the rework introduces new correctness issues that block approval.

Requesting changes — highest severity is CRITICAL.

Blocking

  • CRITICAL scopes/api.py — the make_bundle / GET /scopes/{id}/policy path (the client-polled hot path) reads the cached pygit2 handle on a run_in_executor thread without lock_source, so the PR's new forget_repoRepository.free() (from a concurrent DELETE / self-heal on the same worker) can free it mid-resolve_refish → use-after-free / worker crash. .free() is new in this PR, so the fix introduces this hazard on a path it doesn't cover.
  • HIGH scopes/service.py — the sibling-source check runs before lock_source and isn't re-checked under it; two concurrent DELETEs of scopes sharing a source_id both skip the purge → the exact clone+handle leak this PR exists to close, reintroduced (found independently by all three general reviewers; untested).

Should-fix (MEDIUM)

  • service.py:213 — delete does off-leader, cross-process rmtree on the shared tree, breaking the "only the leader mutates the working tree" invariant that put_scope/refresh_scope honor via pub/sub; the PR3 deferral note frames this as only a leader-cache leak.
  • git_fetcher.py:235 & :251 — the rmtree(ignore_errors=True) silent-orphan anti-pattern just fixed in delete_scope is reintroduced in the invalid-repo self-heal and _clone pre-clean.
  • git_fetcher.py:295 — the gutted-object-store probe opens a fresh Repository synchronously on the event loop every sync cycle, per scope (N per shared source_id), blocking the loop.
  • tests — _get_valid_repo's healthy branches, the concurrent-sibling-delete race, and the make_bundle race are all uncovered.

Minor (LOW)

  • policy/watcher/task.py:38logger.error(...{t.exception()!r}...) isn't URL-redacted (credentialed remote URL can leak via the classic watcher path).
  • git_fetcher.py:270 — corruption-triggered reclone never refreshes repos_last_fetched (stale timestamp → one redundant forced fetch).
  • Also noted (not posted inline): lock_source setdefault(sid, asyncio.Lock()) eagerly allocates a throwaway Lock on the hot path; debug_stats return type narrowed to bare Dict despite mixed int/list values; lock_source's while True has a theoretical (impractical) starvation under adversarial delete/recreate churn; the /internal stats payload now includes absolute clone paths + source_id keys (behind DEBUG_INTERNAL_STATS + auth, but amplifies the pre-existing missing-AUTH_PUBLIC_KEY bypass); the probe's "unlink doesn't invalidate mmap" comment is POSIX-specific.

Per-finding detail is in the inline comments. Consolidated from four specialist reviewers (including an OPAL-domain pass using the repo's contributor skill); every claim re-verified against the code.

🤖 Reviewed with Claude Code

Comment thread packages/opal-server/opal_server/git_fetcher.py
Comment thread packages/opal-server/opal_server/scopes/service.py Outdated
Comment thread packages/opal-server/opal_server/scopes/service.py Outdated
Comment thread packages/opal-server/opal_server/git_fetcher.py Outdated
Comment thread packages/opal-server/opal_server/git_fetcher.py
Comment thread packages/opal-server/opal_server/policy/watcher/task.py Outdated
Comment thread packages/opal-server/opal_server/git_fetcher.py
Zivxx and others added 10 commits July 15, 2026 16:15
…ff-lock

_get_current_branch_head() (called from make_bundle, which is invoked via
run_sync(fetcher.make_bundle, ...) on executor threads by the policy-bundle
route, and directly on the loop by _generate_default_scope_bundle) was
calling self._get_repo(), which reads/populates the shared class-level
repos cache. Both call sites run outside lock_source, so the cached handle
can be concurrently .free()'d by forget_repo() during a scope delete or
invalid-repo recovery — a native use-after-free on the pygit2 handle.
asyncio locks don't exclude executor threads, so the lock around
fetch_and_notify_on_changes does not protect this path.

Fixed by opening a short-lived fresh Repository handle per call (freed in a
finally), the same fresh-probe pattern _get_valid_repo already uses for its
disk-truth check. All remaining shared cached-handle access now happens
under lock_source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k (delete TOCTOU)

delete_scope's sibling-scope check ran before lock_source and before the
record delete. Two concurrent deletes of scopes sharing a source_id could
each see the other still present in the scope store and both skip the
purge, permanently orphaning the clone dir, pygit2 handle cache, and
repos_last_fetched entry.

Restructured so the record delete, sibling re-check, and purge all happen
inside a single lock_source(deleted_source_id) critical section, with the
record delete first — so whichever of the two concurrent deletes finishes
last sees no remaining sharer and performs the purge. Also switched the
rmtree call to run_sync so it no longer blocks the event loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The invalid-repo recovery rmtree and _clone's partial-dir pre-clean both
used ignore_errors=True, silently swallowing any OSError beyond a simple
missing directory (e.g. a permissions problem or a busy mount) with no
trace in the logs. Replaced both with the same try/except pattern the
delete-scope purge path already uses: tolerate FileNotFoundError (already
gone is the intended end state) but log other OSErrors as a warning so a
persistent failure is discoverable instead of silently retried forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecloned repos

_get_valid_repo() opens a fresh Repository handle from disk and walks refs
to detect a gutted object store; called synchronously inline in
fetch_and_notify_on_changes, a slow disk stalls the event loop and every
other request being served on the worker. Route it through run_sync so it
runs on the executor.

Also stamp repos_last_fetched on a successful clone: a reclone just
downloaded current remote state, so without the stamp _was_fetched_after()
sees no record and _should_fetch() forces a redundant fetch on the very
next sync cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Webhook trigger failures were logged with repr(exception) verbatim. Git
exceptions from a failed fetch/clone can embed a credentialed remote URL
(https://user:token@host/repo) directly in the exception message, so a
webhook failure would leak the credential into the server logs. Strip any
`://user:pass@`-shaped segment before logging.

Added a unit test covering a trigger failure whose message embeds a
credentialed URL: the logged ERROR must contain the redacted form and must
not contain the raw credential.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cycle

_get_valid_repo's failure paths (stale cached handle, gutted object store)
were already covered, but its two healthy paths were not: (a) the tracked
branch hasn't been fetched yet (probe.lookup_reference raises KeyError,
tolerated as "fetch path handles that"), and (b) the probe confirms the
head object is actually readable from disk. Both must return the cached
repo unchanged and free() the short-lived disk probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k fails

delete_scope's sibling re-check runs after the record delete; its all()
does a full store scan + Scope.parse_raw, so a transient store error or
one malformed record raises. That exception escaped the source lock with
NOTHING purged, and because the record is already deleted the client's
retry is a 204 no-op (ScopeNotFoundError) — the purge became permanently
unreachable, orphaning the clone dir and both fetcher caches.

The re-check failure is now downgraded to a warning and treated as "no
sharer" (purge). Over-purging self-heals — a surviving sibling re-clones
on its next sync — while under-purging is a permanent leak with no retry
path. The record-delete itself still propagates failures normally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…outing note

The invalid-repo recovery rmtree and _clone's partial-dir pre-clean ran
synchronously on the event loop while this same PR offloads the
delete-path rmtree — inconsistent, and a large clone dir on a slow disk
stalls every request on the worker. Both now go through run_sync inside
their existing try/excepts.

Also widened delete_scope's PR3 NOTE: besides the cross-process leader
gap, the same class exists in-process — a sync that loaded the scope
before the delete and acquires the fresh lock after the purge re-clones
and re-populates the caches for the dead scope. PR3's purge routing must
include a scope-liveness check before clone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nishes mid-request

Both bundle-route except clauses (make_bundle and
_generate_default_scope_bundle) caught InvalidGitRepositoryError,
pygit2.GitError, and ValueError but missed GitPython's NoSuchPathError —
raised when a concurrent scope delete or invalid-repo recovery fully
rmtree'd the clone dir before Repo() opens it. That turned a transient
race into a 500 instead of the intended default-scope fallback. Added
NoSuchPathError to both tuples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reclone stamp added earlier in this PR recorded completion time,
contradicting the start-time rule the fetch path documents at the top of
the same function: negotiation reflects remote state at operation START,
so a clone that started after a webhook's req_time already satisfies it,
while a completion stamp can wrongly suppress a forced refresh requested
mid-clone. Capture clone_started immediately before run_sync(
clone_repository, ...) and stamp that value in the success branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-3 review @ a295717d — 4-reviewer consolidation (python-pro · fastapi-pro · security-auditor · opal-domain)

Approving. The 10 commits since round 2 resolve all 8 round-2 findings — verified against code (python-pro mutation-tested the purge/TOCTOU fixes: the new tests fail on the old code, pass on the new), not just commit messages:

  • CRITICAL (make_bundle UAF) → fixed: _get_current_branch_head opens a fresh short-lived Repository per call and frees it, so the client-polled bundle path never touches the cached handle; audited — the cached handle is now only ever read/written/freed under lock_source.
  • HIGH (sibling-delete TOCTOU) → fixed: record-delete + sibling re-check + purge are one critical section under lock_source, record deleted first, so the last concurrent deleter always purges exactly once (regression-tested).
  • MEDIUM/LOW round-2 items (silent rmtree, probe blocking, off-leader note, credential redaction, reclone timestamp, tests) → all fixed.

No CRITICAL, no crash, no corruption, no cross-tenant exposure, and no security regression remain in the current code. The highest-severity remaining item — the delete-vs-sync re-population leak — is a bounded, same-tenant, never-mis-served resource leak, documented with a seeded churn repro and explicitly deferred to PR3 (leader-routed purge + scope-liveness check); 3 of 4 reviewers (including the security pass) consider that an acceptable scope boundary for PR2 of the series.

Non-blocking follow-ups (posted inline)

  • MEDIUM task.py — the round-2 credential regex is redundant and weaker than the existing global opal_common.logger scrubber; remove it / use redact_url_in_text.
  • MEDIUM service.pydelete_scope is now ~93 lines / complexity 10, over the project's ≤8 hard limit; extract the purge helper.
  • MEDIUM service.py — the deferred delete-vs-sync leak: take the cheap scope_id=-in-sync_scopes mitigation now, add a delete-specific red gate, and surface it as an explicit known-limitation (a 204 DELETE isn't yet a durable cleanup guarantee under concurrent sync).
  • MEDIUM service.py/api.py — the defensive over-purge can transiently serve the default bundle to a live sibling; the NoSuchPathError→default-bundle path is untested; consider a retryable error over silent policy substitution for a still-live scope.
  • LOW service.py — the delete-record-first reorder can orphan the purge on an ambiguous store failure; run the purge in a finally.
  • LOW — fresh-handle-per-bundle-request perf on the hot path (necessary UAF tradeoff; optionally cache the head hash); defensive except could log a traceback.

Consolidated from four specialist reviewers including an OPAL-domain pass using the repo's contributor skill; every claim re-verified against the code.

🤖 Reviewed with Claude Code

Comment thread packages/opal-server/opal_server/policy/watcher/task.py Outdated
Comment thread packages/opal-server/opal_server/scopes/service.py Outdated
Comment thread packages/opal-server/opal_server/scopes/service.py Outdated
Comment thread packages/opal-server/opal_server/scopes/service.py Outdated

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 additional non-blocking MEDIUM (delete_scope complexity over the project limit), posted inline. Overall verdict remains Approve.

Comment thread packages/opal-server/opal_server/scopes/service.py
Zivxx and others added 4 commits July 16, 2026 16:03
The hand-rolled _CREDENTIALED_URL_RE duplicated (a weaker form of) the
shared scrubber: it missed ?token=/?access_token= query-string
credentials that redact_url_in_text's SENSITIVE_QUERY_PARAMS path
covers. Use the vetted helper instead; kept local (rather than relying
solely on the global log patcher) because the patcher only exists once
configure_logs() has installed it.

Addresses review comments:
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge helper

An ambiguous store outcome (record delete committed server-side, error
surfaced to the client) aborted delete_scope before the purge: the
retry then hits ScopeNotFoundError -> 204 no-op, so the clone + cache
entries leaked permanently. Run the purge in a finally so a record-
delete failure can't gate cache cleanup; the error still propagates.

Extracting the sibling-check + purge into
_purge_source_cache_if_unshared / _find_scope_sharing_source also
brings delete_scope from complexity 10 back under the <=8 project
limit, with the critical section unchanged.

Addresses review comments:
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sync_scopes passed the stale all() snapshot object into sync_scope, so
a DELETE that landed while the scope was still queued re-cloned the
dead scope's repo and re-populated repos/repos_last_fetched/repo_locks
(leaked until restart) — reintroducing a bounded form of the leak this
series closes. Pass scope_id instead so sync_scope re-gets fresh state
right before use, shrinking the window from the rest of the sweep to
the sync itself; the delete race now surfaces as ScopeNotFoundError
and is logged at info. The remaining window (delete landing mid-sync)
is a known limitation deferred to PR3's leader-routed purge +
scope-liveness check.

Addresses review comments:
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The NoSuchPathError -> default-bundle branch of GET /scopes/{id}/policy
(a live scope whose clone dir vanished under a concurrent
delete/recovery) had no test; lock it in alongside the pre-existing
scope-not-found fallback. Returning a retryable error (503) for the
live-scope case instead of silently substituting the default bundle is
deferred to PR3 with the delete-routing work.

Addresses review comments:
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants