fix(opal-server): git resilience — never stuck on an offline repo (PR3)#924
fix(opal-server): git resilience — never stuck on an offline repo (PR3)#924dshoen619 wants to merge 48 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire scope clone/fetch through run_in_git_executor with SCOPES_GIT_FETCH_TIMEOUT, and broaden the _clone except to catch asyncio.TimeoutError so a hung clone is logged and the scope skipped instead of crashing the caller. Drop the now-unused run_sync import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for opal-docs canceled.
|
There was a problem hiding this comment.
Pull request overview
This PR improves opal-server resilience when syncing scope policy repos by ensuring git clone/fetch operations can’t block indefinitely or starve the server’s shared executor.
Changes:
- Add a dedicated, bounded
ThreadPoolExecutorandrun_in_git_executor(...)helper to run blocking pygit2 operations with anasyncio.wait_fortimeout. - Apply the helper + new timeout config to scope repo clone and fetch paths.
- Add server config keys for timeout and executor sizing, plus focused unit tests for timeout behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/opal-server/opal_server/git_fetcher.py | Introduces dedicated git executor + timeout helper; routes scope clone/fetch through it. |
| packages/opal-server/opal_server/config.py | Adds SCOPES_GIT_FETCH_TIMEOUT and SCOPES_GIT_MAX_WORKERS configuration. |
| packages/opal-server/opal_server/tests/git_executor_test.py | Tests config defaults and run_in_git_executor basic behavior. |
| packages/opal-server/opal_server/tests/fetch_timeout_test.py | Tests that a hanging git op times out quickly (doesn’t block). |
| .claude/plans/docs/05-config-reference.md | Internal config reference entry for the new env vars and caveat. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
On Python < 3.11 asyncio.TimeoutError is a distinct class from the builtin TimeoutError, so run_in_git_executor's wait_for timeout was not caught by `pytest.raises(TimeoutError)` — failing build (3.9)/(3.10). Normalize to the builtin TimeoutError so the documented contract holds on every supported Python, and update the _clone catch site to match. Also apply black/isort/docformatter formatting to satisfy pre-commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- run_in_git_executor: use asyncio.get_running_loop() instead of the deprecated get_event_loop() inside an async function - fetch_and_notify_on_changes: set repos_last_fetched only after a successful fetch so a timeout/error does not wrongly suppress a later force_fetch via _was_fetched_after - fetch_timeout_test: measure elapsed time with time.monotonic() Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fetch path let TimeoutError propagate to sync_scope's catch-all, which logged a full traceback at ERROR level for the expected unreachable-repo case — inconsistent with the clone path's quiet logger.error. Catch TimeoutError at the fetch site and log without a traceback, then skip (repos_last_fetched stays stale so the next cycle retries). Also shorten the hanging-thread sleeps in the timeout tests so the lingering pool thread doesn't delay process teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tuck-on-an-offline-repo
…tuck-on-an-offline-repo
Review notes — overlap + gate location (planned with the
|
- git_fetcher: document that the dedicated scope-git ThreadPoolExecutor reads SCOPES_GIT_MAX_WORKERS once on first use, caches for the process lifetime (not runtime-reconfigurable), and is never explicitly shut down — matches the PR3 design. - 05-config-reference: fix stale config.py line refs after the master merge shifted the keys — SCOPES_GIT_FETCH_TIMEOUT 150-156 -> 196-202, SCOPES_GIT_MAX_WORKERS 157-163 -> 203-209. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @zeevmoney — addressed in bad21c1. Minor items
Things to resolve
Also confirmed the two improvements you flagged are in place: |
… concurrent sync
Addresses review findings on PR3 (never stuck on an offline repo):
- CRITICAL: reset the dedicated git ThreadPoolExecutor after fork
(os.register_at_fork) and shut it down at the end of preload_scopes. A
pool built in the pre-fork gunicorn master was inherited with dead worker
threads by every worker, so the leader's scope sync stalled forever
(silent policy staleness). Verified with a fork repro on 3.12.
- HIGH: never use the non-thread-safe pygit2 Repository from two threads.
A timed-out clone/fetch keeps running on its pool thread while the
per-source_id lock is released; a per-source_id in-flight guard now skips
a cycle while a prior op is still lingering. run_in_git_executor switches
asyncio.wait_for -> asyncio.wait so a timeout never cancels the future
(the thread runs to completion and clears the in-flight marker).
- HIGH: sync scopes concurrently, bounded by SCOPES_GIT_MAX_WORKERS, so one
unreachable repo no longer serially blocks boot and other scopes.
- MEDIUM: daemon-thread pool so a lingering git op can't block interpreter
shutdown.
- MEDIUM: stamp repos_last_fetched with the fetch start time on success
(was completion time, which could wrongly suppress a force_fetch whose
req_time falls within an in-flight fetch).
- rmtree(ignore_errors) for the abandoned-clone race; harden the
env-sensitive config-defaults test; correct config/doc wording
("logged and skipped" instead of "marked failed").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Review (PER-15157 / PR3) — git resilience: never stuck on an offline repo
What this PR does. Makes scope git clone/fetch resilient to unreachable repos. It moves scope git work off the shared default executor onto a dedicated daemon-thread ThreadPoolExecutor (SCOPES_GIT_MAX_WORKERS, default 10), wraps each clone/fetch in a soft per-op timeout (SCOPES_GIT_FETCH_TIMEOUT, default 120s) via run_in_git_executor using asyncio.wait (so a timeout unblocks the event loop without cancelling the still-running pygit2 call), adds a per-repo in-flight guard so a lingering timed-out op is not touched concurrently (pygit2 Repository is not thread-safe), records repos_last_fetched only on fetch success, makes scope sync concurrent (bounded by the pool via a semaphore), and adds fork-safety (os.register_at_fork reset + shutdown_git_executor() after pre-fork preload). Plus two unit-test files and a private config-reference doc.
Verdict: REQUEST_CHANGES. Per the severity rule, there is one Postable HIGH finding (credential-redaction bypass in the new log lines) → REQUEST_CHANGES. Independently, the PR is not mergeable: GitHub reports CONFLICTING and git_fetcher.py has a content conflict with master (see Blockers).
The core design is sound and a real improvement over master: isolating git work onto a dedicated pool means a hung clone/fetch can no longer starve bundle serving or the event loop (on master these share the default executor via run_sync, so one offline repo hangs the whole server). The soft-timeout + single-flight + fork-safety mechanics are correct and well-tested (test_busy_key_stays_in_flight_until_call_returns, test_hanging_git_op_raises_timeout, the config-default tests). The two prior open Copilot threads are already addressed by the current code (asyncio.get_running_loop() replaces get_event_loop; repos_last_fetched is now written only after a successful fetch) — not re-raised.
Findings
Postable:
| # | Severity | File:Line | Category | Description |
|---|---|---|---|---|
| 1 | HIGH | packages/opal-server/opal_server/git_fetcher.py:412 (also 376, 465) | Security | New skip/timeout/clone-error log lines log the raw self._source.url; master redacts every repo URL in this file via redact_url(). Once merged these are the only un-redacted URL logs → credential exposure. redact_url isn't imported. |
| 2 | MEDIUM | .claude/plans/docs/05-config-reference.md:27 | Doc accuracy | The ceil(offline / workers) × timeout boot/poll bound is optimistic: timed-out ops keep their pool thread until the OS network timeout, so with offline >= workers healthy repos queue behind lingering threads longer than the stated bound. |
Informational (not posted inline):
| # | Severity | File:Line | Category | Description |
|---|---|---|---|---|
| 3 | LOW | packages/opal-server/opal_server/git_fetcher.py:52-91 | Maintainability | _DaemonThreadPoolExecutor._adjust_thread_count reimplements CPython concurrent.futures.thread internals. It falls back to super() if _worker/_threads_queues disappear, but a signature change to _worker (name kept, args changed) would pass the hasattr check yet break. Acceptable given the fallback + # pragma: no cover, but a fragility to track across Python upgrades (repo targets 3.9–3.12). |
| 4 | LOW | .claude/plans/docs/05-config-reference.md (new tracked file) | Cross-PR coordination | PR #922 adds .claude/ to .gitignore while this PR tracks a file under .claude/plans/docs/. Not a conflict on master today (.claude/ isn't ignored), but once both land the tracked doc sits under a gitignored path — coordinate. |
Design note (not a blocker). The "never stuck" guarantee delivered is: the event loop / HTTP surface / bundle serving never block, and each sync slot stalls at most SCOPES_GIT_FETCH_TIMEOUT. It is not that a fixed pool immediately reclaims capacity on timeout — a timed-out op lingers on its thread until the OS network timeout (by design; pygit2 can't be cancelled). For the realistic case (a few offline repos among many healthy) this is fine — the offline ops each hold one lingering thread and the rest of the pool serves healthy repos. The pathological case (offline repos >= pool size) can saturate the git pool; the daemon threads still let the process exit promptly and other server work is unaffected. Finding #2 asks the doc to reflect this precisely.
Blast radius: Production opal-server git-fetcher + scopes sync path — affects every scoped deployment's boot and poll behavior. No client-facing symbol from references/pdp-impact.md §3 is renamed/removed (new module-level helpers + two config keys only; GitPolicyFetcher public shape unchanged), so no PDP import-surface break. Two new OPAL_* keys are additive with sane defaults, no env-name collision, no OPAL_ double-prefix. Pub/sub topology unchanged — this only changes how the leader fetches git; the scope publish path is untouched, so PDP policy-update propagation is unaffected except that offline repos now fail fast (skip + retry) instead of hanging.
Isolation / scope: Well-isolated. All changes serve the stated purpose (git resilience); no unrelated refactors, no half-done work.
Blockers:
- CONFLICTING / not mergeable.
git_fetcher.pyhas a content conflict with master (master addedredact_url()to the log lines this PR also edits). Rebase/merge master and resolve — and when doing so, applyredact_url()to the new log lines too (finding #1). Reviewed here against the merge-base three-dot diff (d30e462...d31a0b63), which is unaffected by the conflict.
…tuck-on-an-offline-repo Resolve git_fetcher.py conflict: keep the PR's soft-timeout fetch path (run_in_git_executor + stamping repos_last_fetched with the start time only on success) and the combined pygit2.GitError/TimeoutError clone handler; drop master's run_sync double-fetch and its separate `except pygit2.GitError` clause. Apply master's redact_url() to the three new offline/error log lines the PR added — single-flight skip, fetch-timeout, and clone-error — so scope git URLs (which can embed user:token@host) are never logged raw once the redaction control from master is in effect (review finding #1, HIGH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2) The `ceil(offline / workers) × timeout` bound was optimistic: the soft timeout unblocks the awaiting coroutine but the timed-out op keeps its pool thread until the OS network timeout, so with offline >= workers a healthy repo queues behind lingering threads up to the OS/TCP timeout, not `ceil × timeout`. Restate the guarantee this actually delivers (event-loop isolation + a bounded per-slot stall), reference the app-tests/git-leak 40-offline/10-worker case, and fix the two config.py line refs (196-203, 204-211). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tuck-on-an-offline-repo
…-git-resilience-never-stuck-on-an-offline-repo # Conflicts: # packages/opal-server/opal_server/git_fetcher.py # packages/opal-server/opal_server/scopes/service.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… repointed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rrection race Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconciliation backstop: after boot sync and each periodic sync pass, the leader lists the clone-sources dir and rmtrees any entry no live scope references, publishing a "orphan" purge command so every worker drops its in-memory cache too. Guards: a store-scan exception aborts the whole sweep (never read a transient error as "no scopes exist"); per-orphan liveness is re-checked under lock_source before deleting; git_op_in_flight defers the reclaim to a later pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…log boot sweep failures - Strengthen test_store_error_aborts_sweep_without_deleting to use BrokenOnceRepo (raises only on first .all() call) instead of always-raising BrokenRepo, ensuring the test discriminates whether the sweep aborts at initial scan (all_calls==1) vs continuing to per-entry recheck (all_calls>1). - Wrap boot-time sweep_orphans() in try/except to log exceptions, preventing silent failure while periodic polling retries on schedule. - Mutation verified: changing purge.py return to live=set() makes test fail (all_calls==3, both clones deleted); restoring return makes it pass. - All 18 tests pass (orphan_sweep_test.py + purge_channel_test.py). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tly broken Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… DELETE/PUT)
LeaderScopePurger.handle ran purge_source_if_unshared inline, awaiting
lock_source(source_id) on the pub/sub publish path. A hung fetch holding
that lock (up to SCOPES_GIT_FETCH_TIMEOUT, 120s default) meant DELETE
/scopes/{id} and repoint PUT latency was unbounded. handle now schedules
the purge as a background task and returns immediately; the purge itself
still serializes on the source lock as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mory purges Routes and the leader's own request now publish ScopePurgeCommand with confirmed=False; every-worker handle_purge_message ignores unconfirmed requests. Only the leader's purge_source_if_unshared acts on requests (sibling-check under lock_source), and republishes the same command with confirmed=True after a successful (or already-gone) disk purge — that confirmation is the only thing that drops in-memory cache entries anywhere, including on the leader itself and the deleting worker. The orphan sweep, which already IS the leader, publishes confirmed=True directly. This closes the over-purge bed regression where deleting one scope dropped cache entries a sibling scope was still using, because memory purges ran unconditionally on both the DELETE route and every worker's handler with no sibling check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round purge failures - Rename _boot_sync_then_sweep to _sync_all_then_sweep (runs on boot and refresh-all triggers) - Update trigger() else-branch to call _sync_all_then_sweep instead of sync_scopes - Update log message from "Boot-time orphan sweep failed" to "Orphan sweep failed" - Add _purge_and_log wrapper to catch and log background task exceptions - Update handle() to use _purge_and_log to prevent asyncio noise on purge failures - Add scopes_task_wiring_test.py to verify sync-then-sweep ordering on all paths Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uming capacity
Replace the shared fixed-size git thread pool with a per-loop asyncio
semaphore bounding LIVE (non-timed-out) ops at SCOPES_GIT_MAX_WORKERS.
Each op now runs on its own single-use daemon-thread executor; on
timeout the semaphore slot is released immediately so a lingering
("zombie") op no longer consumes capacity — only the OS network
timeout bounds zombie count. Fixes the offline-repo bed gate where
N hung clones > pool size permanently starved healthy scopes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion and preload comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test The mid-test --force-recreate resets container-local /proc PIDs; the I5 before/after pid comparison spans two container generations and reported a false worker-crash. Same exemption as test_boot_states.py's force-recreate tests. Root-caused in .superpowers/sdd/fix-d-investigation.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…purge defers to the sweep Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… chown after compose cp Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se-after-free) purge_source_if_unshared published the confirmation AFTER exiting lock_source. Exiting the context manager is an await point, so a re-created scope's sync could acquire the lock, cache a fresh pygit2 Repository handle, and enter _notify_on_changes (which holds that handle across an await) before the stale confirmation ran inline through handle_purge_message -> purge_local_memory -> forget_repo and freed it out from under the concurrent caller (use-after-free). Move the confirmation publish to the end of the locked block, matching sweep_orphans, which already publishes under its lock. No deadlock: the only local subscribers are handle_purge_message (never takes lock_source) and LeaderScopePurger.handle (returns early on confirmed=True). Added a discriminating regression test that captures the source lock object before the purge runs and checks .locked() from inside the fake pubsub endpoint's publish() (same-task state check, since publish() runs on the purge's own task): True only if publish happens before the lock is released. Verified by mutation - moving the publish back outside the lock makes the test fail again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urce _scope_still_exists only checked whether the scope record still existed. After a PUT repoints a scope from source A to source B, a sync that captured source A before the repoint still passes the probe and re-clones A after the leader already purged it, orphaning a dir until the next sweep. The probe now also confirms the fresh record still points at the source this fetcher is syncing (compares source_id), not just that the scope_id exists. Non-ScopeNotFoundError exceptions still propagate to git_fetcher's fail-open catch, unchanged. 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>
… the wire path (arbitrary rmtree) A forged __opal_scope_purge__ pub/sub message's clone_path field was fed unvalidated into shutil.rmtree (leader) and forget_repo/Repository.free (workers). The channel's only gate is OPAL's fail-open topic ACL, so any client JWT lacking permitted_topics could publish a forged purge and make the leader rmtree an arbitrary path as the opal user, or free a live repo handle on workers. Derive the clone path locally from the validated source_id (sha256hex-<shardindex>, confined to base_dir/git_sources) instead, in both the worker handler and the leader's purge_source_if_unshared. The wire clone_path field stays in the model for logging/mixed-fleet additivity but is never used for filesystem I/O. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n queue The _DaemonThreadPoolExecutor used daemon=True so a lingering (timed-out) git call could not block interpreter shutdown — but it still registered every worker in concurrent.futures' global _threads_queues, which _python_exit joins unconditionally at exit, daemon or not. A hung fetch therefore blocked graceful shutdown / rolling restart: the 'stuck on an offline repo' hang this executor prevents, relocated to process exit. Drop the registration; normal shutdown uses self._threads + queue sentinels and is unaffected. Verified on py3.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
run_in_git_executor released its live-ops semaphore permit via per-branch
_release_once() calls after await sem.acquire(), but the await asyncio.wait(
{fut}, timeout=timeout) on the timeout>0 path was not wrapped, so a
cancellation landing inside that await propagated CancelledError without
releasing the permit, permanently shrinking live-op capacity. Wrap the
entire post-acquire body in a single try/finally so the slot is always
freed regardless of where the cancellation lands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T outcome delete_scope was hardened with a try/finally so a record delete that commits server-side but raises to the client still publishes the purge; the repoint path in put_scope had no equivalent. If scopes.put() commits but raises, the old-source purge was skipped, and a client retry sees old_source_id == new_source_id (store already updated) and never re-triggers it -- self-healing only via the slower orphan sweep. Move the repoint-purge publish into a finally on the put() call, mirroring delete_scope; over-publishing is safe since the leader sibling-checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Cover the liveness probe's delete-resurrection branch through the real ScopesService closure (previously only exercised via a hand-rolled probe). - Assert the boot-sweep's failure path actually logs, so deleting the log call would break the test. - Fix SCOPES_GIT_FETCH_TIMEOUT's description: it's a soft timeout, not hard. - Comment sweep_orphans' in-flight continue to explain why it doesn't mirror purge_source_if_unshared's immediate lock/timestamp drain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orkers don't inherit unpurgeable handles Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n (py3.9 isolation) preload_scopes() runs asyncio.run(), which closes its loop and leaves the main thread with no current loop. On Python 3.9 (unlike 3.10+) asyncio primitives grab the loop at __init__, so later sync tests constructing asyncio.Lock() raised 'no current event loop' — build(3.9) went red while 3.10-3.12 stayed green. Restore a fresh loop in a finally so cross-test isolation holds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review round — findings + fixes (all pushed, CI green)Ran a multi-agent review over the whole branch (correctness, security, unit-test coverage, docker-bed coverage, code quality) and acted on everything confirmed. Summarizing here so the earlier Two confirmed blockers — fixed1. Arbitrary path deletion via a forged purge message ( 2. Hung fetch could block graceful shutdown ( Should-fixes — fixed
What held up under review (no change needed)The 503-on-clone-vanish except tuple (empirically probed), the liveness probe gating both clone paths, sibling-delete convergence, the lock-identity recheck, the confirmation-under-lock UAF fix, credential redaction, and the two-phase purge routing all verified sound. Unit mutation audit: 0 weak tests among existing ones. Docker bed: all 10 acceptance gates green. Deferred (out of this PR's scope — flagging for a follow-up)
Full CI matrix (3.9–3.12), both E2E suites, and pre-commit are green. |
…e git pass sync_scopes ran both passes under one asyncio.Semaphore sized to SCOPES_GIT_MAX_WORKERS. Phase 2 (scopes reusing an already-handled repo) does no network fetch in the common case — _should_fetch returns False, so it is only a disk open + local change-check + notify — yet it inherited the network cap meant for phase 1's concurrent clones. Split the bound: phase 1 (distinct repos, network) stays capped at SCOPES_GIT_MAX_WORKERS; phase 2 gets a wider bound (max(git, 32)) so the cheap local checks are not throttled to the conservative git number. Any rare re-fetch phase 2 still triggers (branch missing after phase 1) remains throttled by the inner git-op semaphore in run_in_git_executor, so the network is never over-driven. 32 matches asyncio's default thread-pool ceiling, which is the real limiter on phase-2 disk opens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Review — PR3 (git resilience + fleet-wide purge) @ 8400a75e
21 inline comments: 4 HIGH, 8 MEDIUM, 9 LOW. Requesting changes on the HIGH set. Everything below was verified against the source on this branch (and against the installed fastapi_websocket_pubsub / CPython internals where the design depends on library behaviour), not against the PR description.
HIGH — must resolve
purge.py:73— purge commands are accepted from any connected pub/sub peer. The new control channel rides the samePubSubEndpointclients connect to;RpcEventServerMethods.publishis client-callable and_verify_permitted_topicsreturns immediately when the token has nopermitted_topicsclaim — which the defaultPOST /tokenmint omits. An ordinary client token can therefore publishconfirmed: truefor any known repo URL and driveRepository.free()on every worker in the fleet, or spend the leader's Redis + broadcast budget withconfirmed: false. The sibling-check does keep a live scope's clone from beingrmtree'd — that part holds.purge.py:62—handle_purge_messagefrees the pygit2 handle with nolock_source. This is exactly the use-after-free the leader path documents at 215-221; that protection only covers same-process inline delivery. A confirmation from another pod's leader (per-host leader lock ⇒ one leader per pod) or a replayed confirmation after a re-create both land on the reader task unserialized, andgit_op_in_flightis already False during the_notify_on_changeswindow that holds the handle across anawait.purge.py:213— the confirmation is published afterrepo_locks.pop, andlock_sourcetreats a missing entry as free (freshsetdefault→ uncontended acquire → identity re-check passes). The pop is equivalent to releasing, so theawait publishyield point is open to the exact race the comment says it prevents.sweep_orphanshas the same ordering;origin/masterpopped last.task.py:117— the pre-fork teardown frees handles a lingering timed-out git thread is still using (reset_cacheshas nogit_op_in_flightguard, andshutdown_git_executorerases the evidence first), and gunicorn'swhen_readyforks the workers while that libgit2 thread is alive. Both are reachable in the offline-repo scenario this PR targets.
MEDIUM — worth fixing before merge
api.py:157 a failed PUT publishes a repoint purge whose "sibling-check makes it safe" argument inverts under the Redis outage that caused the failure (fail-open → delete a live scope's clone) · service.py:213 the DELETE disk purge now depends on the leader's broadcaster reader, which does not run without STATISTICS_ENABLED or a client connected to that worker — master did it locally, so this is a regression for that shape · purge.py:195 the deferred-purge backstop (sweep_orphans) only runs at boot under the default POLICY_REFRESH_INTERVAL=0 · purge.py:265 O(M²) full-store rescans + is_dir on the event loop · git_fetcher.py:229 zombie threads/FDs bounded only by source count, with no libgit2 transport timeout so a stalled remote pins them forever · plus three test/doc gaps (dropped PR2 regression tests, untested start()/periodic-sweep wiring, a self-declared private doc as the only home for three new operator-facing keys while configuration.mdx goes stale).
Notes
- Blast radius: server-only —
opal-client/opal-commonuntouched, noOPAL_*key renamed or removed. The 503 flip is the one intentional contract change andopal-client's tenacity retry does tolerate it; direct consumers ofGET /scopes/{id}/policyneed to expect it. The purge channel is new fleet-wide state on the broadcaster, which is where findings 1–3 land. - Scope: in charter overall. The one overreach is committing
.claude/plans/docs/05-config-reference.md(a file that calls itself private) into the OSS tree while the public config reference gains none of the three new keys. - The
app-tests/git-leakREADME gate matrix is now stale in rows this PR made green (rows 48, 62-63 still say the orphan sweep does not exist), along with several "RED until PR3" docstrings intest_transitions.py/test_boot_states.py. Not flagged inline since those lines are untouched by the diff — happy to push the doc fix if you want it in this PR.
| except (ValidationError, TypeError): | ||
| logger.warning("Ignoring malformed scope purge message: {data}", data=data) | ||
| return | ||
| if not cmd.confirmed: |
There was a problem hiding this comment.
[HIGH] Purge commands are accepted from any connected pub/sub peer
Problem: handle_purge_message trusts confirmed as it arrives on the wire, and the channel rides the same PubSubEndpoint that clients connect to. RpcEventServerMethods.publish(topics, data) is exposed to every websocket peer, and EventNotifier.notify enforces channel restrictions only through PubSub._verify_permitted_topics (pubsub.py:340-351), which returns immediately when the token carries no permitted_topics claim — the default POST /token mint omits it (security/api.py:26, AccessTokenRequest.claims defaults to {}). So any holder of an ordinary OPAL client token can publish
{"source_id": "<sha256(repo_url)>-0", "clone_path": "x", "scope_id": "x", "reason": "x", "confirmed": true}to __opal_scope_purge__ and make every worker in the fleet run purge_local_memory -> Repository.free() for a live source at a moment of their choosing — the broadcaster's ALL_TOPICS subscriber re-broadcasts it fleet-wide. With confirmed: false they instead cost the leader a full scopes.all() Redis scan plus a fleet-wide confirmation broadcast per message, unrate-limited, behind an unbounded _pending_purges set.
Client-publishable topics are pre-existing OPAL behaviour; what is new here is that a filesystem/cache control plane now listens on that bus. (Credit where due: the sibling-check does stop a forged request from rmtree-ing a live scope's clone — that part holds.)
Suggestion: do not accept purge commands from a client-reachable endpoint. Either require a server-minted marker inside ScopePurgeCommand and verify it in both handlers, or move the fleet purge onto a server-only broadcaster channel that RpcEventServerMethods.publish cannot reach. At minimum, make _verify_permitted_topics fail closed for internal __opal_* topics regardless of the claim.
| self-heals via the orphan sweep or the next validity probe. | ||
| """ | ||
| if not git_op_in_flight(source_id): | ||
| GitPolicyFetcher.forget_repo(clone_path) |
There was a problem hiding this comment.
[HIGH] Worker handler frees the shared pygit2 handle outside lock_source — the exact use-after-free the leader path guards against
Problem: purge_source_if_unshared goes out of its way to publish the confirmation while still holding lock_source, and the comment at purge.py:215-221 says why: _notify_on_changes holds the cached handle across an await and then calls set_target() on it, so freeing it underneath is a use-after-free. That argument only covers same-process, inline delivery. A confirmation arriving over the broadcaster is dispatched on the reader task with no lock at all, and handle_purge_message takes none.
git_op_in_flight is not a substitute: _mark_git_op_done runs in run_in_git_executor._runner's finally, so it is already False while _notify_on_changes (git_fetcher.py:663-686) still holds repo and local_branch across await self.callbacks.on_update(...) — which for NewCommitsCallbacks builds a bundle and publishes over the backbone (tens to hundreds of ms).
Two reachable triggers:
- Leader election is a per-host
fcntllock, so a multi-pod deployment without a shared volume has one leader per pod, each holding a warm handle for the samesource_id(same key, same relative clone path). Pod A'sconfirmed=Truelands on pod B mid-sync. - The reconnect replay buffer (
pubsub_resilience.py) can redeliver a confirmed purge after the same URL/branch has been re-created —source_idis deterministic from url+branch — so a stale confirmation frees a live handle.
Suggestion: take GitPolicyFetcher.lock_source(cmd.source_id) inside handle_purge_message around purge_local_memory. Note the leader publishes its own confirmation while holding that lock, so inline local delivery would self-deadlock — split local from remote delivery, or drop the publish-under-lock trick and serialize on the receiving side only (see the comment on line 213). A generation/epoch field on ScopePurgeCommand, checked before applying, would also close the replay case.
| logger.warning(f"Failed to remove clone dir {safe_path}: {e!r}") | ||
| # Popped while the lock is held: lock_source waiters re-check | ||
| # the dict entry after acquiring and retry on the fresh lock. | ||
| GitPolicyFetcher.repo_locks.pop(cmd.source_id, None) |
There was a problem hiding this comment.
[HIGH] repo_locks is popped before the confirmation publish, so the "published under the lock" protection does not hold
Problem: the comment two lines below states the confirmation must be published while the source lock is held, because releasing first would let a re-created scope's sync cache a fresh handle and enter _notify_on_changes while this confirmation frees it underneath.
But lock_source (git_fetcher.py:379-384) treats a missing dict entry as free:
while True:
lock = GitPolicyFetcher.repo_locks.setdefault(source_id, asyncio.Lock())
async with lock:
if GitPolicyFetcher.repo_locks.get(source_id) is lock:
yield
returnAfter the pop at line 213 (and line 200 in the in-flight branch), a newcomer's setdefault mints a fresh lock, acquires it uncontended, passes the identity re-check and enters. The pop is therefore equivalent to releasing the lock, and the await ...publish(...) at line 223 is a yield point where exactly the scenario the comment warns about can run. sweep_orphans has the same ordering (pop at 292, publish at 294). origin/master's _purge_source_cache_if_unshared did the pop last, after all other work.
Suggestion: publish the confirmation first and make the repo_locks.pop the last statement in the critical section, in both purge_source_if_unshared branches and in sweep_orphans.
| # and the loop-bound live-op semaphore) so the gunicorn master does | ||
| # not carry stale state into forked workers. Git ops run on per-op | ||
| # daemon threads; there is no shared pool to tear down. | ||
| shutdown_git_executor() |
There was a problem hiding this comment.
[HIGH] Pre-fork teardown frees handles that a lingering timed-out git thread is still using, and forks with that thread alive
Problem: two distinct hazards, both in the block starting here, and both reachable in precisely the offline-repo scenario this PR exists for (any restart with a persistent BASE_DIR and an unreachable remote):
-
Free-while-in-use. After a preload fetch hits
SCOPES_GIT_FETCH_TIMEOUT, its pygit2 call keeps running on a private daemon thread — by design.shutdown_git_executor()then does_git_busy.clear(), destroying the only record that the thread is alive, andGitPolicyFetcher.reset_caches()(line 127) callsforget_repo->Repository.free()on every cached handle with nogit_op_in_flightguard — unlikepurge_local_memory(purge.py:61-62), which the PR guards precisely because "freeing a pygit2 handle a pool thread still uses ... is a crash risk". The submitted callable is the bound method of that cached handle (repo.remotes[self._remote].fetch, git_fetcher.py:448-453), so this frees the repository the thread is inside. A SIGSEGV here is not containable by theexcept Exceptionaroundfree(). -
Fork with a live thread.
scripts/gunicorn_conf.pycallspreload_scopes()fromwhen_ready, which runs in the arbiter before any worker is forked. These threads are deliberately not registered inconcurrent.futures.thread._threads_queuesandasyncio.run's executor shutdown no longer joins them, so the fork can happen while a libgit2/OpenSSL thread holds internal mutexes — the classic fork-in-threaded-process hazard, which is the same "stuck at boot" failure class this series is fixing, relocated to the children.
Suggestion: skip forget_repo for any path whose source_id is still git_op_in_flight, and clear _git_busy only in the child (_reset_git_executor_after_fork already does). Before returning from preload_scopes, wait a bounded time for _git_busy to drain and leave still-busy sources alone; running the preload in a short-lived subprocess removes both hazards outright.
| # delete — over-publishing self-heals, since the leader | ||
| # sibling-checks and a source still shared by another scope | ||
| # survives. | ||
| if old_source_id is not None and old_source_id != new_source_id: |
There was a problem hiding this comment.
[MEDIUM] A failed PUT publishes a repoint purge whose safety argument does not hold
Problem: the publish is in a finally, so it fires even when await scopes.put(scope_in) raised. The comment above says over-publishing self-heals "since the leader sibling-checks and a source still shared by another scope survives". That does not hold for the case that produced the failed put: find_scope_sharing_source (purge.py:119-124) catches any exception from scopes.all() and returns None, which purge_source_if_unshared treats as "no sharer" -> forget_repo + rmtree. ScopeRepository.put and .all hit the same Redis, so the outage that makes put() raise is exactly the one that makes the sibling check fail open into a purge. The leader also ignores cmd.reason, so it cannot distinguish "record known gone" (delete — purging defensively is right) from "record known live" (failed repoint — purging is wrong).
Net effect: a Redis blip during a scope PUT can delete a still-live scope's clone dir.
Suggestion: make the fail-open bias depend on cmd.reason — for reason == "repoint" a raising sibling scan should KEEP the clone (the record is known live, and the orphan sweep is the backstop); keep today's defensive purge for reason == "delete". Alternatively publish the repoint purge only on the success path.
|
|
||
|
|
||
| if hasattr(os, "register_at_fork"): | ||
| os.register_at_fork(after_in_child=_reset_git_executor_after_fork) |
There was a problem hiding this comment.
[LOW] register_at_fork installs only after_in_child, so a fork while _git_busy_lock is held deadlocks the child
Problem: _reset_git_executor_after_fork acquires _git_busy_lock (line 130). threading.Lock state is inherited across fork(): if another thread held it at fork time, the child's copy is permanently locked and the handler blocks forever in the post-fork path — the worker never starts. That lock is taken by non-forking threads: _mark_git_op_done runs on the git daemon thread (from _runner's finally), and preload_scopes can leave such threads alive in the gunicorn arbiter at exactly the moment it forks workers.
The stdlib module this file already imports does it the complete way:
os.register_at_fork(before=_global_shutdown_lock.acquire,
after_in_child=_global_shutdown_lock._at_fork_reinit,
after_in_parent=_global_shutdown_lock.release)Suggestion: register the same triple — before=_git_busy_lock.acquire, after_in_parent=_git_busy_lock.release, and in the child reinit the lock before touching _git_busy.
| """ | ||
|
|
||
| def _adjust_thread_count(self) -> None: # pragma: no cover - thread mgmt | ||
| if not hasattr(cf_thread, "_worker") or not hasattr( |
There was a problem hiding this comment.
[LOW] The fallback guard checks that CPython internals exist, not that they still have the shape this code assumes
Problem: the docstring promises "If a future CPython changes the internals we rely on, we fall back to the stdlib (non-daemon) behaviour", but the guard only tests hasattr(cf_thread, "_worker") and hasattr(cf_thread, "_threads_queues"). On CPython 3.14 both still exist, while _worker's signature changed and ThreadPoolExecutor.__init__ no longer sets _initializer/_initargs — so the override raises AttributeError out of submit(), which lands in the except BaseException at 236-240 and makes every scope clone/fetch fail. 3.9-3.13 are unaffected (verified: 3.13 still has the 4-arg _worker), and setup.py classifiers stop at 3.12 — but python_requires is ">=3.9" with no upper bound, so a 3.14 install is permitted and would silently never sync.
Suggestion: validate what is actually used — e.g. inspect.signature(cf_thread._worker) parameter count plus hasattr(self, "_initializer") — and fall back to super()._adjust_thread_count() on any mismatch. Adding an upper bound to python_requires would also make the constraint explicit.
| SCOPES_GIT_MAX_WORKERS = confi.int( | ||
| "SCOPES_GIT_MAX_WORKERS", | ||
| 10, | ||
| description="Maximum number of LIVE scope git operations (clone/fetch) running concurrently; also bounds how many scopes are synced at once. A timed-out operation stops counting against this limit (its lingering thread persists on its own until the OS network timeout), so capacity is never starved by hung remotes — but worst-case thread count during an outage is this limit plus the number of lingering timed-out operations.", |
There was a problem hiding this comment.
[LOW] Description is a single 438-character line
Problem: awk 'length > 100' flags this line at 438 characters — 4.4x the project's 100-char limit and the longest line in the file by ~3x. It is also inconsistent with SCOPES_GIT_FETCH_TIMEOUT immediately above (lines 217-221), which is wrapped into implicit-concatenated segments. Black does not reflow string literals, so nothing catches this automatically.
Suggestion: wrap it the same way the adjacent key is wrapped, and move the multi-clause caveat about worst-case thread count into a code comment next to the declaration.
| > single-use `_DaemonThreadPoolExecutor(max_workers=1)`, so a lingering op occupies only its own | ||
| > thread and never holds capacity another operation needs. A per-repo in-flight guard | ||
| > (`git_op_in_flight`) prevents a second git op from touching the same (non-thread-safe) pygit2 repo | ||
| > while the first is still lingering. Hard-kill via subprocess is out of scope. See spec §6. |
There was a problem hiding this comment.
[LOW] Dangling spec reference and two off-by-one file:line citations
Problem: this line ends "See spec §6", but no such file exists on this branch: git ls-files matches nothing for pr3 or superpowers, and docs/superpowers/specs/ does not exist (the PR description links docs/superpowers/specs/2026-07-19-pr3-deferred-items-design.md, which is not in the diff either). test_leak.py:198 adds a second dangling reference to .superpowers/sdd/bed-deep-dive.md.
Separately, the doc's stated purpose is letting a contributor jump straight to the Confi declaration, but two of its three citations land on the previous key's closing paren: SCOPES_GIT_MAX_WORKERS is at config.py:223-227 (cited 222-226) and SCOPES_PURGE_CHANNEL at 312-319 (cited 311-318).
Suggestion: land or drop the referenced spec/deep-dive files, and cite symbol names rather than line ranges — the ranges go stale on the next edit to config.py.
| def test_delete_route_purges_fetcher_caches(tmp_path, monkeypatch): | ||
| """DELETE /scopes/{id} must flow through ScopesService.delete_scope so the | ||
| GitPolicyFetcher caches drain (the git-leak churn gate).""" | ||
| def test_delete_route_does_not_purge_fetcher_caches_without_pubsub(tmp_path): |
There was a problem hiding this comment.
[LOW] The replacement route test pins a configuration production cannot reach
Problem: test_delete_route_purges_fetcher_caches was replaced by a test that asserts the caches are LEFT populated, which only holds because _client() wires pubsub_endpoint=None (line 51). Production always supplies self.pubsub.endpoint to both ScopesService and init_scope_router (server.py:275-287), so this locks in the behaviour of a branch only test code reaches — and the assertions would keep passing if the DELETE route stopped publishing a purge command entirely. The service-level publish is covered (delete_scope_cache_purge_test.py:71), but nothing covers it through the route.
Suggestion: wire a fake pubsub endpoint into _client() and assert the route publishes ScopePurgeCommand(source_id=..., reason="delete", confirmed=False) on SCOPES_PURGE_CHANNEL; keep the pubsub_endpoint=None case as a separate "degraded mode does not crash" test.
PR3 — Git resilience + the deferred items from the leak series
Closes PER-15157.
Ships PR3's original charter (no git operation can hang the server) plus the six items deferred out of PR2 (#923), and — because the resilience fix required it — the parallel scope loading originally scoped as PR4 (see §6; PR4 is closed as redundant). Design:
docs/superpowers/specs/2026-07-19-pr3-deferred-items-design.md.1. Git resilience — hung remotes can't starve healthy scopes
Scope clone/fetch used pygit2 with no timeout, on the shared default executor.
POLICY_REPO_CLONE_TIMEOUTis wired only to the legacy non-scopes path, so scopes mode had no bound at all: boot (preload_scopes()) blocked indefinitely on one unreachable repo, and in steady state a hung op held the per-source lock and a shared-pool thread.SCOPES_GIT_FETCH_TIMEOUT) on clone and fetch.SCOPES_GIT_MAX_WORKERSbounds only live ops via a semaphore. A timed-out op releases its capacity slot immediately — its pygit2 call lingers on a private thread until the OS gives up, but no longer consumes capacity. (A fixed pool starves once lingering threads exceed its size: 40 blackholed repos vs. 10 slots meant healthy scopes never got a thread. That's thetest_offline_repo_does_not_block_healthy_scopesgate.)git_op_in_flight(source_id)guards every path that would free a pygit2 handle or delete a clone dir while a thread may still be using it.2. Fleet-wide cache purge — two-phase (deferred items 1 + 3)
PR2's purge was process-local: only the worker serving the DELETE dropped its caches, so the leader — which populates most of
repos/repo_locks/repos_last_fetched— leaked until restart. A non-leader DELETE alsormtree'd the shared clone tree, breaking the "only the leader mutates the clone tree" invariant.New every-worker channel
SCOPES_PURGE_CHANNEL(__opal_scope_purge__, following the__opal_stats_*convention), carrying a two-phase purge:confirmed=False).lock_source, then disk work — in a background task, never on the publish path (publish()awaits subscriber callbacks inline, so an inline handler would put a lock wait on DELETE/PUT latency).confirmed=True), and that is what every worker's memory handler acts on.Phase 2 exists because purging on the raw request over-purges: a source shared by a sibling scope had its cache dropped while the sibling still used it. Only the leader can sibling-check, so only the leader may authorize a memory purge.
PUT /scopeswith a changedsource_idpublishes a purge for the old source. Same channel, same handlers.confirmedis additive with aFalsedefault.3. Scope-liveness check before clone (item 2)
A sync that loaded a scope before its DELETE and reached it mid-sync re-cloned the dead scope and re-populated the caches.
GitPolicyFetchernow takes a liveness probe, checked underlock_sourceimmediately before the clone;ScopesServicesupplies one that re-reads from Redis and also confirms the scope still points at this source (so a repointed scope's stale sync doesn't resurrect the old clone either). Fails open on store errors.4. Orphan clone-dir sweep (item 6)
Leader-only reconciliation after boot, periodic, and refresh-triggered syncs: clone dirs under
git_sources/referencing no live scope are reclaimed. One set-difference covers crash orphans, redis-wiped boots, and old-shard dirs after aSCOPES_REPO_CLONES_SHARDSchange. A store-scan error aborts the sweep (a transient Redis error must never read as "no scopes exist"); each orphan is re-checked under its own lock and skipped while a git op is in flight.5.⚠️ Behavior change — retryable 503 for a live scope's broken clone (item 5)
GET /scopes/{scope_id}/policy:503+Retry-After: 5make_bundleraises rawOSError503+Retry-After: 5A live tenant was briefly served another tenant's policy. The condition is transient by construction (recovery or the next sync re-creates the clone), so a retryable error is the honest answer.
opal-clienttolerates this: itsPolicyFetcherretries with tenacity backoff and a failed cycle is skipped, not fatal — it does not readRetry-After(uses its own backoff). Direct consumers of this endpoint should expect 503 and retry.6. Parallel scope loading — the ~20-minute boot fix (folds in PR4)
sync_scopes(boot preload and every periodic/refresh sweep) was serial: oneawait sync_scope(...)per scope. With a fleet of repos that meant a ~20-minute boot, and — once §1 added a per-fetch timeout — a serial loop would still stall healthy scopes behind each unreachable repo's timeout. So the resilience fix in §1 requires concurrency to actually deliver "one offline repo can't block the others"; the two ship together and PR4 (which planned this same work as a standalone change) is closed as redundant.The sweep runs in two phases, each
asyncio.gatherunder its own semaphore:SCOPES_GIT_MAX_WORKERS. Combined with the zombie-aware executor (§1), a hung remote consumes only its own slot until its timeout, then releases it._should_fetchreturnsFalse, so there's no network fetch — just a disk open + change-check + notify. It therefore gets a separate, wider bound (max(SCOPES_GIT_MAX_WORKERS, 32)) instead of inheriting phase 1's network cap;32matches asyncio's default thread-pool ceiling, which is what actually limits those disk opens. Any rare re-fetch phase 2 does trigger (a branch missing after phase 1) is still throttled by the inner live-ops semaphore, so the network is never over-driven.Per-scope failures stay isolated (
ScopeNotFoundErrorfrom a mid-sweep delete is skipped; other exceptions are logged), andsync_scopere-reads each scope by id right before use, so a delete that lands after the snapshot never re-clones a dead scope.No new config key — the split is internal.
SCOPES_GIT_MAX_WORKERSremains the single operator lever for git concurrency; a dedicated sync-concurrency knob was considered (PR4'sSCOPES_SYNC_CONCURRENCY) and deliberately dropped to avoid a redundant, easily-misconfigured second bound.The timeout unblocks the event loop and the awaiting coroutine, but the underlying pygit2 call keeps running on its private daemon thread until the OS network timeout. Daemon threads never block shutdown, and
git_op_in_flightkeeps anything from freeing that repo meanwhile — but worst-case thread count during an outage isSCOPES_GIT_MAX_WORKERSplus the number of lingering timed-out ops. Hard-kill via subprocess is explicitly out of scope.New config keys (opal-server, server-only, additive)
OPAL_SCOPES_GIT_FETCH_TIMEOUT120.00= no limit.OPAL_SCOPES_GIT_MAX_WORKERS10OPAL_SCOPES_PURGE_CHANNEL__opal_scope_purge__Invariants (enforced and tested)
repo_locksentries are popped only while holding that source's lock.forget_repo/rmtreenever run while a git op is in flight for that source.The purge confirmation is published while holding
lock_source— it frees this process's cached handle via the inline local subscriber, so releasing first opened a use-after-free against a re-created scope's_notify_on_changes(which holds the handle across an await, then callsset_target()on it).Verification
sync_scopes_perpass_test.py(2 tests: phase 2 runs wider than the git cap; phase 1 still respects it) — both green, and the related sync/delete/preload suites pass unchanged.app-tests/git-leakdocker bed: all 10 acceptance gates green — 19/19 in the main phase plustest_offline_repo_does_not_block_healthy_scopes. The bed (not the unit suite) is the real gate: it caught three product bugs unit tests could not — the purge blocking DELETE/PUT on the publish path, the shared-source over-purge, and the thread-pool starvation.OPAL_SCOPES_GIT_FETCH_TIMEOUT=10for realistic serve windows; the delete-vs-inflight-sync churn exclusion lifted (closed by the liveness probe); the repoint gate rewritten as a green guard (it was racing a purge that now completes in ~4ms);allow_worker_restarton the force-recreating boot test;chownafter the restoringcompose cp.Known limitation (pre-existing, not introduced here)
test_server_recovers_after_postgres_bouncecan fail in a full-file bed run. A worker receives backbone messages only if its broadcaster reader is running, which happens viaSTATISTICS_ENABLED(defaultFalse) or a connected websocket client —subscribe()alone does not start it. The bed has no opal-client service and statistics off, so a non-leader worker there is deaf to the backbone and a publish it buffers during an outage never replays. With clients connected (or statistics on) the reader runs and the replay works — verified in the logs of a passing run. Unrelated to this PR's changes; tracked separately.Consumer surface
packages/opal-clientandpackages/opal-commonare untouched. NoOPAL_*key renamed or removed; all three new keys are additive with behavior-preserving defaults. The 503 above is the one intentional contract change.🤖 Generated with Claude Code