Skip to content

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

@TerraX3000

Description

@TerraX3000

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

Describe the bug

When renaming a Python module directory that contains rx.asset(path, shared=True) declarations, stale symlinks persist in assets/external/ pointing to the old directory path. On the next reflex run, rx.asset() creates new symlinks for the renamed path, which triggers Granian's hot reload file watcher. This causes an infinite worker crash loop:


[WARNING] Killing worker-0 after it refused to gracefully stop

The worker respawns, re-imports the app, rx.asset() runs again, symlinks are re-evaluated, Granian detects file activity, kills the worker, and the cycle repeats indefinitely. The app never becomes usable in development mode. Production mode (reflex run --env prod) is unaffected since it disables hot reload.

To Reproduce

  1. Create a component using rx.asset("my_file.js", shared=True) in a directory, e.g., components/my_component_v1/

  2. Run reflex run — works fine. Symlinks are created in assets/external/…/my_component_v1/

  3. Rename the directory: my_component_v1/my_component/

  4. Run reflex run again

  5. Result: Infinite [WARNING] Killing worker-0 after it refused to gracefully stop crash loop

Root Cause

The issue involves an interaction between rx.asset() (in reflex/assets.py) and Granian's hot reload watcher:

  1. rx.asset(shared=True) creates symlinks during import time (lines 84–95 of assets.py), not during a dedicated compilation phase. This means symlink creation happens inside the Granian worker process.

  2. Symlinks are created inside assets/external/, which is within Path.cwd() — the directory monitored by Granian's reload watcher (via reload_paths in run_granian_backend).

  3. Stale symlinks are never cleaned up. When a source directory is renamed or deleted, the old symlinks in assets/external/ become broken but remain on disk. On the next run, rx.asset() creates new symlinks for the new path, writing to the watched directory.

  4. Granian detects the file writes and triggers a reload, which kills the current worker and spawns a new one. The new worker re-imports the app, rx.asset() runs again, and the cycle repeats.

The workers_kill_timeout=2 (in run_granian_backend) is shorter than the typical app import time for large projects (~4–5 seconds), so the worker is killed before it even finishes importing — guaranteeing it never becomes ready.

Workaround

Manually delete the stale symlink directory:

# Find broken symlinks

find assets/external -xtype l



# Delete the stale directory

rm -rf assets/external/<module_path_to_old_directory>

Suggested Fix

Any one (or combination) of these would prevent the issue:

Option A: Clean up stale symlinks on startup (recommended)

Add a cleanup step before compilation that removes broken symlinks in assets/external/:

# In reflex startup/compile phase

external_dir = Path.cwd() / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS

if external_dir.exists():

    for symlink in external_dir.rglob("*"):

        if symlink.is_symlink() and not symlink.resolve().exists():

            symlink.unlink()

    # Also remove empty directories left behind

    for dirpath in sorted(external_dir.rglob("*"), reverse=True):

        if dirpath.is_dir() and not any(dirpath.iterdir()):

            dirpath.rmdir()

Option B: Exclude assets/external/ from Granian reload paths

Since rx.asset() writes to assets/external/ during import (inside the worker), this directory should not trigger reloads. Add it to HOTRELOAD_IGNORE_PATTERNS or exclude it from reload_paths.

Option C: Defer symlink creation outside the worker process

Move the symlink creation from import time to a pre-worker compilation phase, so file writes don't occur inside the Granian-watched worker process.

Related Issues

Specifics

  • Python Version: 3.12

  • Reflex Version: 0.8.27

  • Granian: (bundled with Reflex)

  • OS: Debian/Ubuntu (WSL2)

Additional Context

The rx.asset() function in assets.py only creates symlinks when not backend_only and not dst_file.exists(). On a clean run with no stale symlinks, this is a no-op and causes no issues. The problem only manifests after renaming or deleting a directory that previously had rx.asset(shared=True) declarations, because the old symlinks become broken and new ones must be created at the new path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions