From 03c7cbdf426b4fbafe50b1dc06fb2aec1e67fd80 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 14 Jun 2026 19:52:29 +0200 Subject: [PATCH 1/3] fix: eliminate 10s hang on every XandikosServer.stop() XandikosServer.stop() scheduled a coroutine via run_coroutine_threadsafe that awaited the aiohttp runner cleanup and then called loop.stop() from *inside* that same coroutine. Stopping the loop from within the coroutine prevents the loop from delivering the coroutine's result back to the concurrent.futures.Future, so .result(timeout=10) never resolved and blocked for the full 10 seconds. Because the embedded server is started and stopped once per test, this added ~10s to every Xandikos test teardown, making the integration suite take many minutes and appear to hang. Measured stop() dropped from 10.018s to 0.009s. Fix: await only the runner cleanup inside the coroutine, then stop the loop from outside it via call_soon_threadsafe. Co-Authored-By: Claude Opus 4.8 (1M context) --- caldav/testing.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/caldav/testing.py b/caldav/testing.py index 28a552d8..1211f30b 100644 --- a/caldav/testing.py +++ b/caldav/testing.py @@ -213,18 +213,24 @@ def stop(self) -> None: if self.xapp_loop and self.xapp_runner: - async def cleanup_and_stop() -> None: + async def cleanup() -> None: await self.xapp_runner.cleanup() - self.xapp_loop.stop() try: - asyncio.run_coroutine_threadsafe(cleanup_and_stop(), self.xapp_loop).result( - timeout=10 - ) + asyncio.run_coroutine_threadsafe(cleanup(), self.xapp_loop).result(timeout=10) except Exception: - if self.xapp_loop: - self.xapp_loop.call_soon_threadsafe(self.xapp_loop.stop) - elif self.xapp_loop: + # Best-effort cleanup: we swallow anything it raises (timeout, + # CancelledError, aiohttp errors). The Xandikos server is + # ephemeral per test against a throwaway serverdir, so there is + # no shared state to release and nothing to recover here. + pass + + # Stop the loop from *outside* any coroutine running on it. Calling + # loop.stop() from within a coroutine scheduled via + # run_coroutine_threadsafe can stop the loop before it delivers that + # coroutine's result to the concurrent.futures.Future, so .result() + # would block until its timeout (~10s) on every single stop(). + if self.xapp_loop: self.xapp_loop.call_soon_threadsafe(self.xapp_loop.stop) if self.thread: From db23e305f63e9ae4846feb3f79dcd9e681cb8559 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 14 Jun 2026 19:52:47 +0200 Subject: [PATCH 2/3] test: make class-scoped test_server fixture a classmethod The test_server fixture in AsyncFunctionalTestsBaseClass was scope="class" but defined as a plain instance method. Recent pytest raises PytestRemovedIn10Warning for that pattern, and the project's filterwarnings = ["error"] turns it into a hard error -- erroring out all 122 async integration tests at fixture setup. The fixture only reads the class-level `server` attribute and starts/stops it, so there is no per-instance state to lose. Make it a @classmethod as the warning recommends. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_async_integration.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_async_integration.py b/tests/test_async_integration.py index 97565781..ce1c10aa 100644 --- a/tests/test_async_integration.py +++ b/tests/test_async_integration.py @@ -186,9 +186,15 @@ def check_compatibility_flag(self, flag: str) -> bool: return flag in getattr(self._features, "_old_flags", []) @pytest.fixture(scope="class") - def test_server(self) -> TestServer: - """Get the test server for this class.""" - server = self.server + @classmethod + def test_server(cls) -> TestServer: + """Get the test server for this class. + + Defined as a classmethod because pytest deprecates class-scoped + fixtures written as plain instance methods (each test gets a fresh + instance, so per-instance state set here would not be visible anyway). + """ + server = cls.server server.start() yield server # Stop the server to free the port for other test modules From 4caf6468fe2c656d7dd52770eef5ae902fc522a5 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 14 Jun 2026 19:53:28 +0200 Subject: [PATCH 3/3] fix: treat unusable config-file paths as absent in read_config read_config only caught FileNotFoundError when probing the optional config file locations. When HOME is unset (e.g. a Nix build sandbox), the default path expands to '//.config//caldav/calendar.conf', which open() rejects with IsADirectoryError. That OSError propagated out of an optional-config probe, breaking get_davclient() and even crashing pytest's terminal summary renderer. Catch OSError (covers not-found, is-a-directory, and permission errors) and treat any of them as 'no config here', returning {} as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- caldav/config.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/caldav/config.py b/caldav/config.py index d0008506..05c8af72 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -112,9 +112,13 @@ def read_config(fn, interactive_error=False): f"config file {fn} is not valid JSON, and pyyaml is not installed" ) from None - except FileNotFoundError: - ## File not found - logging.debug(f"config file {fn} not found") + except OSError as e: + ## Optional config file is unusable (missing, a directory, no + ## permission, ...). Treat any of these as "no config here" rather + ## than letting e.g. IsADirectoryError propagate out of an optional + ## config-file probe (happens when HOME is unset and the path expands + ## to something like '//.config//caldav/calendar.conf'). + logging.debug(f"config file {fn} not usable: {e}") return {} except ValueError: # Re-raise ValueError so caller can handle config errors