Serialize production frontend work sharing one .web directory
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) becomespath_ops.file_lock: one implementation of the platform-specific locking (flockon POSIX,msvcrt.lockingon 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_lockwas never released, so there is no deprecation shim. #7051 makes the same rename and locksget_web_dir()through it, so the two converge.- The lock is the existing sidecar in
REFLEX_DIRkeyed by the resolved.webpath, not a file inside.web. Locking never creates.web, and the lock inode survives.webbeing replaced byreflex init. As withjson_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.webexists" 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_previewhold 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:
| main | this PR | |
|---|---|---|
| total export | 6.90 s | 6.84 s |
| compile phase | 0.46 s | 0.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.pyandtest_workflow_gates.pypass locally -
ruff check,ruff format --check,codespell, andpyright reflex testspass locally - News fragment added for every package touched
https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh