Serialize production frontend work sharing one .web directory

Merged
FA
2d ago
+537-109

Split out of #7054. This is the lock only; the opt-in build cache that motivated it is #7455 and is stacked on this.

Problem

Two production exports in the same project (or an export racing a reflex run --env prod start) share .web: the second one rewrites env.json, build/, and the compiled sources while the first is still compiling or zipping, so one of them ships the other's frontend. Review bots on #7054 flagged this for the cache, but it is a pre-existing race.

Changes

  • path_ops.json_file_lock (from #7052) becomes path_ops.file_lock: one implementation of the platform-specific locking (flock on POSIX, msvcrt.locking on Windows) for files and directories, instead of a second copy for builds. It gains what the build lock needs: nested calls from the same thread reuse the lock, other threads and processes wait, and a forked child drops inherited ownership and waits for its parent. On Windows the lock is released explicitly before the descriptor closes. json_file_lock was never released, so there is no deprecation shim. #7051 makes the same rename and locks get_web_dir() through it, so the two converge.
  • The lock is the existing sidecar in REFLEX_DIR keyed by the resolved .web path, not a file inside .web. Locking never creates .web, and the lock inode survives .web being replaced by reflex init. As with json_file_lock, an unwritable lock directory or an unsupported filesystem logs at debug and proceeds unlocked.
  • export.export() holds it from configuration through ZIP creation. Backend-only exports always take it too, since they archive .web/backend; the earlier "only when .web exists" condition existed to avoid creating .web, which the sidecar makes moot, so the race the review bots flagged on that line is closed.
  • _run_prod / _run_preview hold it through compile and build and release it before serving.
  • Tests: spawned-process serialization and same-thread reentrancy, a second thread waiting, fork ownership, Windows contention retry and release, and a real two-process export test where a backend ZIP waits for the compiler's temporary stateful-pages marker to be renamed.

Measurement

No speedup is claimed. Lock overhead on an unchanged bun export, repeat runs:

mainthis PR
total export6.90 s6.84 s
compile phase0.46 s0.44 s

This is pure complexity in service of correctness (and of #7455). If the cache is not wanted, the question for this one is only whether the race above is worth fixing.

Measurement setup

Five-page app from the blank template (Radix Themes + Tailwind v4 plugins), Linux x86_64, 4 vCPU, Python 3.14, Node 22.22, npm 10.9, bun 1.4.2. Each run is a fresh interpreter calling reflex.utils.export.export() the same way reflex export does; durations are the phase timings the export already reports to telemetry (compile includes the dependency install). Medians of 3 runs unless noted. Vite dominates every export (~6.5 s); these numbers isolate the part this PR touches.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • This change requires a documentation update

Checklist

  • Tests added for the change; tests/units/utils, test_reflex.py, test_prerequisites.py and test_workflow_gates.py pass locally
  • ruff check, ruff format --check, codespell, and pyright reflex tests pass locally
  • News fragment added for every package touched

https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh