Skip to content

fix: clean up stale asset symlinks to prevent hot reload crash loop#6163

Merged
masenf merged 5 commits intomainfrom
masenf/clean-up-old-asset-symlinks
Mar 12, 2026
Merged

fix: clean up stale asset symlinks to prevent hot reload crash loop#6163
masenf merged 5 commits intomainfrom
masenf/clean-up-old-asset-symlinks

Conversation

@masenf
Copy link
Copy Markdown
Collaborator

@masenf masenf commented Mar 7, 2026

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.

…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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR fixes a hot-reload crash loop (issue #6159) where renaming a Python module that uses rx.asset(shared=True) leaves stale symlinks in assets/external/, causing Granian's file watcher to continuously detect changes and re-trigger reloads. The fix adds a remove_stale_external_asset_symlinks() utility that cleans up broken symlinks and their empty parent directories before each compilation cycle in App.__call__.

Key changes:

  • reflex/assets.py: New remove_stale_external_asset_symlinks() function walks assets/external/, unlinks broken symlinks, then removes empty directories deepest-first using a reverse-sorted rglob.
  • reflex/app.py: Calls the cleanup at the top of App.__call__ (before _compile), so it runs on every hot-reload, not just initial startup.
  • tests/units/assets/test_assets.py: Two new tests cover the happy path (stale symlink removed, valid symlink preserved) and the no-op path (missing external_dir).

Issues found:

  • test_remove_stale_external_asset_symlinks creates and destroys assets/external/ relative to the real Path.cwd() rather than a tmp_path. The shutil.rmtree in its finally block would silently wipe real app assets if the tests are run from inside a Reflex project directory. Using monkeypatch.chdir(tmp_path) (as done in the companion test) would make it fully hermetic.
  • The broken-symlink removal loop mutates the directory tree while a live rglob generator is still open; collecting candidates into a list first is a minor robustness improvement.

Confidence Score: 4/5

  • Safe to merge after addressing the test isolation issue; the production logic is sound.
  • The core fix in assets.py and the call-site change in app.py are correct and well-scoped. The only meaningful concern is in the test — it uses the real CWD rather than a temporary directory, which could destroy real assets/external/ content in certain environments. This does not affect production behaviour but is worth fixing before merging to avoid CI environment pollution.
  • tests/units/assets/test_assets.py — the main test function needs monkeypatch.chdir(tmp_path) to be environment-safe.

Important Files Changed

Filename Overview
reflex/assets.py Adds remove_stale_external_asset_symlinks() that walks assets/external/, unlinks broken symlinks, and removes now-empty directories; logic is correct (including the not dirpath.is_symlink() guard), but mutating the directory tree while holding a live rglob generator is a minor robustness concern.
reflex/app.py Calls remove_stale_external_asset_symlinks() at the start of App.__call__ (before _compile), ensuring cleanup runs on every hot-reload cycle; the placement is correct and the lazy import avoids circular-import issues.
tests/units/assets/test_assets.py Adds two new tests for the cleanup function; the test_remove_stale_external_asset_symlinks test is missing monkeypatch.chdir(tmp_path), meaning it operates on the real CWD and shutil.rmtree in its finally block can destroy real app assets; test_remove_stale_symlinks_no_external_dir is implemented correctly.

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: ff962fc

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 7, 2026

Merging this PR will not alter performance

✅ 8 untouched benchmarks


Comparing masenf/clean-up-old-asset-symlinks (4710809) with main (1b39a5a)

Open in CodSpeed

- 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>
@masenf
Copy link
Copy Markdown
Collaborator Author

masenf commented Mar 7, 2026

@greptile-apps please re-review

@masenf masenf merged commit 7607fa3 into main Mar 12, 2026
47 checks passed
@masenf masenf deleted the masenf/clean-up-old-asset-symlinks branch March 12, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rx.asset(shared=True) symlink creation triggers Granian hot reload crash loop after directory rename

2 participants