From f334004f8f2c0e1e8fb377014853e182c88102b8 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 14:28:00 +0000 Subject: [PATCH] fix(tests): close the restarted API before teardown unlinks the database (#477) test_a_restart_keeps_every_held_scope_and_cursor_valid registered the second PromptManagerAPI's close with addAsyncCleanup, which unittest runs after tearDownAsync. The base teardown then unlinked the temp database while that API's SQLite connections were still open: Windows refuses (WinError 32). The restart test class now closes its extra APIs in tearDownAsync before the base teardown. ImageAPITestCase's teardown now counts connections any live model still holds on the test database, closes them and fails, so a handle left open past teardown fails on Linux too instead of only on Windows. Closes #475 --- tests/test_api_images.py | 44 +++++++++++++++++++++++------ tests/test_api_workspace_restart.py | 25 ++++++++++++++-- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/tests/test_api_images.py b/tests/test_api_images.py index 0775bcb1..edb3c363 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -35,6 +35,7 @@ from aiohttp import web # noqa: E402 from aiohttp.test_utils import AioHTTPTestCase # noqa: E402 from PIL import Image # noqa: E402 +from database.models import PromptModel # noqa: E402 from database.operations import PromptDatabase # noqa: E402 import py.api.images as images_module from py.api import gallery_listing # noqa: E402 @@ -92,14 +93,41 @@ class ImageAPITestCase(AioHTTPTestCase): self._anchor_patch.stop() # Windows refuses to unlink a database that still has open handles. self.api.db.close_all() - for path in ( - self._temp_db.name, - self._temp_db.name + "-wal", - self._temp_db.name + "-shm", - ): - if os.path.exists(path): - os.unlink(path) - self._output_tmp.cleanup() + try: + leaked = self._close_leaked_connections() + finally: + for path in ( + self._temp_db.name, + self._temp_db.name + "-wal", + self._temp_db.name + "-shm", + ): + if os.path.exists(path): + os.unlink(path) + self._output_tmp.cleanup() + if leaked: + raise AssertionError( + f"{leaked} connection(s) to the test database were still open at " + "teardown; Windows cannot unlink it (WinError 32). Close every " + "extra PromptDatabase before tearDownAsync, not in a cleanup." + ) + + def _close_leaked_connections(self): + """Close and count connections other models still hold on the temp DB. + + Linux unlinks an open SQLite file, Windows does not; counting here makes + a handle left open past teardown fail on every OS (#475). + """ + key = os.path.normcase(os.path.realpath(self._temp_db.name)) + with PromptModel._instances_lock: + models = list(PromptModel._instances) + leaked = 0 + for model in models: + if model._schema_key() != key: + continue + with model._conn_lock: + leaked += len(model._connections) + model.close_all() + return leaked # -- helpers --------------------------------------------------------- diff --git a/tests/test_api_workspace_restart.py b/tests/test_api_workspace_restart.py index d975c640..d2c80bad 100644 --- a/tests/test_api_workspace_restart.py +++ b/tests/test_api_workspace_restart.py @@ -38,11 +38,25 @@ def _payload(token): class TestRestartKeepsTheOpenPage(ImageAPITestCase): async def get_application(self): + self._restarts = [] app = await super().get_application() for index in range(3): make_png(self.output_dir / f"image_{index}.png") return app + async def tearDownAsync(self): + # Before the base teardown unlinks the database: cleanups run after + # tearDownAsync, and Windows cannot unlink a file still open (#475). + errors = [] + while self._restarts: + try: + await self._restarts.pop()() + except Exception as error: # every closer runs; the first error wins + errors.append(error) + await super().tearDownAsync() + if errors: + raise errors[0] + async def first_page(self, client=None, **params): response = await (client or self.client).get( LISTING, params={"mode": "workspace", "limit": "1", **params} @@ -67,7 +81,7 @@ class TestRestartKeepsTheOpenPage(ImageAPITestCase): api.close_auto_thumbnails() api.db.close_all() - self.addAsyncCleanup(close) + self._restarts.append(close) return api, client async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self): @@ -152,8 +166,13 @@ class TestRestartKeepsTheOpenPage(ImageAPITestCase): other_db = self.comfy_dir / "elsewhere" / "prompts.db" other_db.parent.mkdir() api = PromptManagerAPI(db=PromptDatabase(str(other_db))) - self.addCleanup(api.db.close_all) - self.addCleanup(api.close_auto_thumbnails) + + async def close(): + api.close_auto_thumbnails() + api.db.close_all() + + # Closed before teardown removes the folder that holds its database. + self._restarts.append(close) self.assertNotEqual( api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"] )