Repository: Textualize/textual

Issue:
Editing CSS with e.g. vim can crash an app
Editing the CSS of a running app (with the `--dev` switch) using e.g. `vim` causes frequent crashes when saving the changed CSS.

The code that causes it is: https://github.com/Textualize/textual/blob/4da008d5cb5d4c8485c57969b6c27b1bedeae0fc/src/textual/file_monitor.py#L32-L34

Where the `os.stat` call can raise a `FileNotFoundError` (I assume that `vim` stores a temporary version of the edited file, removes the original and renames the temporary to the original file name, meaning that the CSS file doesn't exist for a short time window).

I've added a temporary workaround which loops/sleeps a couple of times, but that's obviously not a real solution...I'm not 100% sure what the right solution would be...


Discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
I always forget the live editing feature so don't use it as often as I should, but I can't seem to reproduce this with a quick few tests.

Out of interest, do you have backups in vim off or on?

```
:set backup?
````

It might be useful to know what versions of textual and textual-dev you're working with too?
> I always forget the live editing feature so don't use it as often as I should, but I can't seem to reproduce this with a quick few tests.

I should have mentioned (or though of, rather) that when I hit this, I was working on a textual project that's stored on a NFS share. I tried reproducing it with a local copy, and I can't (did a quick couple of tests). It anyway seems to be a bug to not handle the potential exception from `os.stat` in the code I referred to above though?

> Out of interest, do you have backups in vim off or on?

```
:set backup?
nobackup
```

> It might be useful to know what versions of textual and textual-dev you're working with too?

The latest via `pip` in a custom `venv`:
```
Using cached textual-0.47.1-py3-none-any.whl
Using cached textual_dev-1.4.0-py3-none-any.whl (26 kB)
```

> It anyway seems to be a bug to not handle the potential exception from os.stat in the code I referred to above though?

I'd second this; there's enough editor backup approaches that do a move then write approach to make this a sensible approach.
> ...I can't seem to reproduce this with a quick few tests.

@TomJGooding another (contrived) way to reproduce it is to start a Textual app and then rename the CSS file...that'll also cause a crash...

Catching that exception seems reasonable, even if we can't reproduce the issue.

Resolving patch:
diff --git a/src/textual/app.py b/src/textual/app.py
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -72,6 +72,7 @@
 from .await_remove import AwaitRemove
 from .binding import Binding, BindingType, _Bindings
 from .command import CommandPalette, Provider
+from .css.errors import StylesheetError
 from .css.query import NoMatches
 from .css.stylesheet import RulesMap, Stylesheet
 from .design import ColorSystem
@@ -1469,7 +1470,15 @@ async def _on_css_change(self) -> None:
             try:
                 time = perf_counter()
                 stylesheet = self.stylesheet.copy()
-                stylesheet.read_all(css_paths)
+                try:
+                    stylesheet.read_all(css_paths)
+                except StylesheetError as error:
+                    # If one of the CSS paths is no longer available (or perhaps temporarily unavailable),
+                    #  we'll end up with partial CSS, which is probably confusing more than anything. We opt to do
+                    #  nothing here, knowing that we'll retry again very soon, on the next file monitor invocation.
+                    #  Related issue: https://github.com/Textualize/textual/issues/3996
+                    self.log.warning(str(error))
+                    return
                 stylesheet.parse()
                 elapsed = (perf_counter() - time) * 1000
                 if self._css_has_errors:
diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py
--- a/src/textual/file_monitor.py
+++ b/src/textual/file_monitor.py
@@ -31,7 +31,14 @@ def __rich_repr__(self) -> rich.repr.Result:
 
     def _get_last_modified_time(self) -> float:
         """Get the most recent modified time out of all files being watched."""
-        return max((os.stat(path).st_mtime for path in self._paths), default=0)
+        modified_times = []
+        for path in self._paths:
+            try:
+                modified_time = os.stat(path).st_mtime
+            except FileNotFoundError:
+                modified_time = 0
+            modified_times.append(modified_time)
+        return max(modified_times, default=0)
 
     def check(self) -> bool:
         """Check the monitored files. Return True if any were changed since the last modification time."""


Test patch:
diff --git a/tests/css/test_css_reloading.py b/tests/css/test_css_reloading.py
--- a/tests/css/test_css_reloading.py
+++ b/tests/css/test_css_reloading.py
@@ -1,7 +1,4 @@
-"""
-Regression test for https://github.com/Textualize/textual/issues/3931
-"""
-
+import os
 from pathlib import Path
 
 from textual.app import App, ComposeResult
@@ -34,7 +31,7 @@ def on_mount(self) -> None:
 
 
 async def test_css_reloading_applies_to_non_top_screen(monkeypatch) -> None:  # type: ignore
-    """Regression test for https://github.com/Textualize/textual/issues/2063."""
+    """Regression test for https://github.com/Textualize/textual/issues/3931"""
 
     monkeypatch.setenv(
         "TEXTUAL", "debug"
@@ -65,3 +62,25 @@ async def test_css_reloading_applies_to_non_top_screen(monkeypatch) -> None:  #
         # Height should fall back to 1.
         assert first_label.styles.height is not None
         assert first_label.styles.height.value == 1
+
+
+async def test_css_reloading_file_not_found(monkeypatch, tmp_path):
+    """Regression test for https://github.com/Textualize/textual/issues/3996
+
+    Files can become temporarily unavailable during saving on certain environments.
+    """
+    monkeypatch.setenv("TEXTUAL", "debug")
+
+    css_path = tmp_path / "test_css_reloading_file_not_found.tcss"
+    with open(css_path, "w") as css_file:
+        css_file.write("#a {color: red;}")
+
+    class TextualApp(App):
+        CSS_PATH = css_path
+
+    app = TextualApp()
+    async with app.run_test() as pilot:
+        await pilot.app._on_css_change()
+        os.remove(css_path)
+        assert not css_path.exists()
+        await pilot.app._on_css_change()
diff --git a/tests/test_file_monitor.py b/tests/test_file_monitor.py
--- a/tests/test_file_monitor.py
+++ b/tests/test_file_monitor.py
@@ -1,3 +1,4 @@
+import os
 from pathlib import Path
 
 from textual.file_monitor import FileMonitor
@@ -6,3 +7,52 @@
 def test_repr() -> None:
     file_monitor = FileMonitor([Path(".")], lambda: None)
     assert "FileMonitor" in repr(file_monitor)
+
+
+def test_file_never_found():
+    path = "doesnt_exist.tcss"
+    file_monitor = FileMonitor([Path(path)], lambda: None)
+    file_monitor.check()  # Ensuring no exceptions are raised.
+
+
+async def test_file_deletion(tmp_path):
+    """In some environments, a file can become temporarily unavailable during saving.
+
+    This test ensures the FileMonitor is robust enough to handle a file temporarily being
+    unavailable, and that it recovers when the file becomes available again.
+
+    Regression test for: https://github.com/Textualize/textual/issues/3996
+    """
+
+    def make_file():
+        path = tmp_path / "will_be_deleted.tcss"
+        path.write_text("#foo { color: dodgerblue; }")
+        return path
+
+    callback_counter = 0
+
+    def callback():
+        nonlocal callback_counter
+        callback_counter += 1
+
+    path = make_file()
+    file_monitor = FileMonitor([path], callback)
+
+    # Ensure the file monitor survives after the file is gone
+    await file_monitor()
+    os.remove(path)
+
+    # The file is now unavailable, but we don't crash here
+    await file_monitor()
+    await file_monitor()
+
+    # Despite multiple checks, the file was only available for the first check,
+    # and we only fire the callback while the file is available.
+    assert callback_counter == 1
+
+    # The file is made available again.
+    make_file()
+
+    # Since the file is available, the callback should fire when the FileMonitor is called
+    await file_monitor()
+    assert callback_counter == 2