fix(mm): close TOCTOU race in _restore_incomplete_installs#9142
fix(mm): close TOCTOU race in _restore_incomplete_installs#9142lstein wants to merge 13 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.
Summary
This PR fixes an intermittent unit test failure in
test_model_install.pyand, following review, two 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._lockis now anRLockandimport_modelholds it across its duplicate check, job creation, and registration, making it atomic with restore.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-end; 15/15 consecutive runspytest tests/app/services/model_install/— 37/37 pass🤖 Generated with Claude Code