Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions docs/assets/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def my_component_with_image():
# Shared assets are linked to your app via symlinks.

When using `shared=True`, the asset is symlinked from its original location to your app's external assets directory. This allows you to keep assets alongside their related code.

On Windows, symlinks require Developer Mode or admin rights. Without them, Reflex falls back to a hardlink on the same drive, or to a copy that is refreshed on the next compile when the source lives on another drive, so shared assets work without any extra setup.
Comment thread
AP24110011695 marked this conversation as resolved.
Resolved
```

## Favicon
Expand Down
1 change: 1 addition & 0 deletions news/+shared-asset-windows-fallback.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`rx.asset(shared=True)` no longer crashes on Windows without Developer Mode: when the platform denies symlink creation, the shared asset falls back to a hardlink on the same drive, or to a copy that is refreshed on the next compile when the source lives on another drive.
58 changes: 41 additions & 17 deletions reflex/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import hashlib
import inspect
import logging
import os
import shutil
import time
import uuid
from pathlib import Path
Expand Down Expand Up @@ -205,19 +207,27 @@ def remove_stale_external_asset_symlinks():


def _links_to(dst_file: Path, src_file: Path) -> bool:
"""Check whether dst_file is already a symlink to src_file.
"""Check whether dst_file already presents the src_file content.

Args:
dst_file: The path to inspect.
src_file: The asset the symlink should point at.
src_file: The asset the link should point at.

Returns:
Whether dst_file is a symlink resolving to src_file.
Whether dst_file is a symlink resolving to src_file, a hardlink
sharing its inode, or a copy with the same content.
"""
try:
if not dst_file.is_symlink():
if dst_file.is_symlink():
resolved = dst_file.resolve()
elif dst_file.is_file() and src_file.is_file():
# A hardlink shares the inode with the source; a copy matches
# by content so an edited source still gets re-copied.
if dst_file.samefile(src_file):
return True
return _content_digest(dst_file) == _content_digest(src_file)
Comment thread
AP24110011695 marked this conversation as resolved.
Resolved
else:
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
Expand All @@ -230,27 +240,31 @@ def _links_to(dst_file: Path, src_file: Path) -> bool:


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.
"""Point dst_file at src_file with a link, 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. 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
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.
dst_file presents the src_file content 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.
Windows is the exception twice over: 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. And without Developer Mode (or admin rights),
creating a symlink is denied outright, so the link falls back to a
hardlink, which shares the content on the same drive, and to a copy when
the source lives on another drive.

Args:
dst_file: The symlink to create in the app's external assets directory.
src_file: The asset file the symlink should point at.
dst_file: The link to create in the app's external assets directory.
src_file: The asset file the link should point at.

Raises:
PermissionError: If the destination could not be replaced.
OSError: If the source could not be linked or copied at all.
"""
if _links_to(dst_file, src_file):
# Already correct: leave it alone so file watchers see no change.
Expand All @@ -262,7 +276,17 @@ def _link_shared_asset(dst_file: Path, src_file: Path) -> None:
# 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)
try:
tmp_file.symlink_to(src_file)
except OSError:
# Symlink creation was denied (Windows without Developer Mode
# raises WinError 1314). A hardlink tracks the source content on
# the same drive; a copy keeps the app working across drives and
# is refreshed by the content check on the next compile.
try:
os.link(src_file, tmp_file)
except OSError:
shutil.copy2(src_file, tmp_file)
Comment thread
AP24110011695 marked this conversation as resolved.
Resolved
Comment thread
AP24110011695 marked this conversation as resolved.
Resolved
Comment thread
AP24110011695 marked this conversation as resolved.
Resolved
for attempt in range(_MAX_LINK_ATTEMPTS):
try:
tmp_file.replace(dst_file)
Expand Down
99 changes: 99 additions & 0 deletions tests/units/assets/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,105 @@ def compile_once() -> None:
assert [p.name for p in dst_file.parent.iterdir()] == ["custom_script.js"]


def _deny_symlinks(monkeypatch: pytest.MonkeyPatch) -> None:
"""Simulate a platform that denies symlink creation.

Windows without Developer Mode denies `os.symlink` outright with
WinError 1314, so the link has to fall back to a hardlink or a copy.

Args:
monkeypatch: A pytest fixture for patching.
"""

def denied_symlink(target, path, *args, **kwargs):
msg = f"A required privilege is not held by the client: {path!r} -> {target!r}"
raise OSError(msg)

monkeypatch.setattr(os, "symlink", denied_symlink)


def test_link_shared_asset_hardlink_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without symlink privilege, a hardlink keeps the content shared.

Regression test: `rx.asset(shared=True)` crashed with WinError 1314 on
Windows without Developer Mode instead of linking the asset another way.

Args:
tmp_path: A temporary directory provided by pytest.
monkeypatch: A pytest fixture for patching.
"""
src_file, dst_file = _staged_link_fixture(tmp_path)
_deny_symlinks(monkeypatch)

_link_shared_asset(dst_file, src_file)

assert dst_file.is_file()
assert not dst_file.is_symlink()
assert dst_file.samefile(src_file)
assert dst_file.read_text() == "script"

# A second link is a no-op so file watchers see no change.
inode = dst_file.stat().st_ino
_link_shared_asset(dst_file, src_file)
assert dst_file.stat().st_ino == inode


def test_link_shared_asset_copy_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When neither symlink nor hardlink exists, a copy keeps the app working.

Args:
tmp_path: A temporary directory provided by pytest.
monkeypatch: A pytest fixture for patching.
"""
src_file, dst_file = _staged_link_fixture(tmp_path)
_deny_symlinks(monkeypatch)

def denied_link(*args, **kwargs):
msg = "Invalid cross-device link"
raise OSError(msg)

monkeypatch.setattr(os, "link", denied_link)

_link_shared_asset(dst_file, src_file)

assert dst_file.is_file()
assert not dst_file.is_symlink()
assert not dst_file.samefile(src_file)
assert dst_file.read_text() == "script"


def test_link_shared_asset_copy_refreshes_edited_source(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An edited source re-copies on the next compile.

The content check in `_links_to` treats a stale copy as missing the
source's content, so the copy is replaced rather than left behind.

Args:
tmp_path: A temporary directory provided by pytest.
monkeypatch: A pytest fixture for patching.
"""
src_file, dst_file = _staged_link_fixture(tmp_path)
_deny_symlinks(monkeypatch)

def denied_link(*args, **kwargs):
msg = "Invalid cross-device link"
raise OSError(msg)

monkeypatch.setattr(os, "link", denied_link)

_link_shared_asset(dst_file, src_file)
src_file.write_text("updated")
Comment thread
AP24110011695 marked this conversation as resolved.
Resolved
_link_shared_asset(dst_file, src_file)

assert dst_file.read_text() == "updated"


@pytest.mark.parametrize(
"stem",
[
Expand Down