From d804a31b45dbcdae1fb4daf03b088769a57d9c19 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 16:30:17 -0700 Subject: [PATCH 01/10] fix(assets): link shared assets atomically instead of check-then-act MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asset(shared=True)` created the symlink into `assets/external/` with a sequence that was racy at every step: the `exists()`/`is_symlink()` guard, the `unlink()` in the `FileExistsError` handler, and the retry `symlink_to()` after it. Concurrent compiles into one working directory — pytest-xdist workers, parallel builds, containers on a shared bind mount — lost those races and aborted the compile with `FileNotFoundError` from the `unlink()`, or with an unhandled `FileExistsError` from the retry. Build the link under a unique temporary name in the destination directory and `os.replace()` it into place, which atomically overwrites whatever the loser of the race left behind and needs no retry. Errors still propagate, so `asset()` cannot return a path with no symlink behind it. The old guard is dropped: `exists()` follows the link, so a destination pointing at some other existing file made it skip rather than repoint. The replacement fast path compares `readlink()` against the intended target, keeping the "no needless re-creation for file watchers" property while actually converging on the right target. The `FileExistsError` comment attributed this to docker bind mounts; that is one cause, but the general one is concurrency, with no container involved. --- news/+asset-symlink-race.bugfix.md | 1 + reflex/assets.py | 45 +++++-- tests/units/assets/test_assets.py | 189 ++++++++++++++++++++++++++++- 3 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 news/+asset-symlink-race.bugfix.md diff --git a/news/+asset-symlink-race.bugfix.md b/news/+asset-symlink-race.bugfix.md new file mode 100644 index 00000000000..8131d0db9d2 --- /dev/null +++ b/news/+asset-symlink-race.bugfix.md @@ -0,0 +1 @@ +`rx.asset(shared=True)` links the asset into `assets/external/` atomically, so several processes compiling into one working directory — pytest-xdist workers, parallel builds, containers on a shared bind mount — no longer fail the compile with `FileNotFoundError` or `FileExistsError`. An existing link pointing somewhere else is now repointed at the asset instead of being left in place. diff --git a/reflex/assets.py b/reflex/assets.py index 5062dd378b6..83edc574677 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -4,6 +4,7 @@ import inspect import logging import time +import uuid from pathlib import Path from typing import TYPE_CHECKING, overload @@ -201,6 +202,38 @@ def remove_stale_external_asset_symlinks(): dirpath.rmdir() +def _link_shared_asset(dst_file: Path, src_file: Path) -> None: + """Point dst_file at src_file with a symlink, whatever is already there. + + Several processes routinely compile into the same assets/external/ + directory at once: pytest-xdist workers, parallel builds, or containers + sharing a bind mount. Every step therefore has to tolerate another process + doing the same work concurrently, so the link is created under a unique + temporary name in the destination directory and renamed into place, which + atomically overwrites whatever the loser of the race left behind. Whichever + process wins, dst_file is a symlink to src_file once this returns. + + Args: + dst_file: The symlink to create in the app's external assets directory. + src_file: The asset file the symlink should point at. + """ + try: + # Already correct: leave it alone so file watchers see no change. + if dst_file.readlink() == src_file: + return + except OSError: + # Missing, or not a symlink: fall through and replace it. + pass + + tmp_file = dst_file.with_name(f".{dst_file.name}.{uuid.uuid4().hex}.tmp") + try: + tmp_file.symlink_to(src_file) + tmp_file.replace(dst_file) + except OSError: + tmp_file.unlink(missing_ok=True) + raise + + def asset( path: str, shared: bool = False, @@ -285,17 +318,7 @@ def asset( asset_folder = Path.cwd() / assets / external / subfolder asset_folder.mkdir(parents=True, exist_ok=True) - dst_file = asset_folder / path - - if not dst_file.exists() and ( - not dst_file.is_symlink() or dst_file.resolve() != src_file_shared.resolve() - ): - try: - dst_file.symlink_to(src_file_shared) - except FileExistsError: - # This happens when Simon builds the app on a bind mount in a docker container. - dst_file.unlink() - dst_file.symlink_to(src_file_shared) + _link_shared_asset(asset_folder / path, src_file_shared) return _versioned_asset_path( f"/{external}/{subfolder}/{path}", diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index d411c40ef92..1e86c6a5ecc 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -1,10 +1,13 @@ import copy import hashlib import io +import itertools import logging +import os import pickle import shutil -from collections.abc import Generator +import threading +from collections.abc import Callable, Generator from pathlib import Path from typing import cast @@ -80,6 +83,190 @@ def test_shared_asset(mock_asset_path: Path) -> None: assert not Path(mock_asset_path / "assets" / "external").exists() +def _shared_dst_file(mock_asset_path: Path) -> Path: + """Return the symlink `rx.asset(shared=True)` creates for this test module. + + Args: + mock_asset_path: The mock current working directory. + + Returns: + The path of the symlink in the app's external assets directory. + """ + return ( + mock_asset_path + / constants.Dirs.APP_ASSETS + / constants.Dirs.EXTERNAL_APP_ASSETS + / "test_assets" + / "custom_script.js" + ) + + +_REAL_SYMLINK = os.symlink + + +def _competitor_links(dst_file: Path, target: Path) -> None: + """Have the competing process point `dst_file` at `target`. + + Args: + dst_file: The destination the competitor writes to. + target: The file the competitor links to. + """ + dst_file.unlink(missing_ok=True) + _REAL_SYMLINK(target, dst_file) + + +def _simulate_competing_process( + monkeypatch: pytest.MonkeyPatch, + script: list[tuple[Callable[[], None], Callable[[], None]]], +) -> None: + """Run another process's writes around each `os.symlink` call. + + Models a second app compiling into the same working directory: each entry + of `script` is a (before, after) pair applied around the n-th symlink call, + so the competitor can create, remove or repoint the destination inside the + window a check-then-act implementation depends on. The last entry is reused + once the script is exhausted. + + Args: + monkeypatch: A pytest fixture for patching. + script: The (before, after) callbacks to apply to successive calls. + """ + real_symlink = os.symlink + calls = itertools.count() + + def fake_symlink(target, path, *args, **kwargs): + before, after = script[min(next(calls), len(script) - 1)] + before() + try: + return real_symlink(target, path, *args, **kwargs) + finally: + after() + + monkeypatch.setattr(os, "symlink", fake_symlink) + + +def test_shared_asset_survives_concurrent_removal( + mock_asset_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A competitor that creates then removes the link must not break `asset()`. + + Regression test: the destination existing when we link and being gone again + when we clean up used to escape as `FileNotFoundError` from `dst_file.unlink()`. + + Args: + mock_asset_path: The mock current working directory. + monkeypatch: A pytest fixture for patching. + """ + source_file = Path(__file__).parent / "custom_script.js" + dst_file = _shared_dst_file(mock_asset_path) + decoy = mock_asset_path / "decoy.js" + decoy.write_text("decoy") + + _simulate_competing_process( + monkeypatch, + [ + ( + lambda: _competitor_links(dst_file, decoy), + lambda: dst_file.unlink(missing_ok=True), + ) + ], + ) + + asset = rx.asset(path="custom_script.js", shared=True) + + assert ( + asset == f"/external/test_assets/custom_script.js?v={_asset_hash(source_file)}" + ) + assert dst_file.is_symlink() + assert dst_file.resolve() == source_file.resolve() + + +def test_shared_asset_survives_concurrent_recreation( + mock_asset_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A competitor recreating the link on every attempt must not break `asset()`. + + Regression test: the retry after `FileExistsError` used to raise a second, + unhandled `FileExistsError` when the destination reappeared in between. + + Args: + mock_asset_path: The mock current working directory. + monkeypatch: A pytest fixture for patching. + """ + source_file = Path(__file__).parent / "custom_script.js" + dst_file = _shared_dst_file(mock_asset_path) + decoy = mock_asset_path / "decoy.js" + decoy.write_text("decoy") + + _simulate_competing_process( + monkeypatch, [(lambda: _competitor_links(dst_file, decoy), lambda: None)] + ) + + rx.asset(path="custom_script.js", shared=True) + + assert dst_file.is_symlink() + assert dst_file.resolve() == source_file.resolve() + + +@pytest.mark.parametrize("existing", ["symlink_to_decoy", "regular_file"]) +def test_shared_asset_converges_on_correct_target( + mock_asset_path: Path, existing: str +) -> None: + """An existing destination is repointed at the asset rather than trusted. + + Args: + mock_asset_path: The mock current working directory. + existing: What another process left at the destination. + """ + source_file = Path(__file__).parent / "custom_script.js" + dst_file = _shared_dst_file(mock_asset_path) + dst_file.parent.mkdir(parents=True, exist_ok=True) + if existing == "symlink_to_decoy": + decoy = mock_asset_path / "decoy.js" + decoy.write_text("decoy") + dst_file.symlink_to(decoy) + else: + dst_file.write_text("stale copy") + + rx.asset(path="custom_script.js", shared=True) + + assert dst_file.is_symlink() + assert dst_file.resolve() == source_file.resolve() + assert dst_file.read_text() == source_file.read_text() + + +def test_shared_asset_is_thread_safe(mock_asset_path: Path) -> None: + """Concurrent `asset()` calls for the same file all succeed. + + Args: + mock_asset_path: The mock current working directory. + """ + source_file = Path(__file__).parent / "custom_script.js" + dst_file = _shared_dst_file(mock_asset_path) + errors: list[BaseException] = [] + start = threading.Barrier(8) + + def compile_once() -> None: + start.wait() + try: + for _ in range(25): + rx.asset(path="custom_script.js", shared=True) + except BaseException as exc: + errors.append(exc) + + threads = [threading.Thread(target=compile_once) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not errors + assert dst_file.is_symlink() + assert dst_file.resolve() == source_file.resolve() + # No temporary link is left behind in the destination directory. + assert [p.name for p in dst_file.parent.iterdir()] == ["custom_script.js"] + + @pytest.mark.parametrize( ("path", "shared"), [ From 0457a35027d06372960d3a95924765e6cf14632b Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 16:30:56 -0700 Subject: [PATCH 02/10] chore: name the news fragment for PR #7039 --- news/{+asset-symlink-race.bugfix.md => 7039.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{+asset-symlink-race.bugfix.md => 7039.bugfix.md} (100%) diff --git a/news/+asset-symlink-race.bugfix.md b/news/7039.bugfix.md similarity index 100% rename from news/+asset-symlink-race.bugfix.md rename to news/7039.bugfix.md From a7f90ac42c0df5616f430a14fee1b0f79024be31 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 16:34:13 -0700 Subject: [PATCH 03/10] docs: tighten comments and the news fragment --- news/7039.bugfix.md | 2 +- reflex/assets.py | 12 ++++++------ tests/units/assets/test_assets.py | 23 +++++++++++++++++------ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/news/7039.bugfix.md b/news/7039.bugfix.md index 8131d0db9d2..d797a27fe5c 100644 --- a/news/7039.bugfix.md +++ b/news/7039.bugfix.md @@ -1 +1 @@ -`rx.asset(shared=True)` links the asset into `assets/external/` atomically, so several processes compiling into one working directory — pytest-xdist workers, parallel builds, containers on a shared bind mount — no longer fail the compile with `FileNotFoundError` or `FileExistsError`. An existing link pointing somewhere else is now repointed at the asset instead of being left in place. +Compiling an app from several processes against one working directory — pytest-xdist workers, parallel builds, or containers sharing a bind mount — no longer aborts with `FileNotFoundError` or `FileExistsError` while linking a `rx.asset(shared=True)` file into `assets/external/`. A shared asset whose link already points at a different file is repointed at the asset rather than left alone. diff --git a/reflex/assets.py b/reflex/assets.py index 83edc574677..451790b1f03 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -203,15 +203,15 @@ def remove_stale_external_asset_symlinks(): def _link_shared_asset(dst_file: Path, src_file: Path) -> None: - """Point dst_file at src_file with a symlink, whatever is already there. + """Point dst_file at src_file with a symlink, regardless of what is there. Several processes routinely compile into the same assets/external/ directory at once: pytest-xdist workers, parallel builds, or containers - sharing a bind mount. Every step therefore has to tolerate another process - doing the same work concurrently, so the link is created under a unique - temporary name in the destination directory and renamed into place, which - atomically overwrites whatever the loser of the race left behind. Whichever - process wins, dst_file is a symlink to src_file once this returns. + sharing a bind mount. Linking in place would be check-then-act, so the link + is built under a unique temporary name in the destination directory and + renamed over dst_file instead, which is one atomic replace on POSIX and + leaves no window to interleave with. Whichever process wins the race, + dst_file is a symlink to src_file once this returns. Args: dst_file: The symlink to create in the app's external assets directory. diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 1e86c6a5ecc..bf4053303c9 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -83,9 +83,17 @@ def test_shared_asset(mock_asset_path: Path) -> None: assert not Path(mock_asset_path / "assets" / "external").exists() +# Captured before any patching so the simulated competitor below can write +# symlinks without recursing back into the fake that wraps `os.symlink`. +_REAL_SYMLINK = os.symlink + + def _shared_dst_file(mock_asset_path: Path) -> Path: """Return the symlink `rx.asset(shared=True)` creates for this test module. + The `test_assets` component is the calling module's name, which is what + `asset()` derives the external subfolder from. + Args: mock_asset_path: The mock current working directory. @@ -101,9 +109,6 @@ def _shared_dst_file(mock_asset_path: Path) -> Path: ) -_REAL_SYMLINK = os.symlink - - def _competitor_links(dst_file: Path, target: Path) -> None: """Have the competing process point `dst_file` at `target`. @@ -127,6 +132,11 @@ def _simulate_competing_process( window a check-then-act implementation depends on. The last entry is reused once the script is exhausted. + Patching `os.symlink` rather than the asset code keeps the simulation + implementation-agnostic: an implementation that links straight to the + destination sees the competitor's writes collide with its own, while one + that links to a private temporary name is untouched by them. + Args: monkeypatch: A pytest fixture for patching. script: The (before, after) callbacks to apply to successive calls. @@ -150,8 +160,9 @@ def test_shared_asset_survives_concurrent_removal( ) -> None: """A competitor that creates then removes the link must not break `asset()`. - Regression test: the destination existing when we link and being gone again - when we clean up used to escape as `FileNotFoundError` from `dst_file.unlink()`. + Regression test: a destination that existed when the link was created but + was gone again by the time it was cleaned up escaped as `FileNotFoundError` + from `dst_file.unlink()`. Args: mock_asset_path: The mock current working directory. @@ -186,7 +197,7 @@ def test_shared_asset_survives_concurrent_recreation( ) -> None: """A competitor recreating the link on every attempt must not break `asset()`. - Regression test: the retry after `FileExistsError` used to raise a second, + Regression test: the retry after `FileExistsError` raised a second, unhandled `FileExistsError` when the destination reappeared in between. Args: From 315856cfab54ba0abceff59478837ab60c17e119 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 16:39:59 -0700 Subject: [PATCH 04/10] fix(assets): bound the staged symlink name so long asset names still link --- reflex/assets.py | 11 ++++++++++- tests/units/assets/test_assets.py | 32 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/reflex/assets.py b/reflex/assets.py index 451790b1f03..22698700753 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -16,6 +16,10 @@ _HASH_CHUNK_SIZE = 1024 * 1024 _MAX_HASH_ATTEMPTS = 3 +# Asset name prefix retained in a staged symlink's name. Together with the +# uuid and separators this stays well inside the 255-byte component limit +# common to ext4, APFS and NTFS, whatever the asset itself is called. +_TMP_NAME_PREFIX_LEN = 64 if TYPE_CHECKING: from typing_extensions import Buffer @@ -225,7 +229,12 @@ def _link_shared_asset(dst_file: Path, src_file: Path) -> None: # Missing, or not a symlink: fall through and replace it. pass - tmp_file = dst_file.with_name(f".{dst_file.name}.{uuid.uuid4().hex}.tmp") + # Only a prefix of the asset name is kept, for the benefit of anyone who + # finds a temporary link left behind by a killed process: appending to a + # basename that is already at the filesystem's limit would not fit. + tmp_file = dst_file.with_name( + f".{dst_file.name[:_TMP_NAME_PREFIX_LEN]}.{uuid.uuid4().hex}.tmp" + ) try: tmp_file.symlink_to(src_file) tmp_file.replace(dst_file) diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index bf4053303c9..c3dd9690ae9 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -15,7 +15,11 @@ import reflex as rx import reflex.constants as constants -from reflex.assets import AssetPathStr, remove_stale_external_asset_symlinks +from reflex.assets import ( + AssetPathStr, + _link_shared_asset, + remove_stale_external_asset_symlinks, +) def _asset_hash(path: Path) -> str: @@ -278,6 +282,32 @@ def compile_once() -> None: assert [p.name for p in dst_file.parent.iterdir()] == ["custom_script.js"] +def test_link_shared_asset_with_long_filename(tmp_path: Path) -> None: + """An asset named up to the filesystem's limit still links. + + The temporary name the link is staged under has to stay within the same + limit, so it cannot simply append to an already maximal basename. + + Args: + tmp_path: A temporary directory provided by pytest. + """ + # 250 bytes: under the 255-byte component limit of ext4, APFS and NTFS, + # but with no room left for a suffix. + name = "a" * 247 + ".js" + src_dir = tmp_path / "src" + src_dir.mkdir() + src_file = src_dir / name + src_file.write_text("script") + dst_dir = tmp_path / "dst" + dst_dir.mkdir() + + _link_shared_asset(dst_dir / name, src_file) + + assert (dst_dir / name).is_symlink() + assert (dst_dir / name).resolve() == src_file.resolve() + assert [p.name for p in dst_dir.iterdir()] == [name] + + @pytest.mark.parametrize( ("path", "shared"), [ From 309883b84ec5e8721f904b9c9180f43832b3b7f9 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 16:47:48 -0700 Subject: [PATCH 05/10] fix(assets): retry the staged rename when Windows denies a concurrent replace --- reflex/assets.py | 48 +++++++++++++++---- tests/units/assets/test_assets.py | 76 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/reflex/assets.py b/reflex/assets.py index 22698700753..901e3e7b5c9 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -20,6 +20,8 @@ # uuid and separators this stays well inside the 255-byte component limit # common to ext4, APFS and NTFS, whatever the asset itself is called. _TMP_NAME_PREFIX_LEN = 64 +_MAX_LINK_ATTEMPTS = 3 +_LINK_RETRY_DELAY = 0.01 if TYPE_CHECKING: from typing_extensions import Buffer @@ -206,6 +208,19 @@ def remove_stale_external_asset_symlinks(): dirpath.rmdir() +def _links_to(dst_file: Path, src_file: Path) -> bool: + """Check whether dst_file is already a symlink to src_file. + + Args: + dst_file: The path to inspect. + src_file: The asset the symlink should point at. + + Returns: + Whether dst_file is a symlink resolving to src_file. + """ + return dst_file.is_symlink() and dst_file.resolve() == src_file.resolve() + + def _link_shared_asset(dst_file: Path, src_file: Path) -> None: """Point dst_file at src_file with a symlink, regardless of what is there. @@ -217,17 +232,21 @@ def _link_shared_asset(dst_file: Path, src_file: Path) -> None: leaves no window to interleave with. Whichever process wins the race, dst_file is a symlink to src_file once this returns. + Windows is the exception: replacing a destination that another process is + itself replacing is denied rather than serialised, so the rename is retried + there, conceding as soon as the other process turns out to have linked the + same asset. + Args: dst_file: The symlink to create in the app's external assets directory. src_file: The asset file the symlink should point at. + + Raises: + PermissionError: If the destination could not be replaced. """ - try: + if _links_to(dst_file, src_file): # Already correct: leave it alone so file watchers see no change. - if dst_file.readlink() == src_file: - return - except OSError: - # Missing, or not a symlink: fall through and replace it. - pass + return # Only a prefix of the asset name is kept, for the benefit of anyone who # finds a temporary link left behind by a killed process: appending to a @@ -237,10 +256,21 @@ def _link_shared_asset(dst_file: Path, src_file: Path) -> None: ) try: tmp_file.symlink_to(src_file) - tmp_file.replace(dst_file) - except OSError: + for attempt in range(_MAX_LINK_ATTEMPTS): + try: + tmp_file.replace(dst_file) + except PermissionError: # noqa: PERF203 # bounded, and dwarfed by the syscall + if _links_to(dst_file, src_file): + # The process that denied us wanted the same link. + return + if attempt == _MAX_LINK_ATTEMPTS - 1: + raise + time.sleep(_LINK_RETRY_DELAY * (attempt + 1)) + else: + return + finally: + # A no-op once the rename succeeded, and the cleanup if it did not. tmp_file.unlink(missing_ok=True) - raise def asset( diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index c3dd9690ae9..05ba32160ed 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -14,6 +14,7 @@ import pytest import reflex as rx +import reflex.assets as assets_module import reflex.constants as constants from reflex.assets import ( AssetPathStr, @@ -308,6 +309,81 @@ def test_link_shared_asset_with_long_filename(tmp_path: Path) -> None: assert [p.name for p in dst_dir.iterdir()] == [name] +def _staged_link_fixture(tmp_path: Path) -> tuple[Path, Path]: + """Create a source asset and an empty destination directory. + + Args: + tmp_path: A temporary directory provided by pytest. + + Returns: + The source asset and the destination the link should be created at. + """ + src_dir = tmp_path / "src" + src_dir.mkdir() + src_file = src_dir / "custom_script.js" + src_file.write_text("script") + dst_dir = tmp_path / "dst" + dst_dir.mkdir() + return src_file, dst_dir / "custom_script.js" + + +def test_link_shared_asset_concedes_denied_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A replace denied by the process that linked the same asset is conceded. + + Windows denies a replace while another process is replacing the same + destination, so this cannot be reached on POSIX without the patch. + + Args: + tmp_path: A temporary directory provided by pytest. + monkeypatch: A pytest fixture for patching. + """ + src_file, dst_file = _staged_link_fixture(tmp_path) + + def denied_replace(self: Path, target) -> Path: + _REAL_SYMLINK(src_file, dst_file) + msg = "Access is denied" + raise PermissionError(13, msg) + + monkeypatch.setattr(Path, "replace", denied_replace) + + _link_shared_asset(dst_file, src_file) + + assert dst_file.is_symlink() + assert dst_file.resolve() == src_file.resolve() + assert [p.name for p in dst_file.parent.iterdir()] == ["custom_script.js"] + + +def test_link_shared_asset_raises_when_replace_stays_denied( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A destination that never becomes linkable surfaces the error. + + Args: + tmp_path: A temporary directory provided by pytest. + monkeypatch: A pytest fixture for patching. + """ + src_file, dst_file = _staged_link_fixture(tmp_path) + attempts = 0 + + def denied_replace(self: Path, target) -> Path: + nonlocal attempts + attempts += 1 + msg = "Access is denied" + raise PermissionError(13, msg) + + monkeypatch.setattr(Path, "replace", denied_replace) + monkeypatch.setattr(assets_module, "_LINK_RETRY_DELAY", 0) + + with pytest.raises(PermissionError): + _link_shared_asset(dst_file, src_file) + + assert attempts == assets_module._MAX_LINK_ATTEMPTS + # The staged link is cleaned up rather than left in the assets directory. + assert list(dst_file.parent.iterdir()) == [] + + @pytest.mark.parametrize( ("path", "shared"), [ From 050ecb25b7ca83790dc637be56fc9f3a21c06623 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 17:05:10 -0700 Subject: [PATCH 06/10] fix(assets): cut the staged name prefix by encoded bytes, not characters --- reflex/assets.py | 16 +++++++++------- tests/units/assets/test_assets.py | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/reflex/assets.py b/reflex/assets.py index 901e3e7b5c9..033248cc449 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -3,6 +3,7 @@ import hashlib import inspect import logging +import os import time import uuid from pathlib import Path @@ -16,10 +17,10 @@ _HASH_CHUNK_SIZE = 1024 * 1024 _MAX_HASH_ATTEMPTS = 3 -# Asset name prefix retained in a staged symlink's name. Together with the -# uuid and separators this stays well inside the 255-byte component limit +# Bytes of the asset name retained in a staged symlink's name. Together with +# the uuid and separators this stays well inside the 255-byte component limit # common to ext4, APFS and NTFS, whatever the asset itself is called. -_TMP_NAME_PREFIX_LEN = 64 +_TMP_NAME_PREFIX_BYTES = 64 _MAX_LINK_ATTEMPTS = 3 _LINK_RETRY_DELAY = 0.01 @@ -250,10 +251,11 @@ def _link_shared_asset(dst_file: Path, src_file: Path) -> None: # Only a prefix of the asset name is kept, for the benefit of anyone who # finds a temporary link left behind by a killed process: appending to a - # basename that is already at the filesystem's limit would not fit. - tmp_file = dst_file.with_name( - f".{dst_file.name[:_TMP_NAME_PREFIX_LEN]}.{uuid.uuid4().hex}.tmp" - ) + # basename that is already at the filesystem's limit would not fit. The + # limit counts bytes, so the prefix is cut with the filesystem encoding + # rather than by character, which also round-trips undecodable names. + prefix = os.fsdecode(os.fsencode(dst_file.name)[:_TMP_NAME_PREFIX_BYTES]) + tmp_file = dst_file.with_name(f".{prefix}.{uuid.uuid4().hex}.tmp") try: tmp_file.symlink_to(src_file) for attempt in range(_MAX_LINK_ATTEMPTS): diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 05ba32160ed..7b805a035d0 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -283,7 +283,17 @@ def compile_once() -> None: assert [p.name for p in dst_file.parent.iterdir()] == ["custom_script.js"] -def test_link_shared_asset_with_long_filename(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "stem", + [ + # Both are just under the 255-byte component limit of ext4, APFS and + # NTFS, with no room left for a suffix. The multi-byte case additionally + # covers a prefix cut by character rather than by encoded byte. + pytest.param("a" * 247, id="ascii"), + pytest.param("😀" * 61, id="multibyte"), + ], +) +def test_link_shared_asset_with_long_filename(tmp_path: Path, stem: str) -> None: """An asset named up to the filesystem's limit still links. The temporary name the link is staged under has to stay within the same @@ -291,10 +301,9 @@ def test_link_shared_asset_with_long_filename(tmp_path: Path) -> None: Args: tmp_path: A temporary directory provided by pytest. + stem: The asset name, without its extension. """ - # 250 bytes: under the 255-byte component limit of ext4, APFS and NTFS, - # but with no room left for a suffix. - name = "a" * 247 + ".js" + name = stem + ".js" src_dir = tmp_path / "src" src_dir.mkdir() src_file = src_dir / name From 21c253b30f0e256a72b3814c9c99d795338e8591 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 17:15:52 -0700 Subject: [PATCH 07/10] fix(assets): treat an unresolvable destination as needing replacement --- reflex/assets.py | 8 +++++++- tests/units/assets/test_assets.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/reflex/assets.py b/reflex/assets.py index 033248cc449..59c3336004f 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -219,7 +219,13 @@ def _links_to(dst_file: Path, src_file: Path) -> bool: Returns: Whether dst_file is a symlink resolving to src_file. """ - return dst_file.is_symlink() and dst_file.resolve() == src_file.resolve() + try: + return dst_file.is_symlink() and dst_file.resolve() == src_file.resolve() + except (OSError, RuntimeError): + # An unresolvable destination, such as the symlink loop a crashed or + # racing writer can leave behind, which Python below 3.13 reports as + # RuntimeError. It is not the link we want either way, so replace it. + return False def _link_shared_asset(dst_file: Path, src_file: Path) -> None: diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 7b805a035d0..260e9269c80 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -336,6 +336,27 @@ def _staged_link_fixture(tmp_path: Path) -> tuple[Path, Path]: return src_file, dst_dir / "custom_script.js" +def test_link_shared_asset_replaces_symlink_loop(tmp_path: Path) -> None: + """A destination caught in a symlink loop is replaced, not reported. + + Resolving a loop raises `RuntimeError` on Python below 3.13, so a + destination left in that state by a crashed or racing writer would + otherwise abort the compile. + + Args: + tmp_path: A temporary directory provided by pytest. + """ + src_file, dst_file = _staged_link_fixture(tmp_path) + partner = dst_file.parent / "loop_partner.js" + dst_file.symlink_to(partner) + partner.symlink_to(dst_file) + + _link_shared_asset(dst_file, src_file) + + assert dst_file.is_symlink() + assert dst_file.resolve() == src_file.resolve() + + def test_link_shared_asset_concedes_denied_replace( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From be10a736e29a88198fb33e93feb6eae7f8ac7f11 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 17:19:53 -0700 Subject: [PATCH 08/10] fix(assets): stage the link under a name that does not derive from the asset --- reflex/assets.py | 17 +++++------------ tests/units/assets/test_assets.py | 9 ++++++--- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/reflex/assets.py b/reflex/assets.py index 59c3336004f..6589db0b9f3 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -3,7 +3,6 @@ import hashlib import inspect import logging -import os import time import uuid from pathlib import Path @@ -17,10 +16,6 @@ _HASH_CHUNK_SIZE = 1024 * 1024 _MAX_HASH_ATTEMPTS = 3 -# Bytes of the asset name retained in a staged symlink's name. Together with -# the uuid and separators this stays well inside the 255-byte component limit -# common to ext4, APFS and NTFS, whatever the asset itself is called. -_TMP_NAME_PREFIX_BYTES = 64 _MAX_LINK_ATTEMPTS = 3 _LINK_RETRY_DELAY = 0.01 @@ -255,13 +250,11 @@ def _link_shared_asset(dst_file: Path, src_file: Path) -> None: # Already correct: leave it alone so file watchers see no change. return - # Only a prefix of the asset name is kept, for the benefit of anyone who - # finds a temporary link left behind by a killed process: appending to a - # basename that is already at the filesystem's limit would not fit. The - # limit counts bytes, so the prefix is cut with the filesystem encoding - # rather than by character, which also round-trips undecodable names. - prefix = os.fsdecode(os.fsencode(dst_file.name)[:_TMP_NAME_PREFIX_BYTES]) - tmp_file = dst_file.with_name(f".{prefix}.{uuid.uuid4().hex}.tmp") + # The staged name deliberately does not derive from the asset name: that + # would have to be truncated to fit the filesystem's limit on a path + # component, and cutting a name to a byte budget without splitting a + # character is more machinery than a transient name is worth. + tmp_file = dst_file.with_name(f".{uuid.uuid4().hex}.tmp") try: tmp_file.symlink_to(src_file) for attempt in range(_MAX_LINK_ATTEMPTS): diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 260e9269c80..615534c3599 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -286,11 +286,14 @@ def compile_once() -> None: @pytest.mark.parametrize( "stem", [ - # Both are just under the 255-byte component limit of ext4, APFS and - # NTFS, with no room left for a suffix. The multi-byte case additionally - # covers a prefix cut by character rather than by encoded byte. + # All are just under the 255-byte component limit of ext4, APFS and + # NTFS, with no room left for a suffix. The multi-byte names guard a + # staged name derived from the asset name by slicing it: whether the + # slice is taken in characters or in bytes, and if in bytes, whether it + # splits an encoded character (which `off_boundary` does at 64 bytes). pytest.param("a" * 247, id="ascii"), pytest.param("😀" * 61, id="multibyte"), + pytest.param("a" + "😀" * 60, id="multibyte_off_boundary"), ], ) def test_link_shared_asset_with_long_filename(tmp_path: Path, stem: str) -> None: From cffc03c57508b4581b774c871b07b93f19b8528e Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 17:23:20 -0700 Subject: [PATCH 09/10] refactor(assets): guard only the destination when checking an existing link --- reflex/assets.py | 8 +++++++- tests/units/assets/test_assets.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/reflex/assets.py b/reflex/assets.py index 6589db0b9f3..6036c7a7b56 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -215,12 +215,18 @@ def _links_to(dst_file: Path, src_file: Path) -> bool: Whether dst_file is a symlink resolving to src_file. """ try: - return dst_file.is_symlink() and dst_file.resolve() == src_file.resolve() + if not dst_file.is_symlink(): + return False + resolved = dst_file.resolve() except (OSError, RuntimeError): # An unresolvable destination, such as the symlink loop a crashed or # racing writer can leave behind, which Python below 3.13 reports as # RuntimeError. It is not the link we want either way, so replace it. return False + # The source is the caller's to validate, so its errors are not caught + # here: replacing the destination on a bad source would destroy a good + # link on the way to failing anyway. + return resolved == src_file.resolve() def _link_shared_asset(dst_file: Path, src_file: Path) -> None: diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 615534c3599..a878e68cc07 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -360,6 +360,38 @@ def test_link_shared_asset_replaces_symlink_loop(tmp_path: Path) -> None: assert dst_file.resolve() == src_file.resolve() +def test_link_shared_asset_leaves_destination_on_source_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A source that cannot be resolved surfaces, without touching the link. + + Only the destination is inspected defensively; swallowing a source error + would replace a good link on the way to failing on the source anyway. + + Args: + tmp_path: A temporary directory provided by pytest. + monkeypatch: A pytest fixture for patching. + """ + src_file, dst_file = _staged_link_fixture(tmp_path) + decoy = tmp_path / "decoy.js" + decoy.write_text("decoy") + dst_file.symlink_to(decoy) + real_resolve = Path.resolve + + def fake_resolve(self: Path, *args, **kwargs) -> Path: + if self == src_file: + msg = "Symlink loop" + raise RuntimeError(msg) + return real_resolve(self, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fake_resolve) + + with pytest.raises(RuntimeError): + _link_shared_asset(dst_file, src_file) + + assert dst_file.readlink() == decoy + + def test_link_shared_asset_concedes_denied_replace( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 1e49725a56fc7422d3a0d60bb4f252e19ab558bc Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 2 Sep 2026 17:29:47 -0700 Subject: [PATCH 10/10] test(assets): compare the untouched link after resolution for Windows --- tests/units/assets/test_assets.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index a878e68cc07..27fc1e5003f 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -389,7 +389,10 @@ def fake_resolve(self: Path, *args, **kwargs) -> Path: with pytest.raises(RuntimeError): _link_shared_asset(dst_file, src_file) - assert dst_file.readlink() == decoy + # Compared after resolution: Windows reads a symlink back with a `\\?\` + # prefix, so the raw target is not comparable to the path it was made from. + assert dst_file.is_symlink() + assert dst_file.resolve() == decoy.resolve() def test_link_shared_asset_concedes_denied_replace(