From b1e2730ee16ec8682980f553974a012e10e90d15 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 31 Oct 2026 10:55:38 -0700 Subject: [PATCH] test(exit): exempt watchdog 7's process-wide inotify reader (#561) watchdog 7 on Linux reads every inotify watch through one InotifyFD daemon thread per process that no observer stop ends, by design. The exit and package tests counted every watchdog BaseThread as the package's, so the singleton failed them once the cap was lifted. Exclude it, and require instead that no non-daemon thread is left at exit. --- tests/test_init_package.py | 7 +++++-- tests/test_package_exit.py | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/test_init_package.py b/tests/test_init_package.py index 01ab75b5..8ffb23e4 100644 --- a/tests/test_init_package.py +++ b/tests/test_init_package.py @@ -587,13 +587,16 @@ PACKAGE_THREAD_NAMES = ("PromptManagerPromptCleanup", "PromptManagerImageWorker" def package_threads(): """Live threads this design owns: the tracker, the image worker, and the - watchdog observer and emitters (which have no name of their own).""" + watchdog observer and emitters (which have no name of their own). + watchdog 7's InotifyFD, one daemon reader per process that no observer + stop ends, is not the package's.""" from watchdog.utils import BaseThread return { thread for thread in threading.enumerate() - if thread.name in PACKAGE_THREAD_NAMES or isinstance(thread, BaseThread) + if thread.name in PACKAGE_THREAD_NAMES + or (isinstance(thread, BaseThread) and type(thread).__name__ != "InotifyFD") } diff --git a/tests/test_package_exit.py b/tests/test_package_exit.py index 405a580b..724e79a0 100644 --- a/tests/test_package_exit.py +++ b/tests/test_package_exit.py @@ -72,13 +72,24 @@ CHILD = textwrap.dedent(""" package = state.get("package") result = getattr(package, "_background_work_result", None) # The image worker, the tracker, and watchdog's observer and emitters. + # watchdog 7's InotifyFD is one daemon reader per process that no + # observer stop ends, by design; it is not the package's. alive = sorted( t.name for t in threading.enumerate() if t.name in ("PromptManagerImageWorker", "PromptManagerPromptCleanup") - or isinstance(t, BaseThread) + or (isinstance(t, BaseThread) and type(t).__name__ != "InotifyFD") + ) + # Threads that would hold the exit before any atexit callback (#561). + holding = sorted( + t.name + for t in threading.enumerate() + if not t.daemon and t is not threading.main_thread() + ) + print( + "PM_EXIT_REPORT " + + json.dumps({"result": result, "alive": alive, "holding": holding}) ) - print("PM_EXIT_REPORT " + json.dumps({"result": result, "alive": alive})) sys.stdout.flush() # First registered, so it runs last: after the package's teardown and @@ -168,6 +179,7 @@ class TestPackageExit(unittest.TestCase): detail, ) self.assertEqual(report["alive"], [], detail) + self.assertEqual(report["holding"], [], detail) # The teardown's own warnings. Import-time warnings (the API's # output-folder search finds no ComfyUI here) are not about exit. for text in ( @@ -187,5 +199,7 @@ class TestPackageExit(unittest.TestCase): {"monitor": False, "thumbnails": True, "tracker": True}, detail, ) + # The stuck reader is still alive, but as a daemon it holds nothing. + self.assertEqual(report["holding"], [], detail) self.assertIn("watchdog observer still running", completed.stderr, detail) self.assertIn("Background work not stopped in time", completed.stderr, detail)