From 47ae902f264540c0f61851b76d188d60b4b53e3d Mon Sep 17 00:00:00 2001 From: realkim93 Date: Wed, 11 Feb 2026 22:31:40 +0900 Subject: [PATCH 1/3] fix(apps): cleanup orphaned app processes on daemon restart When the daemon restarts, app subprocesses from the previous session may still be running as orphans. The new AppManager now detects and terminates them during initialization to prevent conflicting robot commands. Co-Authored-By: Claude Opus 4.6 --- src/reachy_mini/apps/manager.py | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/reachy_mini/apps/manager.py b/src/reachy_mini/apps/manager.py index 05b2eb625..11a51b6cb 100644 --- a/src/reachy_mini/apps/manager.py +++ b/src/reachy_mini/apps/manager.py @@ -64,6 +64,86 @@ def __init__( self.desktop_app_daemon = desktop_app_daemon self.running_on_wireless = wireless_version self.daemon = daemon + self._cleanup_orphaned_apps() + + def _cleanup_orphaned_apps(self) -> None: + """Kill orphaned app subprocesses left by a previous daemon session. + + When the daemon restarts, app subprocesses from the previous session + may still be running. This method detects and terminates them to prevent + conflicts (e.g., two processes sending commands to the robot simultaneously). + """ + try: + venv_parent = str(local_common_venv._get_venv_parent_dir()) + except Exception: + return + + daemon_pid = os.getpid() + try: + daemon_children = { + c.pid for c in psutil.Process(daemon_pid).children(recursive=True) + } + except psutil.NoSuchProcess: + daemon_children = set() + + orphans: list[psutil.Process] = [] + for proc in psutil.process_iter(["pid", "exe", "cmdline"]): + try: + exe = proc.info.get("exe") or "" + cmdline = proc.info.get("cmdline") or [] + + # Must be under the venv parent directory + if not exe.startswith(venv_parent + os.sep): + continue + + # Must match *_venv/bin/python pattern (but NOT .venv — that's the daemon itself) + exe_parts = exe.split(os.sep) + is_app_venv_python = False + for i, part in enumerate(exe_parts): + if ( + part.endswith("_venv") + and part != ".venv" + and i + 1 < len(exe_parts) + and exe_parts[i + 1] == "bin" + ): + is_app_venv_python = True + break + if not is_app_venv_python: + continue + + # Must be a module execution (python -m ...) + if "-m" not in cmdline: + continue + + # Must NOT be the current daemon or its children + pid = proc.info["pid"] + if pid == daemon_pid or pid in daemon_children: + continue + + orphans.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + for proc in orphans: + pid = proc.pid + try: + self.logger.info(f"Terminating orphaned app process (PID {pid})") + proc.terminate() + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # Wait for graceful shutdown, then force-kill survivors + if orphans: + _, alive = psutil.wait_procs(orphans, timeout=3) + for proc in alive: + try: + self.logger.warning( + f"Force-killing orphaned app process (PID {proc.pid})" + ) + self._kill_process_tree(proc.pid) + proc.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue async def close(self) -> None: """Clean up the AppManager, stopping any running app.""" From 4f79af620d583d6e64f239d34e03c2ac57715c2f Mon Sep 17 00:00:00 2001 From: realkim93 Date: Wed, 11 Feb 2026 23:13:40 +0900 Subject: [PATCH 2/3] fix(apps): use cmdline[0] for orphan detection (exe resolves symlinks) psutil.exe() resolves symlinks, losing the original venv path. Fall back to cmdline[0] which preserves the invocation path and correctly matches *_venv/bin/python patterns. Co-Authored-By: Claude Opus 4.6 --- src/reachy_mini/apps/manager.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/reachy_mini/apps/manager.py b/src/reachy_mini/apps/manager.py index 11a51b6cb..aaa7c4371 100644 --- a/src/reachy_mini/apps/manager.py +++ b/src/reachy_mini/apps/manager.py @@ -92,19 +92,25 @@ def _cleanup_orphaned_apps(self) -> None: exe = proc.info.get("exe") or "" cmdline = proc.info.get("cmdline") or [] - # Must be under the venv parent directory - if not exe.startswith(venv_parent + os.sep): + # Use cmdline[0] as primary path — psutil.exe() resolves symlinks + # which loses the original venv path information + cmd_exe = cmdline[0] if cmdline else "" + + # Must be under the venv parent directory (check both exe and cmdline[0]) + prefix = venv_parent + os.sep + if not cmd_exe.startswith(prefix) and not exe.startswith(prefix): continue # Must match *_venv/bin/python pattern (but NOT .venv — that's the daemon itself) - exe_parts = exe.split(os.sep) + check_path = cmd_exe if cmd_exe.startswith(prefix) else exe + path_parts = check_path.split(os.sep) is_app_venv_python = False - for i, part in enumerate(exe_parts): + for i, part in enumerate(path_parts): if ( part.endswith("_venv") and part != ".venv" - and i + 1 < len(exe_parts) - and exe_parts[i + 1] == "bin" + and i + 1 < len(path_parts) + and path_parts[i + 1] == "bin" ): is_app_venv_python = True break From 0675dc07fe39fb8c4211d27d99c788ce498d1a21 Mon Sep 17 00:00:00 2001 From: realkim93 Date: Wed, 11 Feb 2026 23:22:20 +0900 Subject: [PATCH 3/3] fix(apps): support Windows path pattern in orphan detection Check for both bin/ (Linux/macOS) and Scripts/ (Windows) when matching app venv python executables. Co-Authored-By: Claude Opus 4.6 --- src/reachy_mini/apps/manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/reachy_mini/apps/manager.py b/src/reachy_mini/apps/manager.py index aaa7c4371..078babbb2 100644 --- a/src/reachy_mini/apps/manager.py +++ b/src/reachy_mini/apps/manager.py @@ -101,7 +101,10 @@ def _cleanup_orphaned_apps(self) -> None: if not cmd_exe.startswith(prefix) and not exe.startswith(prefix): continue - # Must match *_venv/bin/python pattern (but NOT .venv — that's the daemon itself) + # Must match *_venv/{bin,Scripts}/python pattern + # (but NOT .venv — that's the daemon itself) + # Linux/macOS: *_venv/bin/python + # Windows: *_venv/Scripts/python.exe check_path = cmd_exe if cmd_exe.startswith(prefix) else exe path_parts = check_path.split(os.sep) is_app_venv_python = False @@ -110,7 +113,7 @@ def _cleanup_orphaned_apps(self) -> None: part.endswith("_venv") and part != ".venv" and i + 1 < len(path_parts) - and path_parts[i + 1] == "bin" + and path_parts[i + 1] in ("bin", "Scripts") ): is_app_venv_python = True break