Interval fetch moves into the detached updater - #161
Conversation
Reviewer's GuideMoves the hourly broad git fetch sweep off the foreground launch path into the detached Sequence diagram for background updater cache fetch sweepsequenceDiagram
actor User
participant dl_cli as _run_cli
participant child_dl as update_cache_background
participant sweep as sweep_repo_fetches
participant storage as MetadataStorage
participant repo_manager as RepositoryManager
participant lock as try_hold_lock
User->>dl_cli: dl <command>
dl_cli->>child_dl: spawn dl --update-cache (detached)
child_dl->>sweep: sweep_repo_fetches()
sweep->>storage: list_repositories()
storage-->>sweep: repositories
loop each base_repo
sweep->>repo_manager: lock_path(owner, repo)
sweep->>lock: try_hold_lock(lock_path)
alt lock acquired
sweep->>repo_manager: lazy_fetch(owner, repo)
repo_manager-->>sweep: update last_fetched
else lock not acquired
sweep-->>sweep: skip repo (no waiting)
end
end
sweep-->>child_dl: background sweep completes
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #161 +/- ##
==========================================
+ Coverage 94.67% 94.73% +0.06%
==========================================
Files 21 21
Lines 2591 2621 +30
==========================================
+ Hits 2453 2483 +30
Misses 138 138
🚀 New features to boost your workflow:
|
blooop
left a comment
There was a problem hiding this comment.
This was generated by AI during review.
Two-axis review of perf/149-updater-fetch-sweep @ ec08eee, fixed point = merge-base with main (f03158b), three-dot diff. Preflight: fixed point resolves, diff non-empty (5 files, +409/-1), and gh pr checks 161 reports every check pass (ci (py310) pass, e2e pass, gate pass, prek pass); actions/runs?head_sha=ec08eee → total_count: 3, so the checks are real and not absent.
The axes ran as independent subagents and are reported unmerged and unranked against each other.
Standards
- Inverted bool polarity between the two lock helpers —
devlaunch/worktree/locks.py:51vs:84. Both yieldIterator[bool], butTruemeans "I had to wait" inhold_lock(yield waited, locks.py:77) and "I got it" intry_hold_lock(yield Trueon success, locks.py:108). The PR's own test shows the trap side by side:test/unit/test_locks.pyassertsacquired is True, thencontended is False, for the same successful outcome. Near-identical names, opposite semantics — principle 3 (constructive modeling) and Mysterious Name. blocking - The yielded bool is an ignorable sentinel —
locks.py:100-110. The block runs whether or not the lock was taken, so a caller that omits theif not acquiredguard does the protected work unlocked and nothing reads as wrong at the call site. A shape where the body cannot run unguarded would make that state unrepresentable. blocking - The sweep bypasses dl's single construction point —
devlaunch/dl.py:466-471builds its ownMetadataStorage()+RepositoryManager(...), skipping_get_clone_manager()(dl.py:2126), which the code itself calls "dl's single construction point" and which runsmigrate_cacheunder the metadata lock. The sweep therefore reads and writes metadata this process may not have migrated. It is also Feature Envy: it uses onlyrepo_manager.lock_pathandrepo_manager.lazy_fetchand belongs onRepositoryManager. blocking - The lock-ordering invariant is prose only, and its enumeration is already wrong —
locks.py:26-39. Checked against the code: metadata-under-repo-lock happens atrepo_manager.py:117(_register_existing_bare),:143,:170, andworkspace_clone.py:364— four acquisitions, not the "Three call sites" the docstring names.remove_repository(repo_manager.py:344) andremove_worktree(workspace_clone.py:410) take metadata with no repo lock. "There are two kinds of lock in the cache" omits the per-workspace launch lock (dl.py:1676, PR #138). Nothing enforces the order. Good news, checked because the sweep depends on it:lazy_fetch(repo_manager.py:190) does not take the repo lock, so calling it undertry_hold_lockis not a self-deadlock. non-blocking - Unbounded fetch under the lock, now invisible —
repo_manager.py:157passes notimeout. A foregroundensure_repocan now block behind the background sweep, told only "waiting for another dl run" — naming a process the user cannot see or Ctrl-C. The normal-case wait is bounded by the shared_should_fetchclock; the hang case is new. non-blocking - Error tuple too narrow, and duplicated —
dl.py:481catches(ValueError, RuntimeError, OSError). Metadata/JSON decode errors and non-OSErrorsave()failures escape and kill the child mid-sweep, losing every remaining repo's refresh — exactly what the docstring promises will not happen. The same triple already appears atworkspace_clone.py:191. non-blocking - Docstring density —
locks.py:83-99is 17 prose lines for 11 code lines;dl.py:442-464likewise. The why is genuinely high quality, but the same intent is restated in three places, and the finding above shows one copy has already gone stale relative to the code it describes. non-blocking - Test timing crutches / patch leak —
test/unit/test_locks.py:52(wait(timeout=5)),test/unit/test_updater_fetch_sweep.py:121(join(20.0)). Correct in intent, butpatch("subprocess.run")is process-global and is entered inside a daemon thread: if the join times out, the thread survives and later restoressubprocess.runout from under a subsequent test. Tests do follow thetest_devpod_spawn_counts.pyargv-pinning prior art, andgrep '/home/'over the three-dot diff has no hits. non-blocking — but see the reproduced failure below.
Verified by execution (Standards axis): pixi run -e py310 pylint → Your code has been rated at 10.00/10, exit 0; pixi run -e py310 pytest test/unit -q → 322 passed in 32.09s; the two new files together → 11 passed (10 runs); one earlier run → 5 failed, 6 passed in 46.95s; grep '/home/' over the three-dot diff → no hits.
Not verified (Standards axis): full pixi run ci (format/ruff/ty/coverage), real multi-process lock behaviour, py311+ environments, the cause of the single failing run.
Spec
| Spec claim | Status | Evidence |
|---|---|---|
(i) broad interval fetch "moves into the detached dl --update-cache child" |
Partial — added, not moved | sweep_repo_fetches() added in devlaunch/dl.py, called from the --update-cache branch after update_completion_cache(); the foreground ensure_repo _should_fetch/fetch_repo branch (repo_manager.py:222-228) is untouched |
(ii) non-blocking try_hold_lock |
Present | try_hold_lock in worktree/locks.py (LOCK_NB, yields bool, never unlinks); used by the sweep; repo→metadata ordering invariant added to the module docstring as the ticket asked |
(iii) delete _prepare_workspace's workspace-clone fetch |
Absent — deferred to #150 | workspace_clone.py is not in the diff; #150 body: "Delete _prepare_workspace step 4 (workspace_clone.py:298-310)" |
"push-then-dl lands on the pushed tip" |
Absent — deferred to #150 | #150: "remote gains a commit after the cache cloned → new workspace HEAD == remote tip" |
The three ticket-mandated failing tests all exist in test/unit/test_updater_fetch_sweep.py, read rather than assumed:
(1) elapsed interval → test_a_repo_past_its_interval_is_fetched asserts the exact argv ["git","fetch","origin","+refs/heads/*:refs/heads/*","--tags","--prune"], and test_fetching_advances_the_shared_fetch_clock asserts last_fetched() > stale. (2) lock held elsewhere → test_a_repo_another_run_is_holding_is_skipped (no fetch, thread deadline proves non-blocking) plus test_a_skipped_repo_keeps_its_fetch_clock. (3) within interval → test_a_repo_within_its_interval_is_left_alone. The seam is the one the ticket named: patch("subprocess.run", side_effect=recorder.run) recording argv, per test_devpod_spawn_counts.py.
Findings:
- Spec-source discrepancy on "moves" — non-blocking. The map's recorded resolution says the fetch "moves into the detached
dl --update-cachechild"; ticket #149's own body says "Make the child sweep the cache; leave the foreground untouched for now (the transient double-gate is harmless —last_fetchedis shared)". The diff follows the ticket, and both the PR body and CHANGELOG state the double-gate plainly. The resolution's phrasing overstates what #149 was scoped to do; #149's own contract is coherent, and the seam #150 builds on (unchangedlazy_fetch, unchangedlast_fetched) is intact. - No test points at the behavioural guarantee — non-blocking. "push-then-
dllands on the pushed tip" is correctly #150's: #149 changes no freshness guarantee, and #150 already names that exact integration test ("remote gains a commit after the cache cloned → new workspace HEAD == remote tip"). Nothing here should be blocked on it. - The contention tests pass vacuously at merge-base — non-blocking. Nothing fetches at
f03158b, so they are not red-before-green in the strict sense; confirmed they still discriminate by mutating the implementation.
Verified by execution (Spec axis): pixi run -e py310 pytest test/unit/test_updater_fetch_sweep.py test/unit/test_locks.py -q → 11 passed in 2.66s; red-before with merge-base dl.py/locks.py restored → 3 failed, 3 passed in 1.17s and ImportError: cannot import name 'try_hold_lock'; mutation try_hold_lock→hold_lock → 1 failed, 5 deselected in 26.00s.
Not verified (Spec axis): full pixi run -e py310 ci, the PR body's "1173 passed" and "pylint 10.00/10" claims, and the PR's real-git XDG_CACHE_HOME end-to-end check.
Reviewer's own verification (lead)
Because a reviewer on this map was previously caught reporting results it had not produced, here is exactly what I ran myself, with real output.
A failure I reproduced, independently of the Standards axis. My first run of the two new test files gave:
FAILED test/unit/test_updater_fetch_sweep.py::TestTheUpdaterSweepsTheCache::test_a_repo_past_its_interval_is_fetched
FAILED test/unit/test_updater_fetch_sweep.py::TestTheUpdaterSweepsTheCache::test_fetching_advances_the_shared_fetch_clock
FAILED test/unit/test_updater_fetch_sweep.py::TestTheUpdaterYieldsToLaunches::test_a_repo_another_run_is_holding_is_skipped
FAILED test/unit/test_updater_fetch_sweep.py::TestTheUpdaterYieldsToLaunches::test_a_skipped_repo_keeps_its_fetch_clock
FAILED test/unit/test_updater_fetch_sweep.py::TestTheSweepSurvivesABadRepo::test_a_failing_fetch_does_not_stop_the_next_repo
5 failed, 6 passed in 51.32s
with, on the last of them:
> assert len(recorder.fetches) == 2
E assert 0 == 2
...
INFO devlaunch.worktree.repo_manager:repo_manager.py:172 Successfully fetched updates for owner/repo
The Standards axis hit the same signature (5 failed, 6 passed in 46.95s, same assert 0 == 2) in a separate process. Two independent observations, same signature. The captured log is the diagnostic: a fetch was reported successful in-process while the recorder saw nothing, which means subprocess.run was not patched at that moment — the run was making real network calls, which is what the ~50s wall time is.
What I then ruled out, each by execution:
- ordering (
test_locks.pyfirst vs. second):11 passed in 3.37s,11 passed in 3.20s,11 passed in 3.19s - an exported scratch
XDG_CACHE_HOME:11 passed in 7.10s,11 passed in 1.11s - the file in isolation, three times:
6 passed in 24.10s,6 passed in 0.88s,6 passed in 3.24s - two concurrent pytest processes in one worktree: foreground
11 passed in 0.75s, background322 passed in 1.51s - a cold checkout — a brand-new worktree at
ec08eee, first ever run:11 passed in 2.32s
So I could not reproduce it on demand, and I am not claiming a root cause. The mechanism consistent with everything observed — and it is analysis, not a verified result — is the patch leak the Standards axis flagged independently: run_updater (test_updater_fetch_sweep.py:109-126) enters the process-global patch("subprocess.run") inside a daemon thread and joins with a 20s deadline. Under load, one slow test trips the deadline, the daemon thread survives the failed assertion, and when it later exits the with block it restores the real subprocess.run underneath the next test — turning one slow test into a cascade of tests that silently hit the network. Both observed failures were on loaded machines and both took ~50s. I flag this as a hypothesis worth ten minutes, not as a diagnosis.
I also confirmed by reading the source, not by grep-and-assume:
hold_lockyieldswaited(locks.py:69,73,77) andtry_hold_lockyields acquisition (locks.py:106,108) — the inverted polarity is real._get_clone_manager(dl.py:2126) is the migration point the sweep does not go through — the bypass is real.- the worktree was clean and at
ec08eee(git status --porcelainempty) when the passing runs were made.
Not verified by me: the full pixi run ci pipeline end to end; the PR body's "1173 passed", "pylint 10.00/10" and real-git XDG_CACHE_HOME end-to-end claims (the Standards axis independently reproduced the pylint 10.00/10 and a 322-test unit green; I did not run the full ci task myself); any behaviour on py311/py312/py313 beyond CI's own green; and the patch-leak mechanism above.
Verdict
Request changes.
Blocking findings, all from the Standards axis (the Spec axis raised none):
try_hold_lockandhold_lockyield bools with opposite meanings under near-identical names (locks.py:51vs:84) — a maintainability and constructive-modeling trap, and #150 will add callers.- The yielded bool is an ignorable sentinel (
locks.py:100-110): the guarded block runs regardless, so an omittedif not acquiredsilently does unlocked work. sweep_repo_fetchesbypasses_get_clone_manager()(dl.py:466-471vsdl.py:2126), so the detached child touches metadata without the migration that every other dl construction path performs.
Not blocking, but I would fix them before #150 builds on this seam: the stale lock-ordering enumeration in locks.py:26-39 (four acquisition sites and a third lock kind, not "three call sites" and "two kinds"), and the run_updater patch-leak hazard — a test harness that can put real network calls into a unit run is a poor foundation for #150's fetch pinning.
The Spec axis is otherwise clean: the sweep, the non-blocking lock, and all three ticket-mandated tests are present and assert what #149 asked for. The workspace-clone-fetch deletion and the push-then-dl guarantee are correctly #150's, not gaps here.
There was a problem hiding this comment.
This was generated by AI during review.
Duplicate submission — this review was posted twice by a retry. The review of record is #pullrequestreview-4891152499; please read that one. Verdict there: Request changes.
blooop
left a comment
There was a problem hiding this comment.
This was generated by AI during review.
Re-verdict after the retry. Two-axis review of perf/149-updater-fetch-sweep @ 83b69e3, fixed point = merge-base with main (f03158b), three-dot diff. This is a fresh reviewer: I did not write this code and I did not write the prior review.
Preflight, confirmed myself: fixed point resolves; diff non-empty (6 files, +661/-2); gh api actions/runs?head_sha=83b69e3 → total_count: 3 (CI success, prek success, Auto-publish skipped), so the checks are real and not absent; gh pr checks 161 all pass across py310–py313, e2e, gate; mergeable: MERGEABLE, mergeStateStatus: CLEAN.
The axes ran as independent subagents and are reported unmerged and unranked.
Status of the three original blocking findings
| # | Original finding | Status |
|---|---|---|
| 1 | Inverted bool polarity between hold_lock and try_hold_lock |
Closed |
| 2 | The yielded bool is an ignorable sentinel — the block ran unlocked if a caller dropped the guard | Closed |
| 3 | sweep_repo_fetches bypasses _get_clone_manager(), and therefore migrate_cache |
Closed |
1 and 2 — closed, and closed at the right level. The helper was deleted rather than patched. run_if_lock_free(lock_path, work) -> bool (locks.py:86) takes the work instead of yielding a scope, so on the contended path there is no body to run: work() is reachable only after a successful LOCK_NB, inside the try/finally that closes the fd. Unlocked work is no longer expressible, which is the constructive-modeling answer rather than a better-documented sentinel — principle 3 satisfied on its own terms, not by test coverage.
Judging the shape as asked, not just the tests: the residual question is whether the ambiguity merely moved into the returned bool. I do not think it did. The return has exactly two states, "the work ran" / "the lock was busy", the name states the condition, and the single call site (dl.py:485) consumes it only for a logging.debug. The docstring's claim that "ignoring that answer is safe" is true — nothing is protected by it. A caller cannot misread False into doing the work anyway, because the caller never had the work to do. -> bool is not a boolean-blindness relapse here; it is a report with one consumer.
One residual, non-blocking: hold_lock still yields True for contended while run_if_lock_free returns True for uncontended. The trap the prior review named was the pair — same shape, same type, near-identical names — and all three of those are gone (context manager vs. plain function, different names, different questions). Worth noting only because hold_lock's yielded waited has no production consumer at all (storage.py:105, workspace_clone.py:184,243, repo_manager.py:219, dl.py:1818 are all bare with hold_lock(...):), and it predates this PR — so the one remaining value-yielding lock helper yields a value nobody reads. Not this PR's to fix.
3 — closed. sweep_repo_fetches now takes storage and repo_manager off _get_clone_manager() (dl.py:472-474), which is the migration point. Pinned by TestTheChildMigratesLikeEveryOtherRun, and the pin discriminates: reverting only the construction change fails exactly the two migration tests with assert 1 == 2 and the old clone directory still present — reproduced independently by both axes, under both a targeted and a full test/unit selection.
Standards
- Three places assert a safety invariant the code does not hold —
locks.py:104"it defers to the foreground, and the foreground never defers to it";dl.py:454"Background defers to foreground and never the reverse";CHANGELOG.md:135"the sweep defers to launches and never the other way round". The foreground does defer. I re-derived the path myself rather than taking the axis's word:ensure_repotakes the repo lock with blockinghold_lock(repo_manager.py:219); the sweep holds that same lock for the whole oflazy_fetch→fetch_repo, whosesubprocess.run(["git","fetch",...])has notimeout=(repo_manager.py:157-163); andlazy_fetchitself takes no lock (repo_manager.py:190-202), so the hold spans the entire network call. A launch colliding with the sweep waits an unbounded time, tolddl: waiting for another dl run preparing owner/repo— naming a detached child spawned withstdout/stderratDEVNULLandstart_new_session=True(dl.py:428-433), which the user cannot see or Ctrl-C. This is precisely the builder's parked item (b), and the code documents its absence. blocking — see the verdict for why this one is not waved through. run_if_lock_free— "lock free" is a concurrency term of art (lock-free algorithms) meaning almost the opposite of what is meant here.run_if_lock_available/try_run_lockedreads correctly. Mysterious Name. non-blockingreset_clone_manager(dl.py:2161) is public production API whose only caller istest/conftest.py. Mild test-induced design damage — but the alternative, a conftest reaching intodl._cache, is worse, and the memo genuinely is a real cross-test contamination source now that the sweep goes through it. A leading underscore would say what it is. non-blocking- Duplicated mkdir/open/
finally: os.closepreamble betweenlocks.py:69-71and:106-108. Extract on the third. non-blocking - The lock-ordering docstring is accurate now, and its refusal to enumerate the call sites is the right call — the prior review's complaint was that the enumeration had already gone stale. All acquisition sites take repo→metadata; none takes metadata→repo. resolved
- Parked item (a), the narrow
(ValueError, RuntimeError, OSError)catch: agree with the parking.MetadataStorage._loadis total by construction,saveraises theOSErrorfamily,fetch_reporaises onlyValueError/RuntimeError, and subprocess failures areOSError. Residual escape surface is negligible. non-blocking - No hardcoded
/home/<user>reintroduced:git diff f03158b...HEAD | grep '/home/'→ no output (run by me).
Spec
| Spec claim (#149) | Status | Evidence |
|---|---|---|
"(1) child with elapsed interval fetches and advances last_fetched" |
Met | test_a_repo_past_its_interval_is_fetched asserts the exact argv; test_fetching_advances_the_shared_fetch_clock asserts last_fetched() > stale |
| "(2) child with the repo lock held elsewhere skips without blocking and records no fetch" | Met, and genuinely pinned | fetches == [] plus clock unchanged; the non-blocking half proven by mutation (below) |
| "(3) child within interval fetches nothing" | Met, non-vacuous | Mutating lazy_fetch→fetch_repo turns it red |
"Seam: fake subprocess.run recording git argv, prior art test_devpod_spawn_counts.py" |
Met | Subprocesses.run installed via monkeypatch.setattr, assertions on argv |
| "Document the repo→metadata lock ordering as an invariant in the module docstring" | Met | locks.py:26-42 |
| "leave the foreground untouched for now" | Met | Diff touches neither repo_manager.py nor workspace_clone.py; lazy_fetch/last_fetched unchanged, so #150's seam is intact |
- The
try_hold_lock→run_if_lock_freeswap is legitimate supersession, not a spec deviation. #149's binding text is behavioural — "non-blocking sibling ofhold_lock… never waits, never unlinks" — and all three hold and are pinned. "yields held-or-not" is an implementation shape, and that exact shape is what the prior review ruled a blocking defect. A ticket's suggested shape loses to a review finding against it. not a finding - The PR body is stale. It still documents "
try_hold_lockinworktree/locks.py" — a symbol this head deletes, with a test asserting its absence — describes property (2) as "proved with a thread and a deadline" when the harness is now inline plusSIGALRM, and claims "1173 passed" against a measured 1182. The body is what a reader reaches for first. Rewrite before merge. non-blocking - The sweep sits behind the
completion_cache_is_fresh()early return, so afetch_intervalshortened belowCOMPLETION_CACHE_TTL_SECONDS = 3600is silently capped at an hour. Inherited from #149's own instruction to place it "afterupdate_completion_cache()"; no regression. non-blocking reset_clone_manager()per test andGIT_TERMINAL_PROMPT=0weaken no mandated pin — the reset is what makes the fetch and migration assertions non-vacuous; every mutation above went red with it in place. not a finding
Ruling on the network-escaping test, and on SIGALRM
The prior review reproduced a real escape — 5 failed, 6 passed in 51.32s, assert 0 == 2 alongside an in-process Successfully fetched updates for owner/repo, i.e. subprocess.run unpatched and the suite on the network — and could not reproduce it on demand, labelling the patch-leak explanation analysis, not diagnosis.
The fix is good enough. I accept it. The reasoning is not "the builder ran it a lot":
- The mechanism is removed by construction, not hunted. A process-global replacement entered inside a daemon thread that can outlive its join is a sufficient cause for exactly the observed signature.
monkeypatch.setattr(subprocess, "run", ...)is undone by pytest at teardown of the same test in the same thread, unconditionally. There is no longer a replacement that can survive its test, whatever else goes wrong. - The grep claim is true — I verified it myself, not by accepting it.
grep -rn "threading\|Thread(\|thread" test/ --include=*.pymatches in exactly two files,test/unit/test_locks.pyandtest/unit/test_updater_fetch_sweep.py, and nothing else in the wholetest/tree;grep -rn "multiprocessing\|ThreadPool\|concurrent.futures" test/→ no hits. The one surviving thread (test_locks.py:110) callsrun_if_lock_freeand patches nothing; the sweep file's only use ofthreadingisget_ident. - The property, not the mechanism, is now pinned.
test_the_updater_runs_in_the_thread_that_replaced_subprocess_runasserts everysubprocess.runthe updater makes comes from the test's own thread, andtest_the_real_subprocess_run_is_back_once_a_test_endsasserts the real one is restored. A different mechanism producing the same signature would still have to defeat those. That is what makes an unreproduced flake closable.
Residual risk accepted: nobody has demonstrated that the observed event had this cause, and the claim is structural rather than statistical. The builder says so plainly, which is the right way to report it. GIT_TERMINAL_PROMPT=0 is a sound complement — it converts a future escape from a 50-second "slow run" into an immediate failure at the command that left the machine.
SIGALRM — acceptable, and better than the thread it replaces. Judged rather than waved through:
- It does the job the 20s join did. A genuinely blocking sweep blocks forever on a held lock, not merely slowly, so a 30s wall-clock alarm is a real pin and not a perf budget. Proven by mutation (
LOCK_EX | LOCK_NB→LOCK_EX): the sweep file goes2 failed, 8 passed in 63.45s, both withAssertionError: the cache updater blocked instead of moving on, the frame atlocks.py … run_if_lock_free. Per PEP 475 the raising handler defeats the automaticEINTRretry, which is why the blockedflockactually surfaces. - Its constraints do not bite in this repo as configured — I checked rather than assumed:
pyproject.tomlhas nopytest-xdistorpytest-timeoutconfiguration, neither plugin is importable in the py310 environment,addopts = "-m 'not e2e'", andcirunscoverage run -m pytest— serial, main thread.signal.signaloff the main thread would raise; nothing here runs off it. - Latent, non-blocking: the harness is silently main-thread-only and owns
ITIMER_REALfor the duration. Adding-n autolater, or any second user ofITIMER_REAL, breaks it in a confusing way. One comment line saying "main thread only" would fix that. - Observed, worth a look, non-blocking: the deadline discipline is applied in one file and not the other. Under my own blocking-flock mutation I ran the combined selection
test_updater_fetch_sweep.py test_locks.pyand it did not terminate within my 400s cap — I killed it, so I have no result from that run, only the non-termination. The sweep file alone fails cleanly in ~63s. The likely reason, by reading and explicitly analysis rather than diagnosis:test_locks.pyhas nodeadline(), andtest_a_lock_not_acquired_is_not_released_out_from_under_its_holdercallsrun_if_lock_freeon a lock the same process holds, which a blocking regression wedges permanently. Since wedging-instead-of-failing is the exact hazarddeadline()exists to prevent, the new file's discipline is worth extending to the old one.
What I re-derived myself, and what I took from the axes
Re-derived by me (lead), with real output:
pixi run -e py310 ciin a clean worktree at83b69e3→ exit 0;75 files left unchanged; ruffAll checks passed!;Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00);tyAll checks passed!;1182 passed, 23 deselected in 73.20s (0:01:13); TOTAL coverage 93%. The builder's CI claim, including the 1182, is confirmed by my own run — not accepted from its paste.- The thread/patch grep claim (commands and results above).
- The pytest parallelism configuration and what
ciactually executes. - The who-waits-on-whom path by reading:
repo_manager.py:219blockinghold_lock,:157-163untimedsubprocess.run,:190-202lazy_fetchtakes no lock (so no self-deadlock, and the hold spans the whole fetch),dl.py:428-433child stdio. run_if_lock_free's shape and its single call site; thathold_lock's yielded value has no production consumer.grep '/home/'over the three-dot diff → no hits.
Taken from the axes — each ran it independently, and the two agree test-name for test-name, but I did not run these two mutations myself:
- Mutation A (
work()also called on theBlockingIOErrorpath):3 failed, 328 passedunder fulltest/unit—test_work_is_held_off_entirely_when_another_run_holds_the_lock,test_a_repo_another_run_is_holding_is_skipped,test_a_skipped_repo_keeps_its_fetch_clock. - Mutation B (revert only the
_get_clone_manager()construction):2 failed, 329 passedunder fulltest/unit— the twoTestTheChildMigratesLikeEveryOtherRuntests,assert 1 == 2. - Greens under stated selections:
20 passed(the two files alone),331 passed(fulltest/unit),1182 passed, 23 deselected(full default suite). No guard was red only in isolation; both axes checked both selections. - The blocking-flock mutation result (
2 failed, 8 passed in 63.45s) is the Spec axis's; my own attempt at it did not complete, as described above.
Accepted as covered by the prior review, not re-derived: its non-blocking findings on docstring density and the then-stale lock-ordering enumeration (the latter is now accurate, which I did check).
Not verified by anyone here: py311/py312/py313 beyond CI's own green; the e2e suite locally; prek locally; the PR body's "verified end to end against real git in a scratch XDG_CACHE_HOME"; real multi-process contention between a live sweep and a live launch — the user-visible stall in the blocking finding is derived by reading, not reproduced; the cause of the original 5-failure escape, which remains unexplained by anyone.
Merge-order note, not a finding against this PR: sibling PR #147 adds a clone-manager reset to this same test/conftest.py under a different name (invalidate_clone_manager() plus an autouse fresh_clone_manager fixture) where this PR adds reset_clone_manager(). Both are correct alone; together they are a textual conflict and one concept under two names. Whichever lands second should adopt the first's name rather than keeping both.
Verdict
Request changes — narrowly, on one finding.
Blocking:
locks.py:104,dl.py:454andCHANGELOG.md:135assert that the foreground never waits on the background, and it does.ensure_repoblocks on the repo lock (repo_manager.py:219) while the sweep holds it across an untimedgit fetch(repo_manager.py:157). Either bound the sweep's fetch or correct all three statements to say what is true: the sweep never queues for a launch, but a launch can still queue for the sweep.
I want to be plain about why this blocks when the prior review ruled the same behaviour non-blocking, and about what it is not. It is not a defect in the sweep, and I am not re-litigating the parked limitation on its own. What changed is the frame: the builder parked it on the grounds that it is "not in the seam #150 builds on", and that rationale does not hold — #150 replaces the foreground fetch with a targeted one, so who-waits-on-whom is the seam, and #150 is blocked on this ticket and will be read alongside these three sentences. This PR argues, in its own words, that "a stale list is worse than none because it is what the next reader trusts"; the same standard applied to its own prose is what produces this finding. The fix is a few lines of prose, or one timeout= — cheap now, and mispriced later once a false invariant has been built on.
Everything else is non-blocking, and the substance of this PR is in good shape. All three original findings are closed, and closed by construction rather than by documentation: the ignorable sentinel is gone because there is no longer a body that can run unlocked, and the migration bypass is gone through dl's single construction point. I proved both pins discriminate by mutation rather than trusting the pasted evidence, under both isolated and full-suite selections, and I reproduced the full CI green myself. The network-escaping harness is a legitimate close: honest non-reproduction plus removal of a sufficient mechanism plus a direct pin on the property is the right answer to an unreproducible flake, and the grep that supports it holds up.
…ch leak
**The not-acquired case has no body.** The review found the same defect twice:
`hold_lock` yields "I had to wait" and `try_hold_lock` yielded "I got it" —
opposite meanings under near-identical names — and `try_hold_lock`'s block ran
whether or not the lock was taken, so a caller who dropped the flag did the
protected work unlocked with nothing at the call site reading as wrong. Both go
away by not handing out the flag: `run_if_lock_free` takes the work instead of
yielding, so on a contended lock there is nothing to run and no guard to forget.
It still returns whether the work ran, but that answer reports the past rather
than protecting anything — dropping it costs a debug line, not the lock.
**The sweep goes through dl's single construction point.** It built its own
storage, so the detached child was the one process touching metadata without the
one-shot cache migration every other dl path runs — a background process writing
un-migrated records, surfacing far from its cause. It now takes storage and the
repo manager from `_get_clone_manager()`, and two tests pin it: the child renames
an old-scheme clone directory and leaves the on-disk schema header current.
**The subprocess boundary cannot outlive the test that replaced it.** The
updater test entered a process-global `patch("subprocess.run")` inside a daemon
thread with a 20s join; two reviewers separately saw the suite escape that mock
and make real network calls. The thread is gone: the updater runs inline under
`monkeypatch`, which pytest undoes in this thread at teardown, and a signal
deadline stands in for the join so a sweep that queued still fails one test
rather than wedging the suite. The clone-manager memo is now reset per test — it
is bound to a cache directory the suite moves — and the suite refuses interactive
git credentials, so a stray real fetch fails at once instead of reading as slow.
The lock-ordering docstring loses its enumeration of call sites, which was
already wrong when written (four acquisitions, not three) and omitted the launch
lock. The invariant stays; the list that goes stale does not.
Rebased onto main. The only conflict was positional: #174's fix rewrote `_unsaved_work_in`'s signature and docstring at exactly the line the sweep was inserted above. Both sides kept, main's version of the function taken whole. **The three "background never blocks foreground" claims were false.** `run_if_lock_free` only makes the *caller* never queue. Once `work()` starts it is an ordinary exclusive lock, and it is the same lock `ensure_repo` takes with blocking `hold_lock` -- so a launch of a repo the sweep is fetching waits for the whole fetch. Reproduced with two real processes: a sweep holding the lock across an 8s fetch made a foreground `hold_lock` wait 6.51s, printing `dl: waiting for another dl run preparing owner/repo`. That "other dl run" is a detached child in its own session, so the user can neither see it nor Ctrl-C it, and `fetch_repo`'s `subprocess.run` had no `timeout=`, so the wait had no upper bound at all -- a remote that accepts a connection and then goes quiet holds the repo for as long as the kernel keeps the socket. Fixed on both sides rather than either. `fetch_repo`/`lazy_fetch` take an optional `timeout`, defaulting to None so the launch path is byte-identical and stays #150's to decide; the sweep passes 300s. And the prose in all three places now states the asymmetry that is true: the sweep never queues for a launch, but a launch can queue for the sweep. `subprocess.TimeoutExpired` is a `SubprocessError`, not an `OSError`, so the sweep's existing catch would have let it out of the loop and cost every later repo its refresh to the first slow remote. `fetch_repo` converts it to `RuntimeError` like every other fetch failure. Pinned by a test that fails without it. **Merge order resolved as the review asked.** #147 landed `invalidate_clone_manager()` plus an autouse `fresh_clone_manager` fixture on main; this branch's `reset_clone_manager()` was the same concept on the same `_cache` key under a second name. This landed second, so it adopts the name that is already there and drops its own, returning `isolated_devlaunch_cache` to main's shape.
83b69e3 to
b8a4914
Compare
Closes #149.
The hourly
+refs/heads/*freshness fetch gains a second home: the detacheddl --update-cachechild devlaunch already spawns and forgets. This is thefirst half of the #144 decision
— the sweep has to exist before the foreground can stop sweeping, which is
#150's job.
What changed
run_if_lock_freeinworktree/locks.py— a non-blocking sibling ofhold_lock, which already attemptsLOCK_NBfirst and then waits. It takes thework rather than yielding a "did I get it" flag, so the not-acquired case has no
body to run: the lock is either held for the whole of the work or the work never
happens. Unlocked work is not expressible, which is what an earlier revision's
yielded boolean could not promise. Like
hold_lockit is not reentrant and neverunlinks the lock file.
What that lock does and does not buy. It makes the caller never queue. It
does not make the lock cheap to hold: once the work starts this is an ordinary
exclusive lock, and it is the same one
ensure_repotakes with blockinghold_lock. So the honest statement is asymmetric — the sweep never queues fora launch, but a launch can queue for the sweep — and that is now what the code,
the lock docstring and the changelog all say. An earlier revision of this branch
claimed in three places that the foreground never waits on the background, which
was not true.
It was reproduced with two real processes: a sweep holding the repo lock across
an 8s fetch made a foreground
hold_lockwait 6.51s, printingdl: waiting for another dl run preparing owner/repo— an "other dl run" the user can neithersee nor Ctrl-C, since the child is spawned with
start_new_session=True.Untimed, that wait had no upper bound:
fetch_repo'ssubprocess.runcarried notimeout=, so a remote that accepts a connection and then goes quiet holds therepo for as long as the kernel keeps the socket.
fetch_repo/lazy_fetchnowtake an optional
timeout, defaulting toNoneso the launch path isbyte-identical and stays #150's to decide, and the sweep passes 300s.
The module docstring also writes down the lock ordering that several call sites
already depend on and nothing stated: the metadata lock may be taken while a
repo lock is held; never the reverse. A single site taking them the other way
round would deadlock two dl runs against each other, and nothing would look wrong
at either site. The enumeration of sites is deliberately left out — it goes stale
the first time someone adds a writer.
sweep_repo_fetches()indl.py, called by the--update-cachebranch afterthe completion refresh. For each repo in metadata it takes the repo lock
non-blockingly and runs the existing
lazy_fetch— so the interval gating, therefspec and the
last_fetchedbookkeeping are all unchanged code, just runningsomewhere else. It reaches metadata through
_get_clone_manager(), dl's singleconstruction point and where the one-shot cache migration runs, rather than
building its own storage: a detached child is the worst place to skip a
migration, since nobody is watching it write records in a shape the rest of dl no
longer reads.
A contended repo is skipped; a failed fetch is logged and stepped over, because
one unreachable remote must not cost every other repo its refresh and a detached
child has no terminal to complain to. A fetch that hits its deadline arrives as
subprocess.TimeoutExpired, which is aSubprocessErrorand not anOSError—so
fetch_repoconverts it toRuntimeErrorlike every other fetch failure,without which it would have escaped the sweep's catch and ended the loop at the
first slow remote.
What has not changed yet
The foreground still runs its own interval fetch, and the sweep is a pure
optimisation — the launch path is correct whether or not the updater ever runs.
Both sides read the same
last_fetchedclock, so whichever gets there firstspares the other; the double gate is transient and harmless, and it is what makes
this landable on its own. #150 removes the foreground half and replaces it with a
single targeted fetch of the requested ref;
_prepare_workspace's unusedworkspace-clone fetch is also its to delete. Neither is touched here.
Tests
test/unit/test_locks.py— the lock's own promises: the work runs when the lockis free, is held off entirely when another opener holds it (proved with a thread
and a deadline, so a regression fails instead of wedging the suite), the lock file
is left on disk, and a miss releases nothing out from under the real holder.
test/unit/test_updater_fetch_sweep.py— the child's behaviour pinned at thesubprocessboundary by argv, the seamtest_devpod_spawn_counts.pyestablished.Past its interval → exactly the broad refspec fetch, and the shared clock
advances. Within its interval → nothing. Repo lock held by another opener →
nothing fetched, the clock untouched, and the child returns rather than queueing.
One unreachable remote → the next repo is still fetched. The background fetch
carries its bound and the launch path's does not. A fetch that hits the deadline
→ the next repo is still fetched. Plus the migration pins, and two guards that the
subprocessreplacement cannot outlive the test that made it.The updater runs inline under
monkeypatchrather than on a daemon thread, with aSIGALRMdeadline standing in for a join, so a sweep that queued fails one testinstead of wedging the suite and no replacement can leak into a later test. The
clone-manager memo is reset per test by main's autouse
fresh_clone_managerfixture, and the suite refuses interactive git credentials so a stray real fetch
fails at once instead of reading as slow.
Green:
pixi run test→ 1336 passed, 23 deselected.pixi run lint→ ruff,tyand pylint 10.00/10 all clean.
pixi run prek→ all hooks pass.