Skip to content

fix: recover polling file watcher from transient file absence#8860

Open
Krishnachaitanyakc wants to merge 2 commits intomarimo-team:mainfrom
Krishnachaitanyakc:fix/file-watcher-crash-recovery
Open

fix: recover polling file watcher from transient file absence#8860
Krishnachaitanyakc wants to merge 2 commits intomarimo-team:mainfrom
Krishnachaitanyakc:fix/file-watcher-crash-recovery

Conversation

@Krishnachaitanyakc
Copy link
Copy Markdown

Summary

Fixes #8624

The PollingFileWatcher._poll() method crashed with an unhandled FileNotFoundError when the watched file was temporarily missing. This commonly occurs with editors like vim that save via a delete-and-rename cycle, causing a brief window where the file does not exist. Once the exception propagated, the polling task died permanently and the file watcher never recovered -- forcing users to restart marimo.

Changes:

  • Instead of raising FileNotFoundError immediately when the file is absent, the watcher now tolerates up to MAX_MISSING_POLLS (5) consecutive absences before gracefully stopping
  • If the file reappears within that window, the missing counter resets and the watcher resumes normal operation, firing the change callback as expected
  • Added _missing_count instance variable and MAX_MISSING_POLLS class constant to PollingFileWatcher

Test plan

  • Added test_polling_file_watcher_transient_missing -- verifies the watcher survives a brief file deletion (simulating vim save) and correctly detects the file change when it reappears
  • Added test_polling_file_watcher_permanently_missing -- verifies the watcher gracefully stops after MAX_MISSING_POLLS consecutive checks when the file is truly gone
  • All existing file watcher tests continue to pass
  • ruff check and ruff format pass on changed files

The PollingFileWatcher crashed with an unhandled FileNotFoundError when
the watched file was temporarily missing. This commonly occurs with
editors like vim that save by writing to a temp file, deleting the
original, and renaming -- causing a brief window where the file does
not exist.

Instead of raising immediately, the watcher now tolerates up to
MAX_MISSING_POLLS (5) consecutive absences before giving up. If the
file reappears within that window the watcher resumes normally and
fires the change callback.

Closes marimo-team#8624
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview, Comment Mar 25, 2026 1:22pm

Request Review

os.remove(tmp_path2)


async def test_polling_file_watcher_transient_missing() -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for a slightly more concise setup, you can use tmp_path:

async def test_polling_file_watcher_transient_missing(tmp_path: Path) -> None:
    tmp_file = tmp_path / "nb.py"
    tmp_file.write(b"original")

this will also handle cleanup

async def test_callback(path: Path) -> None:
callback_calls.append(path)

PollingFileWatcher.POLL_SECONDS = 0.05
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also for PollingFileWatcher.MAX_MISSING_POLLS, you should be able to use a patch decorator, that will handle cleanup

@patch("marimo._utils.file_watcher.PollingFileWatcher.MAX_MISSING_POLLS", 0.05)
async def test_polling_file_watcher_transient_missing()

…ests

Address review feedback: use pytest's tmp_path fixture for automatic
cleanup and @patch decorators for class attribute overrides instead of
manual setup/teardown.
@github-actions
Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 25, 2026

Bundle Report

Changes will increase total bundle size by 52 bytes (0.0%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
marimo-esm 25.59MB 52 bytes (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: marimo-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-*.js 52 bytes 55.39kB 0.09%

@Krishnachaitanyakc
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the resilience of the polling-based file watcher so it can survive transient file absence (e.g., editors that save via delete-and-rename), preventing the watcher task from dying permanently.

Changes:

  • Add missing-file tolerance to PollingFileWatcher via MAX_MISSING_POLLS and _missing_count.
  • Stop the polling watcher gracefully after a configurable number of consecutive missing polls.
  • Add tests covering transient and permanent missing-file scenarios.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
marimo/_utils/file_watcher.py Adds missing-file retry/stop behavior to prevent unhandled FileNotFoundError crashes in the polling watcher.
tests/_utils/test_file_watcher.py Adds async tests validating recovery from transient deletion and stopping after repeated absence.
Comments suppressed due to low confidence (1)

marimo/_utils/file_watcher.py:116

  • There’s a race between the async exists() check and os.path.getmtime(): the file can disappear after exists() returns true, causing _get_modified() to return None. In that case the current logic treats modified=None as a change (modified != self.last_modified) and triggers on_file_changed() even though the file is missing. Consider treating modified is None as a “missing” poll (increment _missing_count / retry) or re-checking exists() before firing the callback to avoid spurious change events during delete+rename saves.
            # Check for file changes
            modified = self._get_modified()
            if self.last_modified is None:
                self.last_modified = modified
            elif modified != self.last_modified:
                self.last_modified = modified
                await self.on_file_changed()

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.

File watcher crash reporting missing file and never recovers

3 participants