Address review feedback on pull repo thread safety - #303
Conversation
The lock gives mutual exclusion but not atomicity: nothing rolled back
partial on-disk state, and the next process misread that state as
healthy. Six review comments, all in mlc/repo_action.py.
1. Partial clone poisoned the path permanently (blocking). A clone killed
mid-flight leaves a .git with an origin but no HEAD; the existence
check then took the "already exists" branch and every later pull failed
with "no tracking information". Clone now goes to a <repo>.tmp-clone
sibling and is os.rename'd into place inside the lock, with cleanup on
BaseException so KeyboardInterrupt also tidies up. repo_path is
therefore only ever absent or complete.
2. The existing-repo branch could not tell a healthy checkout from a
half-cloned one. git status exits 0 with empty stdout on the wreckage
and 128 with empty stdout on a non-git directory, so both read as
"clean". Added _is_valid_git_repo(), which probes `rev-parse HEAD`;
anything that fails it is removed and re-cloned rather than pulled.
3. repos.json writes were non-atomic and readers never take the lock:
Action.load_repos_and_meta() and Action.load_repos() both json.load it
bare, so a read landing in the truncate-then-rewrite window sees a
truncated file. Added _atomic_write_json() (temp file + os.replace),
used by register_repo and unregister_repo. This makes the existing
lock-free readers safe without touching action.py.
4. The 300s repo lock timeout was too short for a cold clone of a large
repo and failed hard. Now MLC_REPO_LOCK_TIMEOUT, default 1800s, and on
Timeout we re-check the repo: if the holder left a valid checkout the
work is already done, so return success instead of a spurious error.
Added an explicit PermissionError branch, since creating <repo>.lock
needs write access to repos_path itself and the generic handler only
surfaced a bare "[Errno 13]".
5. Documented the lock ordering invariant (repo lock -> repos.json lock)
at all three acquisition sites, so a future path taking them in the
opposite order is caught in review rather than as a mystery hang.
6. The post-register re-read: moving it inside the lock as suggested is
NOT safe. load_repos_and_meta() calls rm_repo() for vanished entries,
and rm_repo -> unregister_repo takes the same repos.json lock on a new
FileLock instance; filelock is only reentrant per instance, so this
self-deadlocks for the full 60s on every pull whose repos.json holds
one stale entry. Verified empirically. Kept the read outside the lock,
documented why, and closed the reported symptom -- the lookup missing
the repo just registered -- by falling back to the meta already in hand.
Tests: rewrote test_concurrent_pull_repo_clone_called_once, which patched
subprocess.run inside each thread, so the first thread to finish restored
the real one while the others were still in pull_repo and could shell out
to real git. The patch is now applied once in the main thread, a barrier
makes the threads actually contend, and the assertions check the returned
dicts -- pull_repo returns error dicts rather than raising, so the old
assertEqual(errors, []) would have stayed green on {'return': 1}.
Added coverage for each new behaviour, and extracted _RepoActionTestBase
so the new classes do not re-run the existing class's cases.
Verified by mutation: neutering _is_valid_git_repo, making the repos.json
write non-atomic, and removing the clone rollback each fail their tests;
full suite is 59 passed, stable over 5 consecutive runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅ |
🤖 AI PR Review SummaryThis PR improves concurrency safety and robustness in repo management by introducing atomic JSON writes, configurable repo lock timeouts, and stricter validation of git repo states. It also adds detailed lock ordering comments to prevent deadlocks and enhances error handling for permission issues. The changes reduce race conditions and partial state visibility, especially during cloning and repo registration. The test refactor introduces a base class for shared setup, improving test structure. Overall, the design is sound with clear attention to concurrency and failure modes. No major design issues found; comments focus on minor clarifications and potential improvements in logging and exception handling. |
| @@ -134,7 +187,8 @@ def _checkout_pull_branch(self, repo_path, branch): | |||
| f"Initial checkout of '{branch}' failed in {repo_path}. " | |||
There was a problem hiding this comment.
The multiline f-string expression split across lines is invalid syntax. This line should be fixed to a single line or use parentheses to concatenate strings properly.
| @@ -350,11 +427,24 @@ def register_repo(self, repo_path, repo_meta, ignore_on_conflict=False): | |||
| ) | |||
There was a problem hiding this comment.
The fallback to creating a new Repo object if not found in the loaded list is a good safety net. Consider logging this event for easier debugging.
| @@ -504,6 +594,8 @@ def pull_repo(self, repo_url, branch=None, checkout=None, tag=None, | |||
| pat=None, ssh=None, ignore_on_conflict=False, repo_path=None, force=False, | |||
There was a problem hiding this comment.
Cloning into a temporary directory and renaming is a robust approach to avoid partial clones. The cleanup in except block is good. Consider adding a debug log before removing tmp_clone_path.
| @@ -12,11 +12,9 @@ | |||
| from mlc import utils | |||
There was a problem hiding this comment.
Refactoring to a base test class for shared setup is a good practice to avoid test duplication and improve maintainability.
Three critical, three medium, four minor.
CRITICAL - the package did not parse on Python < 3.12. Running autopep8
under 3.13 rewrote a pre-existing line into a PEP 701 multi-line f-string
expression, which is a syntax error on 3.11 and older. pyproject declares
requires-python >=3.7 and CI runs 3.8/3.9/3.11, so this broke import of
the whole package -- including the format workflow itself, which runs on
3.9. Reverted both occurrences and added a syntax gate to my workflow;
verified mlc/ now parses on 3.10, 3.11 and 3.13.
CRITICAL - _is_valid_git_repo returned False for *every* failure, so a git
that could not be executed, or that refused (e.g. "detected dubious
ownership" on a shared MLC_REPOS), was read as "this is junk" and
pull_repo deleted the user's checkout, uncommitted work included.
Replaced with _git_repo_state() returning VALID/INVALID/UNKNOWN; only
INVALID is removed, UNKNOWN aborts with an explanation.
CRITICAL - the Timeout handler reported success whenever any checkout
existed, skipping the work actually requested: with --tag=v2 it returned
{'return': 0} while the tree sat at v1, unregistered and unindexed. For
MLPerf that is a silent wrong-version run, worse than the spurious error
it replaced. The shortcut is now limited to the one safe case -- no
branch/checkout/tag/force requested, repo already registered, checkout
valid -- and errors otherwise.
MEDIUM - lock ordering was not total. register_repo recurses into
pull_repo for deps while holding the parent's repo lock, so the real order
is <A>.lock -> <B>.lock -> repos.json.lock, and a self- or cyclic
dependency blocked for the whole timeout (now 1800s). Added thread-local
reentrancy so a lock already held by this thread is not re-acquired. Two
threads with crossed deps remain a genuine inversion; that is documented,
and it now surfaces as a Timeout error rather than hiding behind the false
success above.
MEDIUM - `git -C` searches upwards, so a plain directory nested inside
another checkout answered for the enclosing repo and was classified valid;
mlc would then run pull/checkout against that unrelated workspace. Now
compares `rev-parse --show-toplevel` against the path itself. This also
fixes a false negative: a freshly cloned empty repo has an unborn HEAD, so
the old `rev-parse HEAD` probe rejected a perfectly good checkout and
re-cloned it on every run.
MEDIUM - rmtree(ignore_errors=True) hid failed removals and fell through
to the "already exists -> pull" branch, i.e. straight back into the broken
state being cleaned up. Removal is now checked and reported, and handles a
file or symlink sitting where the repo should be (rmtree raises
NotADirectoryError on those).
MINOR - _atomic_write_json now uses mkstemp instead of a fixed .tmp name
and copies the original mode across, since os.replace installs a new inode
and would otherwise drop group access on a shared MLC_REPOS. PermissionError
handling narrowed to lock-file creation only, via RepoLockPermissionError,
rather than wrapping the whole 250-line body. rm_repo now takes the same
per-repo lock as pull_repo, closing the gap where `mlc rm repo X` could
delete the tree mid-clone.
Tests: added coverage for each new guard, and sharpened two that did not
isolate what they claimed. The clone-rollback test only checked the
aftermath, so cloning straight into repo_path still passed it; it now
asserts repo_path is absent *while* the clone runs. The timeout tests were
gated by the registration check, so the revision-request guard was
untested; they now register the repo first.
All mutants killed: tmp-clone -> repo_path, UNKNOWN treated as INVALID,
Timeout -> unconditional success, and dropping the toplevel comparison each
fail their tests. 69 passed, stable over 5 consecutive runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second review pass found that two of the fixes in 717bc19 caused worse problems than the ones they closed. CRITICAL - locking rm_repo broke every mlc command. rm_repo was previously infallible, so action.py's `if res["return"] > 0: return res` was dead code. Adding the lock made it fallible, and load_repos_and_meta() then returns that error *dict* where its callers expect a *list* of Repo objects -- including Action.__init__, so self.repos becomes a dict. The trigger is ordinary: a vanished repos.json entry whose lock is held, which is exactly the state during any first-time clone, since the tmp-clone scheme guarantees repo_path does not exist for the clone's duration. Measured: pulling an unrelated repo stalled for the full timeout (1800s in production) and then failed with "'str' object has no attribute 'meta'", leaving the repo it had successfully cloned unregistered. Locking there was also wrong for two further reasons: it blocks pruning behind an unrelated repo's lock, and filelock creates the lock file's parent, so pruning a vanished path *recreated* its directory -- on an unmounted volume, materialising the mount point. rm_repo is now unlocked and infallible again, with a docstring saying why. The lock moved to RepoAction.rm(), the user-facing `mlc rm repo` path, which is where the delete-during-clone hazard actually lives. MEDIUM - the dangling-symlink fix overshot. Short-circuiting on os.path.islink() before consulting git classified a *live* symlink to a healthy checkout as junk, so relocating a repo to another volume and symlinking it back got the link unlinked and the repo silently re-cloned into MLC_REPOS, stranding the real checkout and any local work in it. Now only a dangling link short-circuits; a live one falls through to git, and samefile() resolves it correctly. MEDIUM - reentrancy traded the deadlock for a recursion storm. It stops pull_repo blocking on its own lock, but nothing stopped the *recursion*: A deps B, B deps A produced 1466 git invocations including 488 pulls, each a network round-trip with real git, and reported success. Added a thread-local cycle guard (_repo_pull_lock) that returns early when a repo is already being pulled further up the same stack. MINOR - _git_repo_state had a conditional whose arms were identical. Rewritten so "absent", "dangling link" and "plain file" each return INVALID explicitly. Lock files are now keyed on a normalised path, since the key is both the cross-process exclusion token and the reentrancy key: "<root>/r" and "<root>/./r" previously took different locks for the same directory, so the exclusion silently did nothing. A test was popping MLC_REPO_LOCK_TIMEOUT instead of restoring it, clobbering any ambient value. Tests: the review found three behaviours from the previous commit with no coverage at all -- reentrancy, the rm lock, and copymode -- each of which survived mutation. Added RepoLockMechanicsTest, RmRepoLockingTest and AtomicWriteMetadataTest, plus a live-symlink case that the classification test was missing. All seven mutants now killed: reentrancy removed, rm lock removed, copymode dropped, live symlink to INVALID, cycle guard removed, rm_repo made fallible, and lock key unnormalised. 77 passed, stable over 4 runs, and mlc/ + tests/ verified parsing on 3.10 and 3.11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A third review pass found nothing above LOW. These are the four it found.
_repo_lock_path took basename() from the raw argument while abspath()
stripped the trailing slash, so basename("/base/repoA/") was "". That both
split one repo across two locks ("repoA" vs "repoA/") and collided
unrelated repos onto a single ".lock" -- the exact two failures the helper
exists to prevent, and the collision would additionally make _repo_lock's
reentrancy treat a nested pull of repoB as already-held while pulling
repoA. Trailing slashes reach here from the CLI: rm() uses
run_args['repo'] verbatim. basename() is now taken from the normalised
path.
RepoAction.rm() de-indexed before acquiring the lock, so a concurrent pull
could re-index the repo in the window and rm would report success with the
index still pointing into a deleted directory. The ordering predates the
lock, but the lock is what makes the window reachable. Moved inside;
ordering stays repo lock -> index lock, matching pull_repo.
The MLC_REPO_LOCK_TIMEOUT save/restore fix from the previous commit was
applied to one test class but the newly-added one still popped the
variable, clobbering any ambient value.
rm_repo's infallibility was pinned only against the per-repo lock. The
fallibility that broke every mlc command could also return via
unregister_repo's own 60s repos.json timeout, which nothing covered.
Testing the de-index ordering took two attempts worth recording: asserting
on Index.repos is vacuous, because remove_repo_from_index() mutates
Index.indices instead, and asserting on the persisted index files does not
work either, since a content-less fixture repo produces no entries. The
test now spies on remove_repo_from_index and asserts it is never called
when the lock could not be taken.
All four mutants killed: basename from the raw path, de-index moved above
the lock, rm_repo propagating unregister_repo's Timeout, and the earlier
lock-key mutant. 78 passed, stable over 4 runs; mlc/ and tests/ parse on
3.10, 3.11 and 3.13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7840539
into
copilot/fix-mlc-pull-repo-thread-safety
Resolves the six inline review comments on #265, plus the test issues raised in the review body. The theme across them: the lock gives mutual exclusion but not atomicity — nothing rolled back partial on-disk state, and the next process misread that state as healthy.
Comments resolved
1. Partial clone poisoned the path permanently (the blocking one)
A clone killed mid-flight leaves a
.gitwith a configured origin but noHEAD. The existence check then took the "already exists" branch, and every subsequentmlc pull repofailed identically until the user deleted the directory by hand.Clone now targets a
<repo>.tmp-clonesibling and isos.rename'd into place inside the lock, with cleanup onBaseExceptionsoKeyboardInterrupttidies up too.repo_pathis only ever absent or complete.2. Couldn't distinguish a healthy checkout from a half-cloned one
Confirmed the blind spot:
git status --porcelainexits 0 with empty stdout on the wreckage, and 128 with empty stdout on a non-git directory — both read as "clean". Added_is_valid_git_repo(), which probesgit rev-parse HEAD; anything failing it is removed and re-cloned rather than pulled.3.
repos.jsonwrites were non-atomic, and readers never take the lockAdded
_atomic_write_json()(temp file +os.replace), used by bothregister_repoandunregister_repo. This makes the existing lock-free readers inaction.pysafe without having to change them.4. 300s timeout too short, and timing out was a hard failure
Now
MLC_REPO_LOCK_TIMEOUT, defaulting to 1800s. OnTimeoutwe re-check the repo: if the holder left a valid checkout, the work is done, so return success rather than a spurious error. Also added the explicitPermissionErrorbranch — creating<repo>.lockneeds write access torepos_pathitself, and the generic handler only surfaced a bare[Errno 13].5. Nested lock acquisition undocumented
Lock-ordering invariant (repo lock →
repos.jsonlock) is now stated at all three acquisition sites.6. Post-register re-read —⚠️ the suggested fix is not safe, so I did something else
Moving
load_repos_and_meta()inside the lock self-deadlocks. It callsrm_repo()for entries whose path has vanished, andrm_repo→unregister_repotakes the samerepos.jsonlock on a freshFileLockinstance.filelockis only reentrant per instance:So this would hang for the full 60s on every pull whose
repos.jsonholds one stale entry. I kept the read outside the lock, documented why, and closed the actual reported symptom — the lookup missing the repo just registered — by falling back to the meta already in hand.Test fixes
test_concurrent_pull_repo_clone_called_oncepatchedsubprocess.runinside each thread, so the first thread to exit restored the real one while the others were still inpull_repoand could shell out to real git againstgithub.com/example/test-repo.git. The patch is now applied once in the main thread, a barrier makes the threads genuinely contend, and the assertions check the returned dicts —pull_reporeturns error dicts rather than raising, so the oldassertEqual(errors, [])stayed green on{'return': 1}.Added coverage for each new behaviour (half-clone recovery, failed-clone cleanup, atomic-write reader safety, failed-write rollback, timeout env override and its invalid-value fallback), and extracted
_RepoActionTestBaseso the new classes don't re-run the existing class's cases.Verification
Mutation-tested rather than assumed — each new test fails when its fix is removed:
_is_valid_git_repoalways returnsTrue_atomic_write_jsonwrites directlyrepo_path, no rollbackFull suite 59 passed, stable over 5 consecutive runs.
autopep8 --diff -aclean on both files.Not addressed
add_repo'sos.makedirs+register_reporace — you flagged it as out of scope for a PR titled "pull repo", and I've left it.🤖 Generated with Claude Code