Skip to content

Eagerly import the ASGI app in the parent process#2919

Merged
Kludex merged 13 commits into
mainfrom
eager-load-app-in-parent
Apr 27, 2026
Merged

Eagerly import the ASGI app in the parent process#2919
Kludex merged 13 commits into
mainfrom
eager-load-app-in-parent

Conversation

@Kludex

@Kludex Kludex commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Splits Config.load() so the app import is reusable in isolation, and calls it eagerly from uvicorn.run() before the supervisor or Server is constructed.

  • New: Config.load_app() imports the app via import_from_string and returns it. No state mutation.
  • Config.load() calls self.loaded_app = self.load_app() for the existing back-compat behavior.
  • uvicorn.run() calls config.load_app() and discards the return value: enough to fail fast on a bad import string, without leaving anything on the parent's Config for spawn workers to pick up.

This:

  • Fails fast in the parent on a bad app path; no more infinite worker restart loop with --workers > 1.
  • Imports the app synchronously before any event loop starts, so apps that do app = asyncio.run(...) at module load time no longer crash with RuntimeError: this event loop is already running.
  • Primes sys.modules in the parent, so future thread workers (Add free-threaded thread worker class #2900) see a cache hit when each thread runs its own Config.load() instead of racing on the importlib global lock for the initial import.

Spawn workers are unaffected: uvicorn.run() doesn't store the imported app on Config, so the bound server.run method that Multiprocess ships across pickle never carries it. Workers re-import in Server.run -> Config.load.

Closes / supersedes

Test plan

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

rnv812 and others added 5 commits August 7, 2025 23:01
Splits Config.load() into a small load_app() (imports the app, sets
loaded_app) plus the rest, and calls config.load_app() in uvicorn.run()
before the supervisor or Server is constructed.

This:
- Fails fast in the parent on a bad app path; no more infinite worker
  restart loop with --workers > 1.
- Imports the app synchronously before any event loop starts, so apps
  that do `app = asyncio.run(...)` at module load time no longer crash
  with "loop already running".
- Primes sys.modules in the parent, so future thread workers (#2900)
  see a cache hit in load_app() instead of racing on the importlib
  global lock.

The spawn child must still re-import (fresh interpreter, possibly
non-picklable loaded_app) - _subprocess.get_subprocess() shallow-copies
the Config and clears loaded/loaded_app on the copy before pickling.
A TODO points at the deeper fix: splitting Config (settings) from the
runtime state Server actually needs.

Config.load() is now idempotent (early-return when self.loaded), so
defensive `if not config.loaded: config.load()` guards in Server,
LifespanOn, and the websockets sans-io protocol stay correct.

Closes #941
Closes #2440
Supersedes #942
Supersedes #2444
@github-actions

github-actions Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

@codspeed-hq

codspeed-hq Bot commented Apr 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks


Comparing eager-load-app-in-parent (55fbca9) with main (b224045)

Open in CodSpeed

Codex review caught a real bug: storing loaded_app on the parent's Config
breaks spawn pickling for non-picklable apps (DB pools, locks, etc.) because
Multiprocess passes server.run as a bound method, and that bound method
captures the same Config object. Stripping loaded_app from the kwargs Config
copy doesn't help - the bound method still drags the original.

Fix: in uvicorn.run(), import the app for fail-fast validation and discard
the result. Workers re-import freshly via Server.run -> config.load(). The
parent never carries loaded_app, so the bound-method pickle is safe.

Removes the _subprocess.py shallow-copy hack (no longer needed) and the
test that asserted on it.

The #941 regression test moved from test_config.py to test_main.py, where
it now verifies via Server.run patching that the import lands in
sys.modules before Server.run is called.
@Kludex Kludex merged commit b499bc4 into main Apr 27, 2026
24 checks passed
@Kludex Kludex deleted the eager-load-app-in-parent branch April 27, 2026 21:56
harupy added a commit to mlflow/mlflow that referenced this pull request May 25, 2026
Uvicorn 0.47.0 eagerly imports the ASGI app in the parent process
before forking workers (Kludex/uvicorn#2919). This appears to delay
worker readiness on CI runners, causing the 10s `/health` poll in
tests/webhooks/test_e2e.py and tests/tracking/test_rest_tracking.py
to time out. Pin to verify.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: harupy <17039389+harupy@users.noreply.github.com>
harupy added a commit to harupy/mlflow that referenced this pull request May 26, 2026
uvicorn 0.47 adds a serialized parent-process import of the ASGI app to
the multi-worker startup path (Kludex/uvicorn#2919), pushing
`mlflow server` cold-start past the prior 10-attempt (~10-30s) budget on
CI. 30 attempts gives a 30-90s ceiling, which absorbs the new latency
while still failing fast on a genuinely broken server.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: harupy <17039389+harupy@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Eager Asynchronous Construction of App Interferes with Running Event Loop

2 participants