Skip to content

Serialize imports behind startup restoration - #9433

Merged
lstein merged 5 commits into
invoke-ai:mainfrom
JPPhoto:model-install-restore-barrier
Aug 2, 2026
Merged

Serialize imports behind startup restoration#9433
lstein merged 5 commits into
invoke-ai:mainfrom
JPPhoto:model-install-restore-barrier

Conversation

@JPPhoto

@JPPhoto JPPhoto commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a TOCTOU race between startup restoration and foreground model imports.

PR #9239 introduced the startup restoration barrier by making import_model() and wait_for_installs() wait for _restore_completed_event. However, the event initially started set, leaving paths that could bypass the barrier. This PR closes those paths, hardens failure and timeout handling, and serializes concurrent imports of the same source.

_restore_incomplete_installs() previously scanned temporary install directories using an active-source snapshot that could become stale. A concurrent import_model() could register the same source during that scan, causing duplicate downloads and a later FileNotFoundError when both jobs attempted to move the same .downloading file.

This change establishes startup restoration as a barrier:

  • The restoration event begins unset.
  • import_model() waits for restoration to finish before examining or registering jobs.
  • Once restoration completes, normal duplicate detection sees every restored job.
  • Startup failures release waiters and propagate a clear error.
  • wait_for_installs() applies its timeout while waiting at the barrier.
  • Calls made before service startup fail instead of waiting indefinitely.

This removes restore/import concurrency instead of coordinating ownership while both operations run.

Difference From #9142

This is an alternative to #9142 and accepting one should close the other.

Unlike #9142, this PR builds on the barrier introduced by #9239 and removes restore/import overlap. It only adds per-source coordination for concurrent foreground imports; #9142 coordinates restoration and imports concurrently through a broader ownership and deferred-restoration protocol.

PR #9142 allows foreground imports and startup restoration to execute concurrently. It makes that concurrency safe using:

  • Atomic active-source checks
  • Per-source reservations
  • Condition-variable waits
  • Deferred marker restoration
  • Ownership snapshots
  • Reservation timeouts
  • Explicit cancellation and shutdown synchronization

This PR changes the ordering invariant: foreground imports cannot begin until startup restoration has finished. Because restore and import no longer overlap, it does not need a second source-ownership protocol or deferred-marker state machine.

The tradeoff is that an import requested during startup waits for restoration. Restoration already gates reliable knowledge of active installs, so this keeps synchronization at the startup boundary and leaves the normal import path unchanged after startup.

Scope comparison against current main:

Related Issues / Discussions

Fixes #9141.

Alternative implementation to #9142.

QA Instructions

Run:

pytest tests/app/services/model_install/

Expected result: all tests pass.

Regression coverage verifies:

  • An import racing startup waits for restoration.
  • The restored and foreground requests resolve to one job.
  • wait_for_installs() honors its timeout during restoration.
  • Imports and install waits fail cleanly before startup.
  • Imports fail cleanly after synchronous startup failure.

Merge Plan

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

@JPPhoto JPPhoto added the 6.14.0 label Aug 1, 2026
@JPPhoto JPPhoto moved this to 6.14.x Theme: USER EXPERIENCE in Invoke - Community Roadmap Aug 1, 2026
@github-actions github-actions Bot added python PRs that change python files services PRs that change app services python-tests PRs that change python tests labels Aug 1, 2026

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 842b01d against #9142, since the two are framed as alternatives. Up front: I think the ordering invariant in this PR is the better design, and I'd rather land it than my own PR. But I don't think it closes #9141 as it stands, so I'm requesting changes rather than approving.

Everything below was verified against the code and, where noted, reproduced.

Context worth stating: main already has the barrier

#9239 (merged, db0b08b) already added _restore_completed_event and made import_model() / wait_for_installs() wait on it. So this PR isn't introducing serialization — it's closing the remaining ways to slip past a barrier that already exists (event starts unset instead of set; startup failures propagate; wait_for_installs honors its timeout at the barrier). Worth saying explicitly in the PR body, because the "69 vs 411 lines" comparison reads differently once you account for the barrier already being in main.

Why I prefer this design over #9142

With a genuine barrier, restore's active_sources snapshot at L215-217 is a snapshot of a quiesced _install_jobs. That single fact makes most of #9142 unnecessary — the _pending_sources deferral, _source_import_generations, the deferred-marker recheck and DEFERRED_RESTORE_TIMEOUT all exist only because restore and import can overlap there. Removing the concurrency beats coordinating it, and restoration is a one-shot startup operation where nothing is gained by letting imports through. I'd rather maintain this.

Blocking: #9141 is still reachable, via import-vs-import

The barrier serializes restore against import. It doesn't serialize import against import, and import_model (L498-520) still takes no lock: similar_jobs is computed at L501, but self._install_jobs.append(install_job) happens at L519 — after the metadata fetch and after submit_multifile_download. The dedup window spans a full network round trip.

That matters because of _find_reusable_tmpdir (L191-208). _enqueue_remote_download writes the marker with status WAITING at L1313 before submitting at L1317, so a second import arriving at L1225 after the first reached L1313 is handed the first import's tmpdir, and both download into it.

Reproduced on this branch (both threads run well after restoration completed, so the barrier is not what's being tested):

# tests/app/services/model_install/test_probe.py
import threading, time
from pathlib import Path
import pytest
from pydantic_core import Url

from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.model_install import ModelInstallServiceBase
from invokeai.app.services.model_install.model_install_common import (
    InstallStatus, ModelInstallJob, URLModelSource,
)
from invokeai.app.services.model_install.model_install_default import TMPDIR_PREFIX
from invokeai.app.services.model_records import ModelRecordChanges
from tests.backend.model_manager.model_manager_fixtures import *  # noqa F403


@pytest.mark.timeout(timeout=60, method="thread")
def test_concurrent_imports_of_same_source(
    mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
) -> None:
    source = URLModelSource(url=Url("https://www.test.foo/download/test_embedding.safetensors"))
    mm2_installer._restore_completed_event.wait(timeout=10)

    # A prior interrupted install leaves a marker, so _find_reusable_tmpdir() hands out the SAME dir.
    tmpdir = Path(mm2_app_config.models_path) / f"{TMPDIR_PREFIX}reusable"
    tmpdir.mkdir()
    stub = ModelInstallJob(id=99998, source=source, config_in=ModelRecordChanges(), local_path=tmpdir)
    stub._install_tmpdir = tmpdir
    mm2_installer._write_install_marker(stub, status=InstallStatus.DOWNLOADING)

    real = mm2_installer._import_from_url

    def _slow(src, config=None):
        time.sleep(1.0)   # widen the existing window; the interleaving itself is legal
        return real(src, config)

    mm2_installer._import_from_url = _slow

    threads = [threading.Thread(target=lambda: mm2_installer.import_model(source)) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join(timeout=25)

    jobs = mm2_installer.get_job_by_source(source)
    assert len(jobs) == 1, f"{len(jobs)} jobs for one source, tmpdirs={[j._install_tmpdir for j in jobs]}"

Results:

branch jobs for one source outcome
main @ ae5694d 2, same tmpdir both ERROR — FileNotFoundError: .../test_embedding.safetensors
this PR @ 842b01d 2, same tmpdir identical failure
#9142 @ 74622e1 1 COMPLETED

That's the #9141 symptom verbatim, with the barrier fully in place.

Production path, no test harness: heuristic_import() is called from invokeai/app/invocations/flux_redux.py:156 and ip_adapter.py:220 on session-processor worker threads, and multi-GPU parallel sessions (#9263) makes two of those genuinely concurrent — two graphs needing the same not-yet-installed SigLIP / CLIP-Vision encoder. Both search_by_attrs return empty, both pass similar_jobs == [], both land in the same tmpdir. The HTTP routes are async def so they can't race each other; the node path is the exposure. Note the codebase already recognized this exact hazard for the sibling path and fixed it with _download_cache_lock (L757-797, comment: "parallel (multi-GPU) sessions ... don't race to download into the same cache directory"). import_model never got the equivalent.

Suggested fix: the reservation half of #9142 — a _pending_sources: set[str] plus a Condition on _lock; reserve the source under the lock, run the import helpers unlocked, register under the lock, and have a concurrent import of the same source wait and then re-run the duplicate check. That's ~40 lines and nothing else from #9142 is needed once this barrier exists. The unlocked-helpers part is not optional: download_default.py:691 dispatches _execute_cb while holding the queue lock, and those callbacks take the installer _lock (L1414/1429/1442/1451/1463), so holding _lock across submit_multifile_download inverts the order.

Non-blocking, cheap

  1. L374 except Exception should be except BaseException (or a finally). The event is cleared at L355 and only set by the restore thread spawned at L373; a BaseException escaping in between leaves the barrier permanently closed with _running=True and _startup_error=None, so _wait_for_restore_complete passes both guards and blocks forever at L305. In fairness I could not reach this in production — inside catch_sigint() Ctrl-C hard-kills via SIG_DFL + raise_signal, and otherwise run_app.py:144 tears the process down — so this is robustness, not a live bug. Still a one-word fix, and main didn't have the hole (the event was set in __init__ and only cleared as the last statement of start()).

  2. _startup_error is read before the wait and never re-read. L299 snapshots it, L301 releases, L305 blocks. A startup failure occurring while a caller is parked at L305 sets _startup_error at L375 and sets the event at L376 — so the parked waiter is woken by the very event signalling failure, returns True, and proceeds as if startup succeeded. Re-check under _lock after L305 returns. Needs a stop/start cycle to hit, so low severity, but it defeats the contract this PR adds.

  3. L294 skips the new checks on the lock-timeout branch. return False there bypasses L297-303 entirely, so a contended never-started service surfaces TimeoutError from wait_for_installs instead of the intended RuntimeError("...is not running"). Practically unreachable today, but it makes the new contract conditional on lock contention.

  4. L297's guard is and, so it only ever fires for a never-started service. stop() (L379-389) sets _running = False but never clears _restore_completed_event, so after a normal stop the conjunction is False and imports sail straight through into a stopped service. Pre-existing behavior, not a regression — but the error message claims a check that isn't performed. Relatedly, stop() never clears _stop_event and never joins the restore thread, so on a stop→start cycle the old restore thread's finally at L286 can open the new barrier. Not reachable in the app (nothing restarts the service), but it's a sharp edge under the new "the event means restoration finished" semantics.

  5. test_import_waits_for_startup_restore has no @pytest.mark.timeout, unlike its two new siblings, and its teardown installer.stop()_install_thread.join() is unbounded.

What I attacked and couldn't break

For the record, so this isn't re-litigated:

  • No lock-order inversion, and none introduced. I enumerated every installer _lock holder and every download-queue method reachable from it. The callbacks that run under _lock reach only DownloadQueueService.cancel_job / pause_job, neither of which takes the queue lock. Every _enqueue_remote_download call site runs with _lock not held. Single-direction graph.
  • _wait_for_restore_complete releases _lock at L301 before waiting at L305. That one line is what makes this design deadlock-safe against the restore thread's L215 acquisition — worth a comment so nobody "simplifies" it into a with block.
  • No self-deadlock from start() holding the non-reentrant _lock across its whole body. Traced all six callees; _next_id() (L1152, which takes _lock) is unreachable from any of them.
  • Every production caller of import_model/heuristic_import is provably post-start(), so nothing gets a spurious "not running": Invoker.__init__ starts model_manager before session_processor (fixed insertion order in invocation_services.py), sync_configured_external_starter_models runs after Invoker.__init__ returns, and all install routes are async def served after lifespan startup.
  • wait_for_installs timeout accounting is correctstart = time.time() at L548 is taken before the barrier and counted once; max(0.0, ...) at L294/L304 rules out negative timeouts (which would mean "block forever"). There are no production callers of wait_for_installs anyway.
  • All three new tests genuinely fail against pre-change main — I ran them at ae5694d: 3 failed. test_import_waits_for_startup_restore still fails with the white-box first assertion deleted (DID NOT RAISE TimeoutError), so it's behavioral, not just a flag check. The monkeypatched _observed_wait_for_restore closes over the real bound method, so it doesn't dodge the code under test.
  • download_and_cache_model, resume_job, restart_* don't bypass the barrier meaningfully — the first writes to a different tree and is already per-source locked; resume_job returns unless job.paused, and restore continues past paused jobs without resuming them.
  • tests/app/services/model_install/ passes 35/35 locally, CI green.

Summary

Land this design. Add the import_model reservation so #9141 is actually closed, and I'll close #9142 in favor of it. Items 1-3 above are one-liners; 4 and 5 are your call.

@JPPhoto

JPPhoto commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review. I confirmed the blocking import-vs-import race.

I added a deterministic regression test that holds the first import after its duplicate check while starting a second import for the same source. Before the fix, it registered two jobs; after the fix, both callers receive the same job.

I implemented the narrow reservation portion you suggested:

  • Sources are reserved under a Condition using the installer lock.
  • Import helpers and download-queue calls remain unlocked.
  • Concurrent callers wait for the owner and receive its registered job, including if it has already reached a terminal state.
  • A BaseException from the owner releases the reservation and wakes waiters.

Additional changes:

  • Added a regression test proving failed imports release their reservation.
  • Changed synchronous startup cleanup to catch BaseException.
  • Added a test proving startup interruption opens the barrier and causes later imports to receive the startup failure.
  • Added the missing timeout marker to test_import_waits_for_startup_restore.

I agree the PR description should explicitly state that #9239 introduced the original barrier. This PR closes the remaining early-pass paths and now adds same-source import serialization; the earlier raw line-count comparison with #9142 omitted that context.

On the other non-blocking points:

  • I did not change the pre-wait _startup_error read. start() holds _lock throughout synchronous startup, so a waiter cannot take that snapshot until startup either succeeds or records its failure. The described race requires the unsupported stop/start lifecycle.
  • I left the lock-timeout behavior unchanged. If the caller’s timeout expires before _lock can be acquired, reporting TimeoutError is consistent with the requested wait budget; safely determining service state would itself require acquiring that lock.
  • Stop/restart semantics remain out of scope and are pre-existing. They should be handled separately if service restart becomes supported.

Validation:

  • Regression test failed before the reservation with 2 jobs for one source and now passes.

@JPPhoto
JPPhoto requested a review from lstein August 1, 2026 22:19

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 33812c958a. The blocking finding is fixed — approving. Thanks for taking the reservation approach; the result is tighter than what I had in #9142, and I'll close that one in favour of this.

I pushed two small hardenings to the branch as 99c69bee1a (see below) — revert them if you disagree, the approval stands either way.

Verification

I confirmed the fix two ways, neither of them your tests.

1. My round-1 probe (2 threads, seeded reusable tmpdir, time.sleep(1.0) inside _import_from_url) now passes.

2. A randomized stress probe — 8 threads across 2 sources, 0-0.6 s jitter, a DOWNLOADING marker seeded per source so _find_reusable_tmpdir() hands out a shared directory. It asserts the actual #9141 symptom rather than a job count: no two jobs share an _install_tmpdir, and no job ends up errored.

branch result
33812c958a 8/8 pass
842b01d6f4 (previous head) 5/5 failFileNotFoundError: .../tmpinstall_reuse0/test_embedding.safetensors.downloading -> .../test_embedding.safetensors

That's #9141 verbatim on the old head and gone on this one.

Your new tests also genuinely fail against 842b01d6f4: test_concurrent_imports_of_same_source_return_one_jobassert 2 == 1, test_base_exception_during_startup_releases_import_waiters → event unset. (test_failed_import_releases_source_reservation passes on the old head too — it's a guard for the new mechanism rather than a regression test, which is fine, just noting it isn't load-bearing.) CI green, 17/17.

I also accept both of your rebuttals:

  • Pre-wait _startup_error read — you're right and I was wrong to raise it. start() holds _lock across 355-381, and _wait_for_restore_complete acquires _lock at 297 before snapshotting at 303, so no caller can take the snapshot mid-startup. There's no interleaving that reads None and is then woken by a sync-path failure.
  • Lock-timeout branch — agreed, determining the state requires the lock; import_model always takes the blocking-acquire path anyway.

What I pushed

Both are one-liners in the invariant this PR now owns, each with a regression test that fails against 33812c958a.

(a) Check similar_jobs before new_jobs. A waiter can be released into a state where the source has both a job that was registered while it waited and has since gone terminal, and a live one — and new_jobs[0] returns the dead one. Reproduced:

T1 reserves S, parked in its helper.       T2 enters, known_job_ids = {}, parks at :509.
T1 appends job 88001, notify_all.          job 88001 is/goes ERROR.
T3 enters: similar_jobs empty (88001 terminal) -> reserves S, appends live job 88002, notify_all.
T2 finally wins the lock: new_jobs = [88001, 88002] -> returns new_jobs[0].

T2 got job 88001 (status=InstallStatus.ERROR) instead of the live job 88002

To be precise about the scope: this only bites when a live job co-exists. It does not change the case you documented in the comment — where the owner's job is the only new one and is already terminal, the waiter still receives it under either ordering, which I agree is the right call. Worth knowing that case is more reachable than it looks: submit_multifile_download runs at :1342 but the append is at :542, so a fast download failure (DNS, connection refused) can run _download_error_callback_set_error in between, and _put_in_queuecancel_job (:420-421) does the same after stop().

(b) prune_jobs() filters and reassigns under the condition. It was an unsynchronized read-modify-write on the attribute the dedup now treats as authoritative, while import_model appends under _install_condition at :542:

prune :669  unfinished_jobs = [...]           # snapshot, job not yet appended
import :542 self._install_jobs.append(job)    # appends to the OLD list object
prune :670  self._install_jobs = unfinished_jobs

The consequence is exactly the bug this PR closes: the source ends up in neither _install_jobs nor _pending_sources, its marker still says WAITING, so the next import passes both checks and _find_reusable_tmpdir() hands it the same tmpdir. Reachable — DELETE /api/v2/models/install is async def on the event loop, while heuristic_import runs on session-processor threads (flux_redux.py:156, ip_adapter.py:220).

Two things worth stating plainly, because I got the first one wrong on my first attempt:

  • It still rebinds, deliberately. My first cut mutated in place (self._install_jobs[:] = [...]), which is worse: a CPython list iterator is index-based, so shrinking the list under an unlocked reader (get_job_by_id, get_job_by_source, list_jobs) silently skips the tail. I reproduced ValueError: No job with id 66003 known for a live job that way. The rebind keeps existing iterators on a stable list object, which is the property the original code had for free.
  • It costs something: prune_jobs now waits on _lock, and _download_error_callback (:1487) and _download_cancelled_callback (:1508) hold that lock across _safe_rmtree, which does gc.collect() + rmtree() and up to 1.5 s of sleeps on win32. So cancelling a partly-downloaded multi-GB model and then hitting "clear finished" can now stall the event loop for the duration of that rmtree. It's the same lock install_model already blocks the loop on at :297, so this widens an existing exposure rather than creating a new one — but if you'd rather not take that trade for a window this narrow, say so and I'll drop (b).

I also could not trigger the lost update by blocking inside the comprehension, because a list comprehension picks up concurrent appends — the real window is only the gap between the two statements. The test asserts the invariant directly instead: while a prune is mid-filter, _lock must be held and a concurrent import_model must not get a job registered.

40 passed locally (38 + 2), 3× under random ordering, ruff clean.

Observations, no action needed on this PR

  1. The reservation wait is unbounded and can wedge a multi-GPU worker. _install_condition.wait() (:509) has no timeout, and the reservation is held across _remote_files_from_sourceHuggingFaceMetadataFetchgrep -rn timeout invokeai/backend/model_manager/metadata/fetch/ returns nothing, so a stalled connection blocks forever. Two session_processor_N workers hitting flux_redux.py:156 for the same SigLIP source is the designed-for case: worker 0 wins the reservation and hangs, worker 1 parks at :509. A worker's cancel_event can't interrupt a Condition.wait(), so that GPU's queue is stuck until restart, and stop() (:383-393) touches neither _pending_sources nor notify_all(). Workers are daemon=True, so this doesn't hang process exit — it's a runtime wedge, not a shutdown one. The underlying no-timeout HTTP is the real bug and predates you; a bounded wait() plus a notify_all() in stop() would contain it.

  2. A single bad marker aborts restoration of every other one, and the barrier still reports success. In _restore_incomplete_installs, the per-marker try ends at :249, but ModelRecordChanges(**...) (:251), InstallStatus(status) (:262) and _put_in_queue (:270) are outside it. Anything raised there escapes the for loop; _run catches at :287, logs, and the finally sets the event without setting _startup_error. So _wait_for_restore_complete() returns True and imports proceed believing restoration completed, when nothing was restored. Widening the try to cover :251-270 with a continue would make the barrier's guarantee actually hold. (Trigger is cross-version markers — _read_install_marker only rejects a version mismatch, so a marker from a build with a new InstallStatus member is accepted at :180 and explodes at :262 on rollback.)

  3. The and at :301 still doesn't fire in the case that actually happens. You're right that stop/restart is out of scope, but plain shutdown isn't: Invoker.stop() iterates vars(self.services) in attribute order, and model_manager is assigned at invocation_services.py:109 versus session_processor at :117 — so install.stop() runs before the session workers are stopped. A worker still inside heuristic_import then finds _running False but the event set, sails past the guard, and gets a silently-cancelled job (local) or a download submitted to an already-stopping queue. Not a regression — main has no _running check at all — but the check you added misses its most likely real caller. Clearing _restore_completed_event in stop() would make the guard mean what its message says.

  4. test_concurrent_imports_of_same_source_return_one_job has a real flake path. second_helper_entered can never be set — T2 blocks at :509, which is before the monkeypatched _import_from_url — so second_helper_entered.wait(timeout=1) is a fixed 1 s sleep whose result is discarded. If T2 is descheduled past that second on a loaded runner, release_first_import fires before T2 reaches :506; T1's job then completes (the mock session serves instantly), T2's known_job_ids already contains it, similar_jobs is empty because it's terminal, and T2 creates a second job → assert len(jobs) == 1 fails. Waiting on observable state instead would make it deterministic, and assert not second_helper_entered.wait(...) would state the intent.

  5. QA instructions still say "Expected result: 35 tests pass" — the directory has 40 now.

What I attacked and couldn't break

  • Lock order. Helpers run with _lock released (:522-534), so the established queue-lock → installer-lock direction is preserved; the only installer→queue edges (cancel_job at :1457/:1480) call DownloadQueueService.cancel_job, which takes no queue lock. No cycle.
  • Re-entrancy on the non-reentrant _lock. _next_id() (:1178) is reachable only from the helpers, which run outside the condition. _put_in_queue (:419) takes no lock, so _download_complete_callback_put_in_queue under _lock is safe, and _install_queue is unbounded so put() can't block.
  • Reservation leaks / double-remove / lost wakeups. Both exits (:535-539, :541-544) remove and notify_all under the lock; the check-and-add at :508-520 never releases the lock, so reservation is exclusive and remove can't KeyError.
  • Three-thread barging. :508 is a while, so a waiter re-tests after every wakeup — the only fallout is (a) above.
  • Key/predicate drift. source_key = str(source) and job.source == source both reduce to str() via StringLikeSource.__eq__, and __str__ omits access_token, so _import_from_hf's in-place token mutation at :1205-1206 is invisible to dedup.
  • Cross-source tmpdir sharing. _find_reusable_tmpdir filters on marker["source"] != source_str (:203), so a per-source reservation is sufficient.
  • except BaseException in start(). It does close the hole: _restore_incomplete_installs_async() is the last statement in the try, nothing runs between the try and the with exit, and a Thread.start() failure propagates into the handler, which sets the event. _run's finally (:289-290) also runs for BaseException.
  • Barrier deadlock. _lock is released in the finally at :305 before Event.wait() at :309, so no waiter holds it while blocked; the restore thread's with self._lock at :219 and _next_id() at :253 don't overlap.
  • wait_for_installs timeout accounting. start = time.time() at :573 precedes the barrier wait, both max(0.0, ...) clamps hold, no double-counting. (_install_queue.join() at :583 is still unbounded, but that's pre-existing and outside this diff.)
  • download_and_cache_model bypassing the barrier. It does bypass it, but it never writes _install_jobs/_download_cache and writes only under models/.download_cache, which can't match models_path.glob("tmpinstall_*"). Benign.

JPPhoto and others added 4 commits August 1, 2026 21:50
Two hardenings to the import reservation.

import_model() checked new_jobs before similar_jobs, so a waiter released
into a state where the source has both a job registered while it waited
that has since gone terminal and a live one would return the dead job,
reporting a failure for a source that is actively installing. Check for a
live job first; the documented case where the owner's job is the only new
one and is already terminal is unchanged.

prune_jobs() filtered and reassigned _install_jobs with no lock, so an
import_model() registration landing between the two was dropped - leaving
a live install invisible to the duplicate check, its marker still WAITING,
and the next import of that source reusing its tmpdir. Do both under the
install condition. Rebind rather than mutating in place so that readers
already iterating the old list are not silently truncated.

Both tests fail against the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JPPhoto
JPPhoto force-pushed the model-install-restore-barrier branch from 99c69be to a7e2600 Compare August 2, 2026 02:50
@JPPhoto

JPPhoto commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review, independent stress testing, and the two hardenings. I agree with both changes in 99c69bee1a and will keep them assuming my testing (ongoing) doesn't turn up scope creep or new issues.

Preferring an active similar_job before considering newly registered terminal jobs preserves the intended waiter behavior. Synchronizing prune_jobs() also addresses a lost-update path; the possible event-loop delay is an acceptable tradeoff for maintaining the authoritative job-list invariant.

I also agree that the concurrent-import test has a scheduling-dependent path. I've replaced the discarded one-second wait with observable synchronization.

The unbounded reservation wait, malformed-marker isolation, and shutdown behavior are useful findings, but are broader pre-existing lifecycle concerns. I would handle those separately rather than expand this fix further.

I added some tests to verify everything (the current test count is now 43):

  • invokeai/app/services/model_install/model_install_default.py:668: New _install_condition acquisition can block the async DELETE /api/v2/models/install handler while callbacks hold _lock across _safe_rmtree(). This can stall the event loop during slow cleanup, especially Windows retry sleeps. Test: test_prune_jobs_waits_for_the_installer_lock proves pruning cannot complete until another lock holder releases it.

  • invokeai/app/services/model_install/model_install_default.py:514: Live-job-first ordering introduced no functional regression found. It selects the live job when terminal and live jobs coexist, while still returning the concurrent owner's terminal job when no live job exists. Test: existing test_waiting_import_prefers_a_live_job_over_a_terminal_one and new test_waiting_import_returns_its_new_terminal_job_when_no_live_job_exists pass.

  • invokeai/app/services/model_install/model_install_default.py:674: Rebinding under the condition prevents concurrent import registration loss and preserves existing iterators. Test: existing test_prune_jobs_cannot_drop_a_concurrent_registration and new test_prune_jobs_rebind_preserves_existing_iterators pass.

  • invokeai/app/services/model_install/model_install_default.py:263: Restore still appends outside _install_condition, so concurrent pruning can discard a restored job. This race predates the latest changes; the prune hardening does not introduce it, but does not cover it. Test: new test_prune_jobs_cannot_drop_a_concurrent_restore_registration deterministically fails with zero jobs after pruning. I'm attaching the test to this message so this can be applied in a future PR that resolves that issue.

@lstein
lstein merged commit a19a5a6 into invoke-ai:main Aug 2, 2026
17 checks passed
@JPPhoto
JPPhoto deleted the model-install-restore-barrier branch August 2, 2026 16:31
lstein added a commit that referenced this pull request Aug 2, 2026
…9448)

The four timeout=5 marks added in #9433 use method="thread", so the
5-second budget covers fixture setup and teardown as well as the test
body. Tearing down the mm2_download_queue fixture alone takes up to
~1s (five worker threads polling the queue at a 1-second interval),
and on a heavily loaded CI runner the total easily exceeds 5s: the
py3.11 windows-cpu job on #9447 timed out in
test_base_exception_during_startup_releases_import_waiters during
download-queue teardown, and an unrelated branch hit the same timeout
in test_import_fails_after_startup_failure on linux-cpu the same day.
Neither dump showed a deadlock - the workers were in their normal poll
loop.

Bump these marks to 30s, matching the other timeout marks in this
file. The timeouts exist to catch hangs, not to enforce speed, so the
larger budget loses nothing.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.0 python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

Race condition in ModelInstallService._restore_incomplete_installs causes duplicate download enqueue

2 participants