Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/hosting/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ this, use the `--no-zip` parameter. This provides the frontend in the
`.web/build/client/` directory and the backend can be found in the root directory of
the project.

Production exports that share a `.web` directory wait for one another from
compilation through ZIP creation, on macOS, Linux, and Windows. Backend-only
exports also wait while archiving an existing `.web/backend` directory, but do
not create `.web` when it is absent. Production and preview startup hold the
same lock while compiling and building and release it before serving. The lock
file `.web/.reflex-build.lock` stays in place between commands; do not remove it
while a command is running. Initialization, development hot reload, and other
tools writing to `.web` are outside this lock, so avoid running them during an
export.

The export also writes a pre-compressed `.gz` copy of the compressible text
assets (JS, CSS, HTML, JSON, SVG, and similar), so configure the static host
to serve those directly where it supports it. Set the
Expand Down
1 change: 1 addition & 0 deletions news/+export-workspace-lock.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
58 changes: 41 additions & 17 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import signal
import sys
from collections.abc import Callable
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from importlib import import_module
from importlib.util import find_spec
from pathlib import Path
Expand Down Expand Up @@ -508,21 +508,33 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str):
"""
import atexit

from reflex.utils import build, exec, processes, telemetry
from reflex.utils import (
build,
build_lock,
exec,
prerequisites,
processes,
telemetry,
)

config = get_config()

config._set_persistent(frontend_port=port, backend_port=port)
with (
build_lock.frontend_build_lock(prerequisites.get_web_dir())
Comment thread
FarhanAliRaza marked this conversation as resolved.
if running_mode.has_frontend()
else nullcontext()
):
config._set_persistent(frontend_port=port, backend_port=port)

# Mount the compiled frontend into the dev backend so no Vite server is needed.
environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(
running_mode.has_frontend() and running_mode.has_backend()
)
# Mount the compiled frontend into the dev backend so no Vite server is needed.
environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(
running_mode.has_frontend() and running_mode.has_backend()
)

if running_mode.has_frontend():
# Compile the app and produce the initial frontend build.
_compile_app()
build.setup_frontend_prod(Path.cwd())
if running_mode.has_frontend():
# Compile the app and produce the initial frontend build.
_compile_app()
build.setup_frontend_prod(Path.cwd())

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

from reflex.utils import build, exec, processes, telemetry
from reflex.utils import (
build,
build_lock,
exec,
prerequisites,
processes,
telemetry,
)

config = get_config()

config._set_persistent(frontend_port=port, backend_port=port)
with (
build_lock.frontend_build_lock(prerequisites.get_web_dir())
if running_mode.has_frontend()
else nullcontext()
):
config._set_persistent(frontend_port=port, backend_port=port)

if running_mode.has_frontend():
# Get the app module.
_compile_app(avoid_dirty_check=False)
build.setup_frontend_prod(Path.cwd())
if running_mode.has_frontend():
# Get the app module.
_compile_app(avoid_dirty_check=False)
build.setup_frontend_prod(Path.cwd())

_skip_compile()

Expand Down
119 changes: 119 additions & 0 deletions reflex/utils/build_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Serialize production frontend work that shares one ``.web`` directory."""

from __future__ import annotations

import errno
import logging
import os
import stat
import sys
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path

from reflex_base import constants

logger = logging.getLogger(__name__)

LOCK_FILE = ".reflex-build.lock"
_lock_state = threading.local()
_lock_descriptors: set[int] = set()


def _reset_build_locks_after_fork() -> None:
"""Close inherited descriptors without unlocking the parent's file descriptions."""
for descriptor in _lock_descriptors:
os.close(descriptor)
_lock_descriptors.clear()
_lock_state.paths = set()


if not constants.IS_WINDOWS:
os.register_at_fork(after_in_child=_reset_build_locks_after_fork)


def _lock_descriptor(descriptor: int, *, unlock: bool = False) -> None:
"""Acquire or release the platform's exclusive file lock.

Args:
descriptor: Open lock-file descriptor, positioned at byte zero.
unlock: Whether to release a previously acquired lock.

Raises:
OSError: The lock operation fails for a reason other than contention.
"""
if sys.platform == "win32":
import msvcrt

if unlock:
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
return
while True:
try:
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
except OSError as error:
if error.errno != errno.EACCES:
raise
# Poll every 100 ms; builds can exceed LK_LOCK's ten-second limit.
time.sleep(0.1)
else:
return
else:
import fcntl

fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX)


@contextmanager
def frontend_build_lock(web_dir: Path) -> Iterator[None]:
"""Serialize production workspace access, including builds with caching disabled.

Nested calls from the same thread reuse its lock. Other threads and processes
open independent descriptors and wait on the same persistent lock file.

Args:
web_dir: Shared frontend working directory.

Yields:
Control while this thread owns the production workspace.

Raises:
OSError: A regular lock file cannot be opened or locked safely.
"""
owner_pid = os.getpid()
web_dir = web_dir.resolve()
held: set[Path] = getattr(_lock_state, "paths", set())
if web_dir in held:
yield
return
web_dir.mkdir(parents=True, exist_ok=True)
lock_path = web_dir / LOCK_FILE
descriptor = os.open(
lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600
)
_lock_descriptors.add(descriptor)
try:
info = os.fstat(descriptor)
path_info = lock_path.lstat()
if (
not stat.S_ISREG(info.st_mode)
or not stat.S_ISREG(path_info.st_mode)
or not os.path.samestat(info, path_info)
):
msg = "Production build lock must be a regular file"
raise OSError(msg)
_lock_descriptor(descriptor)
held.add(web_dir)
_lock_state.paths = held
try:
yield
finally:
if os.getpid() == owner_pid:
held.remove(web_dir)
_lock_descriptor(descriptor, unlock=True)
finally:
if os.getpid() == owner_pid:
_lock_descriptors.discard(descriptor)
os.close(descriptor)
81 changes: 42 additions & 39 deletions reflex/utils/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from pathlib import Path

from reflex_base import constants
from reflex_base.config import get_config
from reflex_base.environment import environment
from reflex_base.utils import console

from reflex.utils import build, exec, prerequisites, telemetry
from reflex.utils import build, build_lock, exec, prerequisites, telemetry

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -49,23 +49,6 @@ def export(
# Set the log level.
console.set_log_level(loglevel)

# Set env mode in the environment
environment.REFLEX_ENV_MODE.set(env)

# Override the config url values if provided.
if api_url is not None:
config._set_persistent(api_url=str(api_url))
logger.debug(f"overriding API URL: {config.api_url}")
if deploy_url is not None:
config._set_persistent(deploy_url=str(deploy_url))
logger.debug(f"overriding deploy URL: {config.deploy_url}")

# Show system info
exec.output_system_info()

# Compile the app in production mode and export it.
console.rule("[bold]Compiling production app and preparing for export.")

start = time.monotonic()
phase_durations: dict[str, float] = {}
status = "success"
Expand All @@ -80,26 +63,46 @@ def _time_phase(name: str) -> Iterator[None]:
phase_durations[name] = time.monotonic() - t0

try:
if frontend:
with _time_phase("compile_duration"):
# Ensure module can be imported and app.compile() is called.
prerequisites.get_compiled_app(
prerender_routes=prerender_routes, trigger="export"
)
with _time_phase("setup_duration"):
# Set up .web directory and install frontend dependencies.
build.setup_frontend(Path.cwd())
with _time_phase("build_duration"):
build.build()
if zipping:
with _time_phase("zip_duration"):
build.zip_app(
frontend=frontend,
backend=backend,
zip_dest_dir=zip_dest_dir,
include_db_file=upload_db_file,
backend_excluded_dirs=backend_excluded_dirs,
)
web_dir = prerequisites.get_web_dir()
with (
build_lock.frontend_build_lock(web_dir)
if frontend or (backend and zipping and web_dir.is_dir())
Comment thread
FarhanAliRaza marked this conversation as resolved.
Comment thread
FarhanAliRaza marked this conversation as resolved.
else nullcontext()
):
# Set env mode in the environment.
environment.REFLEX_ENV_MODE.set(env)

# Override the config url values if provided.
if api_url is not None:
config._set_persistent(api_url=str(api_url))
logger.debug(f"overriding API URL: {config.api_url}")
if deploy_url is not None:
config._set_persistent(deploy_url=str(deploy_url))
logger.debug(f"overriding deploy URL: {config.deploy_url}")

exec.output_system_info()
console.rule("[bold]Compiling production app and preparing for export.")

if frontend:
with _time_phase("compile_duration"):
# Ensure module can be imported and app.compile() is called.
prerequisites.get_compiled_app(
prerender_routes=prerender_routes, trigger="export"
)
with _time_phase("setup_duration"):
# Set up .web directory and install frontend dependencies.
build.setup_frontend(Path.cwd())
with _time_phase("build_duration"):
build.build()
if zipping:
with _time_phase("zip_duration"):
build.zip_app(
frontend=frontend,
backend=backend,
zip_dest_dir=zip_dest_dir,
include_db_file=upload_db_file,
backend_excluded_dirs=backend_excluded_dirs,
)
except Exception as exc:
status = "failure"
detail = type(exc).__name__
Expand Down
Loading
Loading