fix(mm): close TOCTOU race in _restore_incomplete_installs#9142
fix(mm): close TOCTOU race in _restore_incomplete_installs#9142lstein wants to merge 16 commits into
Conversation
The restore path snapshotted active sources under the lock, then released the lock and iterated tmpdirs against the stale snapshot. A foreground import_model call landing during iteration could append a job the loop never saw, leading to a duplicate enqueue and a FileNotFoundError when the second download tried to rename the .downloading file the first had already moved. Move the membership check inside the lock immediately before the append so check-and-append is atomic. Fixes #9141. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JPPhoto
left a comment
There was a problem hiding this comment.
This looks good when inspecting visually, but I'd also like to see a test added for this in tests/app/services/model_install/test_model_install.py, especially since it's a race condition. It may be a little challenging to get the test right, though:
- Create a tmp install dir with an active marker for a remote source.
- Monkeypatch
_resume_remote_download()so restore pauses at a controlled point. - Insert an active job for the same
sourceinto_install_jobswhile restore is between marker parsing and the new locked duplicate check. - Let restore continue.
- Assert restore did not append a duplicate job and did not enqueue/resume a second download.
That maps directly to the PR’s fix around invokeai/app/services/model_install/model_install_default.py:249.
The only slightly fiddly part is choosing the pause point. Because the fixed code does the duplicate check under _lock immediately before append, the test probably needs to monkeypatch either _guess_source() or _read_install_marker() to signal “restore has observed the marker”, then add the foreground job before restore reaches the locked block. That is manageable with two threading.Events, similar to the existing test_import_waits_for_startup_restore.
The test should not need real network downloads if _resume_remote_download() is monkeypatched to fail the test if called for the skipped duplicate.
Per review feedback, add a test that pauses _restore_incomplete_installs between marker parsing and the locked duplicate check (via a monkeypatched _guess_source), queues a foreground job for the same source in the window, then asserts restore skips the source: no duplicate job is appended and _resume_remote_download is never called. Verified the test fails against the pre-fix code on main and passes with the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the detailed test sketch @JPPhoto — added in b953bba, following it closely:
To confirm it actually covers the race, I ran the test against the pre-fix |
JPPhoto
left a comment
There was a problem hiding this comment.
-
invokeai/app/services/model_install/model_install_default.py:222:_restore_incomplete_installs()now marks a source as seen before it checks whether that source is already active. If there are twotmpinstall_*dirs with markers for the same source and an active job is already using the second dir, restore can see the stale first dir, add the source toseen_sources, skip it as active at lines 249-255, then treat the active second dir as a duplicate and delete it at lines 222-224. That can remove the active job's partial download directory while the job is still tracked in_install_jobsor_download_cache, causing later resume/download/install work to fail or restart incorrectly. To expose this issue, add a test that creates two install marker dirs for the same source, registers an active job whose_install_tmpdiris the second dir, forces restore to visit the first dir before the active dir, then asserts the active dir still exists and no duplicate restore job was queued. -
invokeai/app/services/model_install/model_install_default.py:249: The new lock is one-sided and does not make registration atomic withimport_model(). The production import path checks_install_jobsat line 477, updates_download_cacheat line 1264, and appends at line 495 without acquiring_lock. An import that passes_wait_for_restore_complete()immediately beforestart()clears the initially-set event can therefore race restoration: both paths can observe no active job and enqueue the same source. The new test masks this because it manually acquires_lockbefore appending atinvokeai/tests/app/services/model_install/test_model_install.py:442, unlike the production path. To expose this issue, add a test that pauses a realimport_model()call after its duplicate check, starts restoration for the same source, lets restore perform its locked check, and then allows both paths to register their jobs; assert that only one job and one download are created. -
invokeai/tests/app/services/model_install/test_model_install.py:381: The new regression test covers the single-marker duplicate-source case, but it does not cover the tmpdir ownership/deletion case above because it appends a foreground job using the sametmpdiras the marker at lines 436-443. It also bypasses the real remote import shape where_enqueue_remote_download()stores the job in_download_cachebeforeimport_model()appends it to_install_jobs(invokeai/app/services/model_install/model_install_default.py:1264and:495). To expose this issue, add coverage where the active job is represented by_download_cacheand has a different_install_tmpdirfrom an older duplicate marker for the same source.
…tomic Address JPPhoto's second review round: 1. Restore could delete an active job's tmpdir: the seen_sources dedup ran before the active-source check, so with two markers for one source and an active job owning the later-visited dir, the stale dir marked the source seen and the active dir was then rmtree'd as a duplicate. The dedup now runs inside the locked section after the active check, and no tmpdir is ever deleted while its source has an active job; stale duplicates are collected on a later idle startup. 2. The restore-side lock was one-sided: import_model checked and registered jobs without holding _lock, so a real import could still race restore. _lock is now an RLock (the import helpers call _next_id, which acquires it) and import_model holds it across its duplicate check, job creation, and registration. New regression tests, each verified to fail against the pre-fix code: - test_restore_preserves_active_jobs_tmpdir (active job in _install_jobs) - test_restore_preserves_tmpdir_of_job_in_download_cache (active job only in _download_cache with a different tmpdir, per review) - test_concurrent_import_and_restore_register_single_job (real import_model racing restore, no manual locking; install queue buffered so the job cannot go terminal before restore's active check) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @JPPhoto — all three findings were real, and they're addressed in ebaec12. 1. Restore deleting an active job's tmpdir. Confirmed exactly as you described: the 2. One-sided lock. Also confirmed. 3. Verification: all three new tests fail against the pre-fix code (checked by reverting |
JPPhoto
left a comment
There was a problem hiding this comment.
Two issues:
-
invokeai/app/services/model_install/model_install_default.py:493:import_model()now holds the installer lock while remote import reaches_multifile_download(), which acquires the download queue lock through_next_id(). Download callbacks take these locks in the opposite order:invokeai/app/services/download/download_default.py:691holds the download queue lock while invoking_download_progress_callback(), which acquires the installer lock atinvokeai/app/services/model_install/model_install_default.py:1400. If a second remote import begins while an earlier download callback is running, the callback can wait for the installer lock while the import waits for the download queue lock, permanently deadlocking both threads. This can freeze active downloads and leave the new import request blocked indefinitely. To expose this issue, add a test that pauses an existing multifile callback while it holds the download queue lock, starts another remoteimport_model()call until it holds the installer lock and requests a download job ID, then releases the callback and asserts both threads finish within a timeout. -
invokeai/app/services/model_install/model_install_default.py:253: The new per-marker active check excludes terminal jobs, but job terminal transitions, marker deletion, and temporary-directory cleanup are not synchronized with this lock. Restore can read an active job's marker, the installer thread can then mark that job completed at line 1487, and restore can subsequently see no nonterminal owner and append a second job for the same marker at line 264. The original up-frontactive_sourcessnapshot removed by this PR would continue treating a job that was active when restoration began as owned. The original job then deletes the marker and temporary directory while the duplicate restore job resumes from that directory, causing a duplicate installation or filesystem failure. To expose this issue, add a test that starts restore with an active job owning the marker directory, pauses restore after reading the marker, transitions the owner toCOMPLETEDwhile leaving its marker and directory present, resumes restore, and asserts that no second job or download is queued for that source.
… race in restore Addresses JPPhoto's third review round: - import_model no longer holds the installer lock while calling into the download queue. Download-queue callbacks run on queue threads that hold the queue's lock while acquiring the installer lock, so the previous full-body lock inverted the acquisition order and could deadlock a new import against an in-flight download callback. import_model now reserves the source in _pending_sources under the lock, runs the import helpers unlocked, and registers the job under the lock afterwards; concurrent imports of the same source wait on a condition variable and return the registered job. The lock reverts from RLock to Lock since nothing acquires it reentrantly anymore. - _restore_incomplete_installs snapshots the set of actively-owned sources before scanning. Terminal transitions, marker deletion and tmpdir cleanup are not synchronized with the per-marker check, so an owner completing mid-scan could look inactive while its marker was still on disk, causing restore to enqueue a duplicate job for a directory the owner was about to clean up. A source owned when the scan starts now stays owned for the whole scan. Both regression tests fail against the previous code: - test_import_during_paused_download_callback_does_not_deadlock pauses a multifile on_start callback while it holds the queue lock, drives a second real import_model to the point of requesting a download job ID, releases the callback and asserts both threads finish. It uses a private download queue and daemon import thread so a regression fails the test in ~15s instead of hanging the run in fixture teardown. - test_restore_skips_marker_of_job_completing_mid_scan pauses restore after it reads the owner's marker, transitions the owner to COMPLETED with marker and directory still present, and asserts no duplicate job or download is queued. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @JPPhoto — both findings were real, and both are fixed in f60cbf7 along with the two regression tests you outlined. 1. Lock-order deadlock in Confirmed: Fix: Since nothing acquires the lock reentrantly anymore, the Test: 2. Owner completing mid-scan re-enqueued by restore Confirmed: the per-marker active check alone can't see a job that was active when restore read the marker but transitioned to COMPLETED before the locked check, since terminal transitions and marker/tmpdir cleanup aren't synchronized with the installer lock. Fix: Test: Verification
|
JPPhoto
left a comment
There was a problem hiding this comment.
The reservation closes the deadlock, but it introduces an unsolved handoff case: restore treats a pending import as an owner and permanently skips its marker, even though that import may fail before registering any job:
invokeai/app/services/model_install/model_install_default.py:222:_restore_incomplete_installs()permanently copies_pending_sourcesintoowned_sources, but a pending import is only a reservation and may fail before registering a job. Ifimport_model()is pending when restoration starts and its helper raises at lines 531-548, the reservation is removed, but restore still skips every marker for that source at lines 273-282. Startup then finishes with the incomplete install neither restored nor owned by an active job, deferring recovery until another restart or manual retry. Test: create an incomplete marker, pauseimport_model()after it reserves the source, start restoration so the reservation is included inowned_sources, make the import helper raise before registering a job, and assert restoration rechecks the failed reservation and queues the marker instead of completing with no owner.
Summary
This PR fixes an intermittent unit test failure in
test_model_install.pyand, following review, several further races in the same code._restore_incomplete_installssnapshotted active sources under the lock, then iterated tmpdirs against the stale snapshot. A foregroundimport_modelcall landing during iteration could be missed, the same source enqueued twice, and the second download would fail withFileNotFoundErrorwhen trying to rename the.downloadingfile the first download had already moved._install_jobs.append(job)so check-and-append is atomic.import_modelchecked/registered without holding_lock.import_modelnow performs an atomic check-and-register.import_model's download enqueue inverted the lock order against download-queue callbacks (queue lock → installer lock) and could deadlock a new import against an in-flight callback.import_modelnow reserves the source in_pending_sourcesunder the lock, runs the import helpers unlocked, and registers the job under the lock; concurrent imports of the same source wait on a condition variable. The lock is a plainLockagain, with the lock-order rule documented at its declaration.Fixes #9141.
Test plan
pytest tests/app/services/model_install/test_model_install.py::test_simple_download— ran 10× consecutively, all pass (was flaky ~30–50% onmain)test_restore_skips_source_queued_during_restore: pauses restore in the race window, queues a foreground job for the same source, asserts no duplicate is enqueued — fails onmain, passes with this fixtest_restore_preserves_active_jobs_tmpdir: two markers for one source, active job owns the later-visited dir — asserts the active dir survives restoretest_restore_preserves_tmpdir_of_job_in_download_cache: same, with the active job tracked only in_download_cachewith a different tmpdirtest_concurrent_import_and_restore_register_single_job: a realimport_model()racing restore registers exactly one job and completes end-to-endtest_import_during_paused_download_callback_does_not_deadlock: a secondimport_model()requests a download job ID while a download callback is paused inside the queue lock — asserts both threads finish and both installs completetest_restore_skips_marker_of_job_completing_mid_scan: the owner transitions to COMPLETED after restore reads its marker — asserts no duplicate job or download is queueddeadlock,mid_scan,concurrent_import_and_restore) pass 10/10 consecutive runspytest tests/app/services/model_install/— 39/39 pass🤖 Generated with Claude Code