perf: preload the app and fork production granian workers
Summary
- On Linux,
run_granian_backend_prodnow forces theforkstart method, imports the app in the supervisor, and runsgc.collect(); gc.freeze()before granian starts workers. Workers share the framework and app pages copy-on-write instead of each re-importing them. REFLEX_BACKEND_START_METHOD(fork/spawn/forkserver) overrides the choice for apps that are not fork-safe. Unset keeps the interpreter default on macOS and Windows (spawn), so nothing changes there.- Granian no longer forks its workers while a telemetry thread exists.
reflex run --env prodsends itsrun-prodevent synchronously on the calling thread (telemetry._send) before the server starts, so it never touches the thread pool. Right before forking, the supervisor drains whatever is still queued (the compile event, queued before the frontend build) and joins the pool's thread (telemetry._shutdown_executor()). A send that stalls past the 2 s drain, e.g. in a DNS lookup, leaves a live thread, and the supervisor then starts its workers withspawninstead of forking. Nothing in the supervisor sends telemetry after that point, so a SIGHUP respawn forks from a thread-free process too. - As the dev fork path does since #7049, the supervisor runs
_prepare_serializers_for_fork()before forking. - A forked child inherits the telemetry
ThreadPoolExecutorbut not its worker thread, so any event submitted from a worker would queue forever. Anos.register_at_forkhook drops the inherited pool in the child and recreates its creation lock, which a thread creating the pool at fork time would otherwise leave held. This also affected dev mode on Python <= 3.13, whereforkwas already the Linux default.
Measurement
One-page app (a counter rx.State), GRANIAN_WORKERS=4, reflex run --env prod --backend-only, telemetry off, Python 3.14, granian 2.8.1. PSS summed over the whole process tree from /proc/<pid>/smaps_rollup, after 32 /ping requests, median of 3 runs, measured against current main (which includes #7049).
| PSS | private dirty | first /ping OK after | |
|---|---|---|---|
| main | 335 MB | 302 MB | 1.01 s |
| this PR | 143 MB | 65 MB | 0.66 s |
Per worker: 66 MB PSS → 27 MB PSS. The supervisor goes from 48 MB to 34 MB PSS, and the tree drops the multiprocessing forkserver (16 MB) and resource tracker (7 MB) that main starts under Python 3.14's default forkserver method.
Functional check: 8 socket.io sessions against the forked workers, each hydrating and sending two increment events, all receiving count 1 then 2. A SIGHUP reload respawned all four workers and /ping answered 32/32 before and after it.
Notes for review
- The synchronous
run-prodsend is bounded byurlopen's 5 s timeout, the same as every other CLI telemetry event before the thread pool existed. - Granian starts a watcher thread per worker, so workers 2..N are forked while those threads exist. They wait in
waitpidand hold no locks. - The uvicorn/gunicorn prod path runs its server in a subprocess, so nothing there forks a reflex process.