Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions AutoML.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@
from tools.model_loader import start_cleanup_thread, stop_cleanup_thread
from tools.splash_launcher import SplashLauncher
from tools.trash_eater import manager_eater
from tools.thread_manager import manager as thread_manager
from mainappsrc.version import VERSION
from mainappsrc.services import service_manager
from mainappsrc.core.automl_core import (
AutoMLApp,
FaultTreeNode,
Expand Down Expand Up @@ -300,18 +300,9 @@ def _loader() -> Any:
if module is None: # pragma: no cover - defensive
return

class _AutoMLCoreService:
def __init__(self, mod: Any) -> None:
self._module = mod

def run(self) -> None:
self._module.main()

service_manager.request(
"automl_core", lambda: _AutoMLCoreService(module), recoverable=False, daemon=False
)
service_manager.join("automl_core")
service_manager.release("automl_core")
thread = thread_manager.register("main_app", module.main, daemon=False)
thread.join()
Comment on lines +303 to +304

Choose a reason for hiding this comment

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

[P1] Unregister main thread before ThreadManager can respawn it

The new main() flow registers the application’s entry point with thread_manager and only calls thread_manager.unregister("main_app") after joining the returned thread. ThreadManager automatically restarts any registered thread that terminates, so there is a race: if module.main returns or raises and the monitor runs before unregister executes, a fresh non-daemon thread is spawned. The join waits only for the original thread, while the replacement thread continues running unjoined and may block process shutdown or execute module.main twice. To avoid this, unregister (and join the returned thread) before the monitor can respawn the worker, or otherwise disable auto-restart for this one-shot task.

Useful? React with 👍 / 👎.

thread_manager.unregister("main_app")

memory_manager.cleanup()
if _diagnostics_manager:
Expand Down
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
-->

# Version History
- 0.2.208 - Run core services through monitored threads, reintroduce threaded
service manager and execute main app within thread manager.
- 0.2.207 - Remove threaded service manager and launch core directly.
- 0.2.206 - Internal version synchronisation.
- 0.2.205 - Launch AutoML core through service manager and allow non-daemon
service threads with join support.
- 0.2.204 - Introduce threaded service manager to lazily load services,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 0.2.206
version: 0.2.208
Author: Miguel Marina <karel.capek.robotics@gmail.com> - [LinkedIn](https://www.linkedin.com/in/progman32/)
# AutoML

Expand Down
7 changes: 2 additions & 5 deletions mainappsrc/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@
SERVICE_MODULES = SERVICE_CLASSES

__all__ = list(SERVICE_CLASSES)
from .service_manager import ServiceManager, manager as service_manager
__all__.extend(["ServiceManager", "service_manager"])


def __getattr__(name: str) -> Any: # pragma: no cover - simple delegation
Expand All @@ -150,8 +152,3 @@ def __getattr__(name: str) -> Any: # pragma: no cover - simple delegation
raise AttributeError(f"module 'mainappsrc.services' has no attribute {name!r}") from exc
module = import_module(module_name)
return getattr(module, attr_name)


from .service_manager import ServiceManager, manager as service_manager

__all__.extend(["ServiceManager", "service_manager"])
2 changes: 2 additions & 0 deletions mainappsrc/services/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,5 @@ def _watchdog(self) -> None: # pragma: no cover - simple loop


manager = ServiceManager()

__all__ = ["ServiceManager", "manager"]
2 changes: 1 addition & 1 deletion mainappsrc/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@

"""Project version information."""

VERSION = "0.2.206"
VERSION = "0.2.208"

__all__ = ["VERSION"]
39 changes: 39 additions & 0 deletions tests/test_launcher_threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import time
import threading
import automl as launcher


Expand Down Expand Up @@ -44,3 +45,41 @@ def wait(self):
launcher.ensure_packages()
elapsed = time.time() - start
assert elapsed < 0.35


def test_main_runs_in_thread(monkeypatch):
"""The main application executes within a monitored thread."""

import AutoML as real_launcher

called: list[str] = []

def fake_register(name, target, *a, **k):
called.append(name)
thread = threading.Thread(target=target, daemon=k.get("daemon", True))
thread.start()
return thread

monkeypatch.setattr(real_launcher.thread_manager, "register", fake_register)
monkeypatch.setattr(real_launcher.thread_manager, "unregister", lambda name: None)

class DummySplash:
def __init__(self, loader, post_delay=0):
self.loader = loader

def launch(self):
self.loader()

monkeypatch.setattr(real_launcher, "SplashLauncher", DummySplash)

def fake_bootstrap():
class _Mod:
def main(self):
pass

return _Mod()

monkeypatch.setattr(real_launcher, "_bootstrap", fake_bootstrap)

real_launcher.main()
assert "main_app" in called
9 changes: 8 additions & 1 deletion tests/test_thread_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

import time

from tools.thread_manager import ThreadManager
from tools.thread_manager import ThreadManager, manager as thread_manager
from tools.memory_manager import manager as memory_manager


def test_thread_manager_restarts_dead_thread() -> None:
Expand All @@ -32,3 +33,9 @@ def worker() -> None:
time.sleep(0.15) # allow thread to run and be restarted
assert runs["count"] >= 2
manager.stop_all()


def test_memory_manager_thread_registered() -> None:
"""Memory manager uses thread manager for its background thread."""

assert "memory_manager" in thread_manager._threads
10 changes: 6 additions & 4 deletions tools/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
from typing import Any, Callable, Dict, Set
import atexit

from .thread_manager import manager as thread_manager

try: # pragma: no cover - optional dependency
import psutil
except Exception: # pragma: no cover - psutil may not be installed
Expand All @@ -55,8 +57,7 @@ def __init__(self, interval: float = 60.0) -> None:
self._procs: Dict[str, Any] = {}
self._interval = interval
self._stop_event = threading.Event()
self._thread = threading.Thread(target=self._monitor, daemon=True)
self._thread.start()
self._thread = thread_manager.register("memory_manager", self._monitor, daemon=True)

def lazy_load(self, key: str, loader: Callable[[], Any]) -> Any:
"""Return cached object for *key*, loading it if necessary."""
Expand Down Expand Up @@ -117,8 +118,9 @@ def _monitor(self) -> None:
def shutdown(self) -> None:
"""Stop the monitoring thread."""
self._stop_event.set()
if self._thread.is_alive():
self._thread.join(timeout=1)
thread = thread_manager.unregister("memory_manager")
if thread and thread.is_alive():
thread.join(timeout=1)


def lazy_import(name: str) -> Any:
Expand Down