perf: fork the dev backend reload worker instead of using a forkserver

Merged
FA
10d ago
+239-16

Stacked on #7079 (its commit is included in this diff until it merges). Follow-up to #7112, which measured this change and dropped it so the fork helpers land once, in #7079.

Summary

  • reflex run now applies the same start-method rule as the prod supervisor: fork where the interpreter already defaults to fork or forkserver (Linux), spawn under REFLEX_STRICT_HOT_RELOAD, and REFLEX_BACKEND_START_METHOD overrides both. macOS and Windows keep spawn, so nothing changes there.
  • On Python 3.14 Linux the default is forkserver, so the supervisor kept a forkserver plus resource tracker alive and the single reload worker re-imported the whole framework privately. Now granian forks the worker from the supervisor and framework pages are shared copy-on-write.
  • The start method is fixed in _compile_app before the compile ProcessPoolExecutor starts. Otherwise that pool creates the forkserver pair first and it survives for the whole session.
  • The supervisor runs gc.collect(); gc.freeze() before granian forks (shared _freeze_for_fork, also used by the prod preload), but never imports the user app. Every reload still imports it fresh in the worker. Verified with a route that changed between reloads: the new value was served 0.5 s after the edit, and the tree stays at two Python processes.
  • #7049 preloads reflex.app in the supervisor only when the start method is already fork. With this PR that condition holds on 3.14, so the two compose.

Measurement

Blank app, reflex run, page loaded once in headless Chrome, PSS over the session tree from /proc/<pid>/smaps_rollup, 15 s settle, Python 3.14, granian 2.8.1. Before is the #7079 branch as-is; after adds this commit. Node numbers are unchanged by this PR (see #7112 for the vite side).

beforeafter
supervisor152 MB105 MB
forkserver + resource tracker6 + 19 MBgone
reload worker151 MB96 MB
python total330 MB201 MB
whole tree, cold (2 runs)1128 / 1099 MB988 / 995 MB
whole tree, warm (3 runs)737 / 745 / 742 MB615 / 609 / 606 MB
backend ready4.4 s3.0 s

Dev runs one worker, so this is a fixed ~130 MB and ~1.4 s, not a per-worker multiple. On Python 3.13 and older, Linux already forks, so the only change there is the freeze.

Test plan

  • uv run pytest tests/units (8353 passed, 76.15% coverage)
  • uv run pre-commit run --all-files
  • Manual: hot reload picks up an edit with the forked worker; REFLEX_STRICT_HOT_RELOAD=1 still spawns

https://claude.ai/code/session_015Gi5wTWZNBA61pVDdPLu8u