Skip to content

Commit b7d35ca

Browse files
committed
Serialize production frontend work sharing one .web directory
Two exports (or an export and a prod/preview start) in the same project used to race on .web: the second replaced build/ and env.json while the first was still compiling or zipping, so one of them shipped the other's frontend. Hold an exclusive, reentrant file lock on .web/.reflex-build.lock from compilation through ZIP creation, release it before serving, and never create .web for a backend-only export that has nothing to protect. Split out of #7054 (lock only; the opt-in build cache that needs it follows separately). Claude-Session: https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
1 parent 62a56ba commit b7d35ca

8 files changed

Lines changed: 663 additions & 59 deletions

File tree

‎docs/hosting/self-hosting.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ this, use the `--no-zip` parameter. This provides the frontend in the
136136
`.web/build/client/` directory and the backend can be found in the root directory of
137137
the project.
138138

139+
Production exports that share a `.web` directory wait for one another from
140+
compilation through ZIP creation, on macOS, Linux, and Windows. Backend-only
141+
exports also wait while archiving an existing `.web/backend` directory, but do
142+
not create `.web` when it is absent. Production and preview startup hold the
143+
same lock while compiling and building and release it before serving. The lock
144+
file `.web/.reflex-build.lock` stays in place between commands; do not remove it
145+
while a command is running. Initialization, development hot reload, and other
146+
tools writing to `.web` are outside this lock, so avoid running them during an
147+
export.
148+
139149
The export also writes a pre-compressed `.gz` copy of the compressible text
140150
assets (JS, CSS, HTML, JSON, SVG, and similar), so configure the static host
141151
to serve those directly where it supports it. Set the
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Production exports, `reflex run --env prod`, and preview startup that share a `.web` directory now wait for one another instead of overwriting each other's compiled frontend and archives. The lock file `.web/.reflex-build.lock` stays in place between commands.

‎reflex/reflex.py‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import signal
77
import sys
88
from collections.abc import Callable
9-
from contextlib import contextmanager
9+
from contextlib import contextmanager, nullcontext
1010
from importlib import import_module
1111
from importlib.util import find_spec
1212
from pathlib import Path
@@ -508,21 +508,33 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str):
508508
"""
509509
import atexit
510510

511-
from reflex.utils import build, exec, processes, telemetry
511+
from reflex.utils import (
512+
build,
513+
build_lock,
514+
exec,
515+
prerequisites,
516+
processes,
517+
telemetry,
518+
)
512519

513520
config = get_config()
514521

515-
config._set_persistent(frontend_port=port, backend_port=port)
522+
with (
523+
build_lock.frontend_build_lock(prerequisites.get_web_dir())
524+
if running_mode.has_frontend()
525+
else nullcontext()
526+
):
527+
config._set_persistent(frontend_port=port, backend_port=port)
516528

517-
# Mount the compiled frontend into the dev backend so no Vite server is needed.
518-
environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(
519-
running_mode.has_frontend() and running_mode.has_backend()
520-
)
529+
# Mount the compiled frontend into the dev backend so no Vite server is needed.
530+
environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(
531+
running_mode.has_frontend() and running_mode.has_backend()
532+
)
521533

522-
if running_mode.has_frontend():
523-
# Compile the app and produce the initial frontend build.
524-
_compile_app()
525-
build.setup_frontend_prod(Path.cwd())
534+
if running_mode.has_frontend():
535+
# Compile the app and produce the initial frontend build.
536+
_compile_app()
537+
build.setup_frontend_prod(Path.cwd())
526538

527539
# Post a telemetry event.
528540
telemetry.send("run-preview")
@@ -547,16 +559,28 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str):
547559
def _run_prod(running_mode: constants.RunningMode, port: int, host: str):
548560
import atexit
549561

550-
from reflex.utils import build, exec, processes, telemetry
562+
from reflex.utils import (
563+
build,
564+
build_lock,
565+
exec,
566+
prerequisites,
567+
processes,
568+
telemetry,
569+
)
551570

552571
config = get_config()
553572

554-
config._set_persistent(frontend_port=port, backend_port=port)
573+
with (
574+
build_lock.frontend_build_lock(prerequisites.get_web_dir())
575+
if running_mode.has_frontend()
576+
else nullcontext()
577+
):
578+
config._set_persistent(frontend_port=port, backend_port=port)
555579

556-
if running_mode.has_frontend():
557-
# Get the app module.
558-
_compile_app(avoid_dirty_check=False)
559-
build.setup_frontend_prod(Path.cwd())
580+
if running_mode.has_frontend():
581+
# Get the app module.
582+
_compile_app(avoid_dirty_check=False)
583+
build.setup_frontend_prod(Path.cwd())
560584

561585
_skip_compile()
562586

‎reflex/utils/build_lock.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Serialize production frontend work that shares one ``.web`` directory."""
2+
3+
from __future__ import annotations
4+
5+
import errno
6+
import logging
7+
import os
8+
import stat
9+
import sys
10+
import threading
11+
import time
12+
from collections.abc import Iterator
13+
from contextlib import contextmanager
14+
from pathlib import Path
15+
16+
from reflex_base import constants
17+
18+
logger = logging.getLogger(__name__)
19+
20+
LOCK_FILE = ".reflex-build.lock"
21+
_lock_state = threading.local()
22+
_lock_descriptors: set[int] = set()
23+
24+
25+
def _reset_build_locks_after_fork() -> None:
26+
"""Close inherited descriptors without unlocking the parent's file descriptions."""
27+
for descriptor in _lock_descriptors:
28+
os.close(descriptor)
29+
_lock_descriptors.clear()
30+
_lock_state.paths = set()
31+
32+
33+
if not constants.IS_WINDOWS:
34+
os.register_at_fork(after_in_child=_reset_build_locks_after_fork)
35+
36+
37+
def _lock_descriptor(descriptor: int, *, unlock: bool = False) -> None:
38+
"""Acquire or release the platform's exclusive file lock.
39+
40+
Args:
41+
descriptor: Open lock-file descriptor, positioned at byte zero.
42+
unlock: Whether to release a previously acquired lock.
43+
44+
Raises:
45+
OSError: The lock operation fails for a reason other than contention.
46+
"""
47+
if sys.platform == "win32":
48+
import msvcrt
49+
50+
if unlock:
51+
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
52+
return
53+
while True:
54+
try:
55+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
56+
except OSError as error: # noqa: PERF203 - retry a contended OS lock
57+
if error.errno != errno.EACCES:
58+
raise
59+
# Poll every 100 ms; builds can exceed LK_LOCK's ten-second limit.
60+
time.sleep(0.1)
61+
else:
62+
return
63+
else:
64+
import fcntl
65+
66+
fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX)
67+
68+
69+
@contextmanager
70+
def frontend_build_lock(web_dir: Path) -> Iterator[None]:
71+
"""Serialize production workspace access, including builds with caching disabled.
72+
73+
Nested calls from the same thread reuse its lock. Other threads and processes
74+
open independent descriptors and wait on the same persistent lock file.
75+
76+
Args:
77+
web_dir: Shared frontend working directory.
78+
79+
Yields:
80+
Control while this thread owns the production workspace.
81+
82+
Raises:
83+
OSError: A regular lock file cannot be opened or locked safely.
84+
"""
85+
owner_pid = os.getpid()
86+
web_dir = web_dir.resolve()
87+
held: set[Path] = getattr(_lock_state, "paths", set())
88+
if web_dir in held:
89+
yield
90+
return
91+
web_dir.mkdir(parents=True, exist_ok=True)
92+
lock_path = web_dir / LOCK_FILE
93+
descriptor = os.open(
94+
lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600
95+
)
96+
_lock_descriptors.add(descriptor)
97+
try:
98+
info = os.fstat(descriptor)
99+
path_info = lock_path.lstat()
100+
if (
101+
not stat.S_ISREG(info.st_mode)
102+
or not stat.S_ISREG(path_info.st_mode)
103+
or not os.path.samestat(info, path_info)
104+
):
105+
msg = "Production build lock must be a regular file"
106+
raise OSError(msg)
107+
_lock_descriptor(descriptor)
108+
held.add(web_dir)
109+
_lock_state.paths = held
110+
try:
111+
yield
112+
finally:
113+
if os.getpid() == owner_pid:
114+
held.remove(web_dir)
115+
_lock_descriptor(descriptor, unlock=True)
116+
finally:
117+
if os.getpid() == owner_pid:
118+
_lock_descriptors.discard(descriptor)
119+
os.close(descriptor)

‎reflex/utils/export.py‎

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
import logging
44
import time
55
from collections.abc import Iterator
6-
from contextlib import contextmanager
6+
from contextlib import contextmanager, nullcontext
77
from pathlib import Path
88

99
from reflex_base import constants
1010
from reflex_base.config import get_config
1111
from reflex_base.environment import environment
1212
from reflex_base.utils import console
1313

14-
from reflex.utils import build, exec, prerequisites, telemetry
14+
from reflex.utils import build, build_lock, exec, prerequisites, telemetry
1515

1616
logger = logging.getLogger(__name__)
1717

@@ -49,23 +49,6 @@ def export(
4949
# Set the log level.
5050
console.set_log_level(loglevel)
5151

52-
# Set env mode in the environment
53-
environment.REFLEX_ENV_MODE.set(env)
54-
55-
# Override the config url values if provided.
56-
if api_url is not None:
57-
config._set_persistent(api_url=str(api_url))
58-
logger.debug(f"overriding API URL: {config.api_url}")
59-
if deploy_url is not None:
60-
config._set_persistent(deploy_url=str(deploy_url))
61-
logger.debug(f"overriding deploy URL: {config.deploy_url}")
62-
63-
# Show system info
64-
exec.output_system_info()
65-
66-
# Compile the app in production mode and export it.
67-
console.rule("[bold]Compiling production app and preparing for export.")
68-
6952
start = time.monotonic()
7053
phase_durations: dict[str, float] = {}
7154
status = "success"
@@ -80,26 +63,46 @@ def _time_phase(name: str) -> Iterator[None]:
8063
phase_durations[name] = time.monotonic() - t0
8164

8265
try:
83-
if frontend:
84-
with _time_phase("compile_duration"):
85-
# Ensure module can be imported and app.compile() is called.
86-
prerequisites.get_compiled_app(
87-
prerender_routes=prerender_routes, trigger="export"
88-
)
89-
with _time_phase("setup_duration"):
90-
# Set up .web directory and install frontend dependencies.
91-
build.setup_frontend(Path.cwd())
92-
with _time_phase("build_duration"):
93-
build.build()
94-
if zipping:
95-
with _time_phase("zip_duration"):
96-
build.zip_app(
97-
frontend=frontend,
98-
backend=backend,
99-
zip_dest_dir=zip_dest_dir,
100-
include_db_file=upload_db_file,
101-
backend_excluded_dirs=backend_excluded_dirs,
102-
)
66+
web_dir = prerequisites.get_web_dir()
67+
with (
68+
build_lock.frontend_build_lock(web_dir)
69+
if frontend or (backend and zipping and web_dir.is_dir())
70+
else nullcontext()
71+
):
72+
# Set env mode in the environment.
73+
environment.REFLEX_ENV_MODE.set(env)
74+
75+
# Override the config url values if provided.
76+
if api_url is not None:
77+
config._set_persistent(api_url=str(api_url))
78+
logger.debug(f"overriding API URL: {config.api_url}")
79+
if deploy_url is not None:
80+
config._set_persistent(deploy_url=str(deploy_url))
81+
logger.debug(f"overriding deploy URL: {config.deploy_url}")
82+
83+
exec.output_system_info()
84+
console.rule("[bold]Compiling production app and preparing for export.")
85+
86+
if frontend:
87+
with _time_phase("compile_duration"):
88+
# Ensure module can be imported and app.compile() is called.
89+
prerequisites.get_compiled_app(
90+
prerender_routes=prerender_routes, trigger="export"
91+
)
92+
with _time_phase("setup_duration"):
93+
# Set up .web directory and install frontend dependencies.
94+
build.setup_frontend(Path.cwd())
95+
with _time_phase("build_duration"):
96+
build.build()
97+
if zipping:
98+
with _time_phase("zip_duration"):
99+
build.zip_app(
100+
frontend=frontend,
101+
backend=backend,
102+
zip_dest_dir=zip_dest_dir,
103+
include_db_file=upload_db_file,
104+
backend_excluded_dirs=backend_excluded_dirs,
105+
)
103106
except Exception as exc:
104107
status = "failure"
105108
detail = type(exc).__name__

0 commit comments

Comments
 (0)