fix: clean up stale asset symlinks to prevent hot reload crash loop#6163
fix: clean up stale asset symlinks to prevent hot reload crash loop#6163
Conversation
…6159) When a Python module directory using rx.asset(shared=True) is renamed, stale symlinks in assets/external/ cause Granian to enter an infinite reload loop. Clean up broken symlinks and empty directories in assets/external/ in App.__call__ before compilation, so the cleanup runs on every hot reload, not just initial startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a hot-reload crash loop (issue #6159) where renaming a Python module that uses Key changes:
Issues found:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Granian as Granian (file watcher)
participant App as App.__call__
participant Cleanup as remove_stale_external_asset_symlinks
participant FS as assets/external/ (filesystem)
participant Compile as App._compile
Granian->>App: hot reload triggered
App->>Cleanup: remove_stale_external_asset_symlinks()
Cleanup->>FS: rglob("*") — find all paths
FS-->>Cleanup: list of paths (symlinks + dirs)
Cleanup->>FS: unlink broken symlinks (target missing)
Cleanup->>FS: rglob("*") — find remaining dirs
FS-->>Cleanup: remaining directories
Cleanup->>FS: rmdir() empty directories (deepest first)
Cleanup-->>App: done
App->>Compile: _compile(prerender_routes=...)
Compile->>FS: rx.asset(shared=True) recreates symlinks
Note over FS: No stale symlinks → no spurious<br/>file-change events → no reload loop
Compile-->>App: compilation complete
App-->>Granian: ASGI app returned
Last reviewed commit: ff962fc |
| for dirpath in sorted(external_dir.rglob("*"), reverse=True): | ||
| if dirpath.is_dir() and not any(dirpath.iterdir()): | ||
| dirpath.rmdir() |
There was a problem hiding this comment.
rmdir() will fail on valid symlinks pointing to directories
Path.is_dir() returns True for both real directories and symlinks that resolve to a directory. If assets/external/ ever contains a valid directory symlink (e.g., placed there manually or via a future API change), dirpath.rmdir() would raise NotADirectoryError on POSIX systems because os.rmdir() requires the path to be an actual directory, not a symlink.
The check should also exclude symlinks:
| for dirpath in sorted(external_dir.rglob("*"), reverse=True): | |
| if dirpath.is_dir() and not any(dirpath.iterdir()): | |
| dirpath.rmdir() | |
| for dirpath in sorted(external_dir.rglob("*"), reverse=True): | |
| if dirpath.is_dir() and not dirpath.is_symlink() and not any(dirpath.iterdir()): | |
| dirpath.rmdir() |
tests/units/assets/test_assets.py
Outdated
| def test_remove_stale_symlinks_no_external_dir() -> None: | ||
| """Test that cleanup is a no-op when assets/external/ doesn't exist.""" | ||
| from reflex.assets import remove_stale_external_asset_symlinks | ||
|
|
||
| external_dir = ( | ||
| Path.cwd() / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS | ||
| ) | ||
| assert not external_dir.exists() | ||
| # Should not raise. | ||
| remove_stale_external_asset_symlinks() |
There was a problem hiding this comment.
Fragile pre-condition assertion
Line 133 assert not external_dir.exists() hard-codes an assumption about the test environment state. If the directory exists at test start (e.g., a previous test run crashed before cleanup, or tests are run in a non-clean environment), this assertion will fail before remove_stale_external_asset_symlinks() is even exercised, producing a misleading failure.
Consider using pytest.skip or ensuring the directory is cleaned up via a fixture:
def test_remove_stale_symlinks_no_external_dir(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
...
remove_stale_external_asset_symlinks() # should not raiseOr simply skip the pre-condition check if the intent is just to verify the function handles a missing directory gracefully.
- Add `not dirpath.is_symlink()` check before `rmdir()` to avoid NotADirectoryError on symlinks pointing to directories. - Use tmp_path/monkeypatch in no-external-dir test to avoid depending on prior test environment state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@greptile-apps please re-review |
| def test_remove_stale_external_asset_symlinks() -> None: | ||
| """Test that stale symlinks and empty dirs in assets/external/ are cleaned up.""" | ||
| from reflex.assets import remove_stale_external_asset_symlinks | ||
|
|
||
| external_dir = ( | ||
| Path.cwd() / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS | ||
| ) | ||
|
|
||
| # Set up: create a subdirectory with a broken symlink. | ||
| stale_dir = external_dir / "old_module" / "subpkg" | ||
| stale_dir.mkdir(parents=True, exist_ok=True) | ||
| stale_symlink = stale_dir / "missing_file.js" | ||
| stale_symlink.symlink_to("/nonexistent/path/missing_file.js") | ||
| assert stale_symlink.is_symlink() | ||
| assert not stale_symlink.resolve().exists() | ||
|
|
||
| # Also create a valid symlink that should be preserved. | ||
| valid_dir = external_dir / "valid_module" | ||
| valid_dir.mkdir(parents=True, exist_ok=True) | ||
| valid_target = Path(__file__).parent / "custom_script.js" | ||
| valid_symlink = valid_dir / "custom_script.js" | ||
| valid_symlink.symlink_to(valid_target) | ||
| assert valid_symlink.is_symlink() | ||
| assert valid_symlink.resolve().exists() | ||
|
|
||
| try: | ||
| remove_stale_external_asset_symlinks() | ||
|
|
||
| # Broken symlink and its empty parent dirs should be removed. | ||
| assert not stale_symlink.exists() | ||
| assert not stale_symlink.is_symlink() | ||
| assert not stale_dir.exists() | ||
| assert not (external_dir / "old_module").exists() | ||
|
|
||
| # Valid symlink should be preserved. | ||
| assert valid_symlink.is_symlink() | ||
| assert valid_symlink.resolve().exists() | ||
| finally: | ||
| # Clean up. | ||
| shutil.rmtree(external_dir) |
There was a problem hiding this comment.
Test operates on real CWD, not an isolated temp directory
test_remove_stale_external_asset_symlinks resolves external_dir from Path.cwd() without redirecting the working directory to a tmp_path. This has two concrete risks:
-
Side-effect on real app directories: If the test suite is run from a directory that already has a real
assets/external/tree (e.g., a developer running tests inside a Reflex project), theshutil.rmtree(external_dir)in thefinallyblock will delete all real symlinked assets, not just the test artifacts. -
Test interaction: Because
test_shared_asset(line 11) also creates and tears downassets/external/in the same CWD, the two tests may stomp on each other if run in parallel or in certain orders.
By contrast, test_remove_stale_symlinks_no_external_dir (line 126) correctly uses monkeypatch.chdir(tmp_path). The same pattern should be applied here:
def test_remove_stale_external_asset_symlinks(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
external_dir = (
tmp_path / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS
)
# ... rest of setup using external_dir ...
try:
remove_stale_external_asset_symlinks()
...
finally:
shutil.rmtree(external_dir, ignore_errors=True)This makes the test fully hermetic regardless of the environment.
| for path in external_dir.rglob("*"): | ||
| if path.is_symlink() and not path.resolve().exists(): | ||
| path.unlink() |
There was a problem hiding this comment.
Mutating directory while iterating its generator
external_dir.rglob("*") returns a lazy generator backed by os.scandir(). Calling path.unlink() inside the loop modifies the directory tree that the generator is still traversing. On most platforms this is harmless because already-buffered directory entries are not re-read, but it is undefined behaviour in edge cases (e.g., nested symlinks or certain network/FUSE filesystems) and can produce subtle skipping of entries.
A safer pattern is to collect all candidates first, then delete:
broken = [
p for p in external_dir.rglob("*")
if p.is_symlink() and not p.resolve().exists()
]
for path in broken:
path.unlink()This also makes the intent clearer — collect, then act.
fix #6159
When a Python module directory using rx.asset(shared=True) is renamed, stale symlinks in assets/external/ cause Granian to enter an infinite reload loop. Clean up broken symlinks and empty directories in assets/external/ in
App.__call__before compilation, so the cleanup runs on every hot reload, not just initial startup.