diff --git a/README.md b/README.md index db7ed52..0487b5e 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Or Git Bash: `./launch_lucy.sh` ## Using the Lucy launcher -After **`Launch`**, enable **Core + Control Panel** in the launcher. Once it is running, the **Lucy Control Panel is accessible in your browser at [http://localhost:4004](http://localhost:4004)** (or the next free port if 4004 is already taken). The launcher also shows the exact URL next to the Control Panel entry once it's up. +**Launch** starts a **tmux** session (Linux/macOS) and the **Lucy Control Center** TUI (`python -m launcher`, or `python launcher.py` / `./launch_lucy.sh`): | Key | Action | |-----|--------| diff --git a/config/repos.json b/config/repos.json index c378042..2fdf79f 100644 --- a/config/repos.json +++ b/config/repos.json @@ -24,13 +24,6 @@ "optional": true, "url_https": "https://github.com/micro-ROS/micro-ROS-Agent.git", "url_ssh": "git@github.com:micro-ROS/micro-ROS-Agent.git" - }, - { - "name": "audio_common", - "branch": "ros2", - "optional": true, - "url_https": "https://github.com/ros-drivers/audio_common.git", - "url_ssh": "git@github.com:ros-drivers/audio_common.git" } ] } diff --git a/docs/developer_lucy_packages.md b/docs/developer_lucy_packages.md index 4ac92d7..18a5b41 100644 --- a/docs/developer_lucy_packages.md +++ b/docs/developer_lucy_packages.md @@ -70,7 +70,7 @@ Developer CLI equivalents: | `Lucy.exe` | `./launch_lucy.sh` | | `Lucy.exe --cli build-only` | `./install.sh --build-only` | -Launch runs via Git Bash (`bash launch_lucy.sh`). Without tmux, the Control Center runs directly (`pixi run -- python launcher.py`). +Launch runs via Git Bash (`bash launch_lucy.sh`). Without tmux, the Control Center runs directly (`pixi run -- python -m launcher`). ### Workspace install (`install.sh`) diff --git a/docs/launcher_packages.md b/docs/launcher_packages.md index 937982d..6e96ea1 100644 --- a/docs/launcher_packages.md +++ b/docs/launcher_packages.md @@ -4,7 +4,7 @@ The launcher's configuration is entirely driven by the `launcher_config.json` fi ### Local launcher overrides (`config/launcher_config.json.local`) -To customize the launcher package list (add experimental tools, tweak commands, or hide entries) without editing the tracked `config/launcher_config.json`, create **`config/launcher_config.json.local`**. When present it is used instead of `launcher_config.json` by `launcher.py`, and it is gitignored so overrides are never committed. +To customize the launcher package list (add experimental tools, tweak commands, or hide entries) without editing the tracked `config/launcher_config.json`, create **`config/launcher_config.json.local`**. When present it is used instead of `launcher_config.json` by the launcher (`python -m launcher` or `python launcher.py`), and it is gitignored so overrides are never committed. Use the same structure as `launcher_config.json` — copy the file and edit as needed, or include only the `packages` entries you want to change if you prefer a full replacement (the local file replaces the tracked file entirely, it is not merged). @@ -40,16 +40,27 @@ Every package entry in `config/launcher_config.json` uses the following fields t | `command` | `string` or `object` | The shell command to execute.
- **For `"core"`**: The base command (e.g. `ros2 launch ...`).
- **For `"modifier"`**: The argument string appended to the core command.
- **For `"interface"` / `"tool"`**: A simple string executed in a new tmux window, or a complex object containing `"start"`, `"stop"`, and `"is_running"` shell commands for custom background handling (like the web control panel). | | `default_on` | `boolean` | If set to `true`, the package will be selected by default when the launcher boots up (currently unused as the launcher loads an empty initial state, but available for future functionality). | -### Under the Hood (`launcher.py` and `launch_lucy.sh`) +### Under the Hood (`launcher` and `launch_lucy.sh`) When you run `./launch_lucy.sh`: 1. It ensures the workspace is built (`install/setup.bash` exists). 2. On Linux/macOS with **host tmux**, it starts or attaches to a **tmux** session named `lucy_ws`. -3. The main window runs `pixi run -- python launcher.py` (Control Center TUI). +3. The main window runs `pixi run -- python -m launcher` (Control Center TUI). Package commands in other tmux windows are wrapped in `pixi run` so each pane gets RoboStack and the colcon overlay. Display and OpenGL-related variables from your shell and `.env` are forwarded into those panes. -When you apply changes in `launcher.py`: +When you apply changes in the launcher: - **Core + Modifiers:** The script takes the core command, appends all active modifier commands, and spins up a dedicated `core` tmux window. - **Interfaces / Tools:** The script spins up a new tmux window named after the package's `id` and executes its command via Pixi. Legacy complex `{start, stop, is_running}` objects are still supported for local overrides. + +### Stopping packages and exiting + +On **Linux and macOS**, services run in **tmux** windows. On **Windows** (Git Bash, no tmux), the same launcher TUI runs but process detection uses cross-platform logic; orphan cleanup is scoped to this workspace path so other projects are not affected. + +- **Q**, **X**, or **Esc** — prompts *Stop all processes and exit?*; on **y**, every running package is torn down, then workspace-scoped orphans are killed in a **single cleanup pass** (including **Gazebo `gz sim`**, which often appears as **ruby** in `top` because Pixi wraps it), then the tmux session ends. +- Short-lived **`gz sim server`** / **`gz sim gui`** processes often have no workspace path in their command line; cleanup also checks **cwd**, **Pixi/Conda env**, and **`GZ_SIM_*`** variables so they are still reaped safely. +- **Enter (Apply)** with boxes unticked — stops those packages asynchronously; orphan cleanup runs after each stop completes. +- **Ctrl+C** outside the TUI — also triggers a full stop on exit. + +Unticking a single package (e.g. Control Panel) sends **Ctrl+C** to its tmux window, waits briefly, removes the window, then reaps any workspace-scoped stragglers (`vite`, `gz sim`, `rosbridge`, etc.). diff --git a/launch_lucy.sh b/launch_lucy.sh index 812b9c8..51e6194 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -137,7 +137,7 @@ TMUX_SESSION="${LUCY_TMUX_SESSION:-lucy_ws}" case "$(uname -s)" in Linux|Darwin) if command -v tmux >/dev/null 2>&1; then - LAUNCH_CMD="cd \"${SCRIPT_DIR}\" && pixi run -- python launcher.py" + LAUNCH_CMD="cd \"${SCRIPT_DIR}\" && pixi run -- python -m launcher" export LUCY_TMUX_SESSION="$TMUX_SESSION" exec bash -c " set -e @@ -157,4 +157,4 @@ case "$(uname -s)" in esac # Git Bash / MSYS on Windows: no tmux — run the Control Center launcher directly. -exec pixi run -- python launcher.py +exec pixi run -- python -m launcher diff --git a/launcher.py b/launcher.py index 682c91d..fe93343 100644 --- a/launcher.py +++ b/launcher.py @@ -1,844 +1,7 @@ #!/usr/bin/env python3 +"""Compatibility entry point — prefer `python -m launcher`.""" -try: - import curses -except ImportError: - curses = None # Windows: no _curses; TUI helpers import-only on other platforms -import os -import sys -import subprocess -import threading -import time -import json -import shlex -from pathlib import Path - -WORKSPACE_ROOT = Path(__file__).resolve().parent -CONFIG_DIR = WORKSPACE_ROOT / "config" -DEFAULT_CONFIG_FILE = CONFIG_DIR / "launcher_config.json" -LOCAL_CONFIG_FILE = CONFIG_DIR / "launcher_config.json.local" -STATE_FILE = WORKSPACE_ROOT / ".lucy_launcher_modifiers.json" -SELECTION_FILE = WORKSPACE_ROOT / ".lucy_launcher_state.json" -TMUX_SESSION = os.environ.get("LUCY_TMUX_SESSION", "lucy_ws") -MIN_TERM_HEIGHT = 22 -MIN_TERM_WIDTH = 65 - -LOADING_TIMEOUT = 30 # seconds before LOADING transitions to CRASHED -STOPPING_TIMEOUT = 30 # seconds to show STOPPING before giving up - -_pkg_start_times = {} # pkg_id -> float, timestamp when start was issued -_intended_running = set() # pkg_ids that should be running (for crash detection) -_pkg_stop_times = {} # pkg_id -> float, timestamp when an async stop was issued - -# Tearing down the core window with `tmux kill-window` alone orphans the GUI -# processes ros2 launch spawned (notably `gz sim`), so they keep showing on the -# Native GUI. Send SIGINT first for a clean ros2 launch shutdown, wait for the -# sim/RViz to exit, force-kill any stragglers, then remove the window. -CORE_TEARDOWN = ( - f"tmux send-keys -t {TMUX_SESSION}:core C-c 2>/dev/null; " - "for _ in $(seq 1 12); do " - "pgrep -f '[g]z sim' >/dev/null 2>&1 || pgrep -x rviz2 >/dev/null 2>&1 || break; sleep 0.25; " - "done; " - "pkill -f '[g]z sim' 2>/dev/null; pkill -x rviz2 2>/dev/null; " - f"tmux kill-window -t {TMUX_SESSION}:core 2>/dev/null" -) - -def get_dev_mode(): - env_path = WORKSPACE_ROOT / ".env" - if not os.path.exists(env_path): - return False - with open(env_path, "r") as f: - for line in f: - if line.strip().startswith("DEV="): - return line.strip().split("=")[1].lower() == "true" - return False - -def load_workspace_env(): - """Load optional .env into os.environ (ports, GUI overrides, DEV=).""" - env_path = WORKSPACE_ROOT / ".env" - if not env_path.exists(): - return - with open(env_path, "r") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, val = line.partition("=") - key = key.strip() - if not key: - continue - val = val.strip().strip('"').strip("'") - os.environ[key] = val - -# Forward into tmux panes — GUI processes do not inherit the launcher session env. -_GUI_ENV_KEYS = ( - "DISPLAY", - "WAYLAND_DISPLAY", - "XAUTHORITY", - "XDG_RUNTIME_DIR", - "QT_QPA_PLATFORM", - "QT_XCB_GL_INTEGRATION", - "LIBGL_ALWAYS_SOFTWARE", - "MESA_LOADER_DRIVER_OVERRIDE", - "LIBGL_DRIVERS_PATH", - "LD_LIBRARY_PATH", - "LD_PRELOAD", - "__EGL_VENDOR_LIBRARY_FILENAMES", - "GZ_IP", -) - -def _gui_env_exports() -> str: - parts = [] - for key in _GUI_ENV_KEYS: - val = os.environ.get(key) - if val: - parts.append(f"export {key}={shlex.quote(val)}") - return "; ".join(parts) - -def is_in_tmux(): - return 'TMUX' in os.environ - - -def needs_tmux_session(): - """tmux launcher is used on Linux/macOS; Windows runs launcher.py directly.""" - return sys.platform not in ('win32', 'cygwin', 'msys') and os.name != 'nt' - -def _launcher_config_path(): - """config/launcher_config.json.local (gitignored) overrides the tracked file.""" - return str(LOCAL_CONFIG_FILE if LOCAL_CONFIG_FILE.exists() else DEFAULT_CONFIG_FILE) - -def load_config(): - config_path = _launcher_config_path() - if not os.path.exists(config_path): - raise FileNotFoundError(f"Configuration file not found at {config_path}") - with open(config_path, 'r') as f: - return json.load(f) - -def load_state(): - if not STATE_FILE.is_file(): - return {"modifiers": []} - with open(STATE_FILE, 'r') as f: - try: - return json.load(f) - except json.JSONDecodeError: - return {"modifiers": []} - -def save_state(state_data): - with open(STATE_FILE, 'w') as f: - json.dump(state_data, f) - -def load_selection(): - """Set of package ids the user last applied, or None if never saved.""" - if not os.path.exists(SELECTION_FILE): - return None - try: - with open(SELECTION_FILE) as f: - return set(json.load(f).get("selected", [])) - except (json.JSONDecodeError, OSError): - return None - -def save_selection(selected_ids): - """Persist the applied tick selection so it is restored on the next launch.""" - try: - with open(SELECTION_FILE, 'w') as f: - json.dump({"selected": sorted(selected_ids)}, f) - except OSError: - pass - -NIX_GL_ENV_SCRIPT = WORKSPACE_ROOT / "scripts" / "nix_gl_env.sh" - -def _nix_gl_source() -> str: - """Source hook for NixOS: prepend host Mesa before Pixi conda GL (no-op elsewhere).""" - if os.environ.get("LUCY_NIX_GL", "auto").lower() in ("0", "false", "no", "off"): - return "" - if not NIX_GL_ENV_SCRIPT.is_file(): - return "" - return f"source {shlex.quote(str(NIX_GL_ENV_SCRIPT))}; " - -def _pixi_workspace_script(user_cmd: str) -> str: - """Shell script body: workspace root + Pixi env (RoboStack + colcon overlay).""" - user_cmd = user_cmd.strip() - nix_gl = _nix_gl_source() - if user_cmd.startswith("pixi "): - pixi_part = user_cmd - elif nix_gl or any(op in user_cmd for op in (";", "&&", "||", "|", "&")): - pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}" - elif user_cmd.startswith("ros2 "): - pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}" - else: - pixi_part = f"pixi run -- {user_cmd}" - body = f"cd {WORKSPACE_ROOT} && {pixi_part}" - exports = _gui_env_exports() - if exports: - body = f"{exports}; {body}" - return body - -def _tmux_new_pixi_window(window: str, user_cmd: str, remain_on_exit: bool = False) -> str: - """Open a tmux window that runs user_cmd inside pixi run (tmux panes don't inherit pixi).""" - inner = f"bash -lc {shlex.quote(_pixi_workspace_script(user_cmd))}" - cmd = f"tmux new-window -d -t {TMUX_SESSION} -n {window} {inner}" - if remain_on_exit: - cmd += f"; tmux set-window-option -t {TMUX_SESSION}:{window} remain-on-exit on" - return cmd - -def _complex_package_start(pkg) -> str: - """Legacy complex {start,stop,is_running} entries — route through Pixi when possible.""" - if pkg.id == "control_panel": - return _tmux_new_pixi_window("control_panel", "pixi run panel-dev", remain_on_exit=True) - return pkg.command["start"] - -def run_shell_command(cmd, capture_output=False): - if capture_output: - return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode == 0 - else: - subprocess.run(cmd, shell=True) - -def run_shell_command_async(cmd): - """Fire a shell command without blocking the UI (daemon thread reaps the child). - - Used for stops so the TUI can show STOPPING while a slow shutdown runs.""" - def _target(): - try: - subprocess.run(cmd, shell=True) - except Exception: - pass - threading.Thread(target=_target, daemon=True).start() - -def _pane_exit_status(pkg_id): - """Exit code of the package's dead tmux pane, or None if it isn't dead. - remain-on-exit keeps the dead pane (and its output) so we can read the code: - 0 is a clean exit (STOPPED), anything else (incl. signal death) a crash (CRASHED).""" - out = subprocess.run( - f"tmux list-panes -t {TMUX_SESSION}:{pkg_id} -F '#{{pane_dead}}:#{{pane_dead_status}}' 2>/dev/null", - shell=True, capture_output=True, text=True, - ).stdout - for line in out.splitlines(): - dead, _, status = line.strip().partition(":") - if dead == "1": - try: - return int(status) - except ValueError: - return -1 # signal death reports no status; treat as a crash - return None - -class Package: - def __init__(self, data, running_modifiers): - self.id = data['id'] - self.name = data['name'] - self.description = data.get('description', '') - self.type = data['type'] - self.dependencies = data.get('dependencies', []) - self.conflicts = data.get('conflicts', []) - self.command = data.get('command', '') - self.lifecycle_hooks = data.get('lifecycle_hooks', {}) - self.selected = data.get('default_on', False) - # Robot-description package this entry selects (e.g. inmoov_urdf). When set, - # the entry is hidden unless that package is built, so only installed robots - # appear in the mutually-exclusive selector. - self.requires_pkg = data.get('requires_pkg') - # Render with a deeper indent so it reads as a sub-option of its dependency - # (e.g. headless under "... with Simulator"). - self.subitem = data.get('subitem', False) - # Optional shell probe that exits 0 only once the package is truly up. - # Without it, a package is considered "ready" the instant its window exists. - self.readiness_check = data.get('readiness_check') - self.readiness_timeout = data.get('readiness_timeout', LOADING_TIMEOUT) - # Legacy config fields (VNC removed); kept for JSON compat, unused. - self.runs_on_vnc = data.get('runs_on_vnc', False) - self.display_switch = data.get('display_switch', False) - # Access URL shown after [RUNNING] (e.g. control panel). May reference env - # vars as ${VAR} — expanded at render time. - self.url = data.get('url') - # Navigation hint for non-web packages (e.g. "Ctrl-B W" for tmux windows). - self.nav_hint = data.get('nav_hint', '') - - # is_running = window/process exists; ready = readiness probe passed; - # pane_dead = window kept open (remain-on-exit) after its process exited. - self.is_running = False - self.ready = False - self.pane_dead = False - self.pane_exit_status = None - self.update_running_status(running_modifiers) - - # Robot-package radios are mutually exclusive - if self.type == 'modifier' and self.requires_pkg: - self.selected = self.is_running - # Reflect running state as ticked — but not while it is being stopped, so an - # in-progress shutdown doesn't re-check the box the user just unticked. - elif self.is_running and self.id not in _pkg_stop_times: - self.selected = True - - def update_running_status(self, running_modifiers): - if self.is_complex_command(): - self.is_running = run_shell_command(self.command['is_running'], capture_output=True) - elif self.type == 'modifier': - self.is_running = self.id in running_modifiers - elif self.type == 'core': - self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) - if not self.is_running: - save_state({"modifiers": []}) - elif self.type in ['tool', 'interface']: - self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) - - # Derive readiness: only meaningful while the window/process exists. - if not self.is_running: - self.ready = False - elif self.readiness_check: - self.ready = run_shell_command(self.readiness_check, capture_output=True) - else: - self.ready = True - - # Window still up (remain-on-exit) but the process has exited. - # The exit code distinguishes a clean stop from a crash (see get_pkg_status). - self.pane_exit_status = _pane_exit_status(self.id) if self.is_running else None - self.pane_dead = self.pane_exit_status is not None - - def is_complex_command(self): - return isinstance(self.command, dict) - -def _env_enabled(var_name): - """True when a package has no env gate, or its `requires_env` var is truthy. - - Optional packages can gate on an env var via `requires_env` in launcher_config.""" - if not var_name: - return True - return os.environ.get(var_name, "").strip().lower() in ("1", "true", "yes") - - -def _ros_pkg_installed(pkg_name): - """True when a ROS package is built in the workspace overlay (install/). - - Used to gate the robot-package selector entries so only robots that are - actually built show up — mirrors lucy.launch.py's runtime discovery.""" - if not pkg_name: - return True - return (WORKSPACE_ROOT / "install" / pkg_name).is_dir() - -def _pkg_visible(pkg_config, dev_mode): - """Whether a package appears in the launcher: hidden when it is `dev_only` and - Developer Mode is off, when its `requires_env` gate isn't satisfied, or when a - `requires_pkg` robot package isn't built.""" - if pkg_config.get('dev_only') and not dev_mode: - return False - if not _ros_pkg_installed(pkg_config.get('requires_pkg')): - return False - return _env_enabled(pkg_config.get('requires_env')) - -class LauncherState: - def __init__(self, config_data): - running_state = load_state() - # Hide gated packages before building them, so their readiness probes don't run and they don't render. - dev_mode = get_dev_mode() - package_configs = [ - p for p in config_data['packages'] if _pkg_visible(p, dev_mode) - ] - self.packages = [Package(p, running_state['modifiers']) for p in package_configs] - self.package_map = {p.id: p for p in self.packages} - - def get_by_id(self, pkg_id): - return self.package_map.get(pkg_id) - - def refresh_status(self): - """Re-probe running/ready state for all packages without touching selected. - - Used by the poll timer so in-flight user tick changes aren't wiped out - between keypresses (LauncherState.__init__ resets selected to default_on).""" - running_state = load_state() - for pkg in self.packages: - pkg.update_running_status(running_state['modifiers']) - - def _enable(self, pkg): - """Tick a package, clearing anything it conflicts with first.""" - for conflict_id in pkg.conflicts: - conflict_pkg = self.get_by_id(conflict_id) - if conflict_pkg and conflict_pkg.selected: - conflict_pkg.selected = False - pkg.selected = True - - def _enable_with_deps(self, pkg): - """Tick a package and any of its (transitive) dependencies that are off, so a - sub-option pulls in its parent (e.g. headless ticks the simulator).""" - for dep_id in pkg.dependencies: - dep = self.get_by_id(dep_id) - if dep and not dep.selected: - self._enable_with_deps(dep) - self._enable(pkg) - - def _disable_with_dependents(self, pkg): - """Untick a package and any (transitive) dependents, so turning off a parent - also turns off its sub-options (e.g. unticking core drops the simulator and - headless together rather than leaving them orphaned).""" - for other_pkg in self.packages: - if pkg.id in other_pkg.dependencies and other_pkg.selected: - self._disable_with_dependents(other_pkg) - pkg.selected = False - - def toggle(self, pkg_id): - pkg = self.get_by_id(pkg_id) - if not pkg: - return None - if pkg_id in _pkg_stop_times and not pkg.selected: - return "Still stopping…" - if not pkg.selected: - # Ticking any option auto-enables its (transitive) dependencies instead - # of being blocked — e.g. the simulator/RViz/a robot pulls in core, and - # headless pulls in the simulator. - self._enable_with_deps(pkg) - else: - self._disable_with_dependents(pkg) - return None - -def get_pkg_status(pkg): - """Return one of: running, loading, crashed, stopped. - - ``pkg.is_running`` means the tmux window / process merely exists; ``pkg.ready`` - means its readiness probe passed (the stack is actually up). A package we - started (in ``_intended_running``) that isn't ready yet shows LOADING until its - timeout elapses, after which it is reported CRASHED. A package being shut down - (in ``_pkg_stop_times``) shows STOPPING until its process is gone. - """ - if pkg.id in _pkg_stop_times: - if not pkg.is_running: - _pkg_stop_times.pop(pkg.id, None) - return "stopped" - if time.time() - _pkg_stop_times[pkg.id] < STOPPING_TIMEOUT: - return "stopping" - _pkg_stop_times.pop(pkg.id, None) # gave up; fall through to real state - # Window left open by an exited process (remain-on-exit). - # Reported right away so the output can be read in tmux: exit 0 is a clean stop, else a crash. - if pkg.pane_dead: - _pkg_start_times.pop(pkg.id, None) - if pkg.pane_exit_status == 0: - _intended_running.discard(pkg.id) - return "stopped" - return "crashed" - if pkg.ready: - _pkg_start_times.pop(pkg.id, None) - return "running" - if pkg.id in _intended_running: - timeout = getattr(pkg, "readiness_timeout", LOADING_TIMEOUT) - started = _pkg_start_times.get(pkg.id) - if started is None: - if pkg.is_running: - _pkg_start_times[pkg.id] = time.time() - return "loading" - return "crashed" - if time.time() - started < timeout: - return "loading" - _pkg_start_times.pop(pkg.id, None) - return "crashed" - return "stopped" - -def _has_unapplied_changes(state): - for pkg in state.packages: - if pkg.id in _pkg_start_times or pkg.id in _pkg_stop_times: - continue - if pkg.selected != pkg.is_running: - return True - return False - -def _nav_hint(pkg): - """Navigation hint for packages without a web URL (e.g. tmux terminal windows).""" - if not pkg.nav_hint or not pkg.is_running: - return "" - return f"({pkg.nav_hint})" - -def _status_url(pkg): - """Expanded access URL for a package, or '' if it has none / an env var in it - is unset (so we never show a half-resolved 'localhost:${...}').""" - if not pkg.url: - return "" - expanded = os.path.expandvars(pkg.url) - if "${" in expanded or expanded.endswith(":"): - return "" - return expanded - -def _draw_pkg_row(stdscr, y, x, prefix, indent, checkbox, name, attr, status, hint="", url=""): - base = f"{prefix}{indent}{checkbox} {name}" - stdscr.addstr(y, x, base, attr) - col = x + len(base) - labels = { - "running": (" [RUNNING]", curses.color_pair(4)), - "loading": (" [LOADING]", curses.color_pair(1)), - "stopping": (" [STOPPING]", curses.color_pair(1)), - "crashed": (" [CRASHED]", curses.color_pair(2) | curses.A_BOLD), - "stopped": (" [STOPPED]", curses.A_DIM), - } - status_str, status_attr = labels.get(status, (" [STOPPED]", curses.A_DIM)) - try: - stdscr.addstr(y, col, status_str, status_attr) - col += len(status_str) - except curses.error: - pass - # Access URL after the status, shown only while actually running. - if url and status == "running": - text = f" ({url})" - try: - stdscr.addstr(y, col, text, curses.color_pair(3)) # cyan - col += len(text) - except curses.error: - pass - # Navigation hint after the status label (e.g. Ctrl-B W). - if hint: - text = f" {hint}" - try: - stdscr.addstr(y, col, text, curses.color_pair(3)) # cyan - col += len(text) - except curses.error: - pass - -def draw_too_small_message(stdscr): - h, w = stdscr.getmaxyx() - stdscr.clear() - message = "Please increase terminal size" - message2 = f"({MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} required)" - stdscr.addstr(h // 2 - 1, max(0, (w - len(message)) // 2), message, curses.A_BOLD) - stdscr.addstr(h // 2, max(0, (w - len(message2)) // 2), message2, curses.A_DIM) - stdscr.refresh() - -def draw_tui(stdscr, state, current_idx, error_msg, status_msg, unapplied=False): - h, w = stdscr.getmaxyx() - if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: - draw_too_small_message(stdscr) - return None - - stdscr.clear() - title = "Lucy Control Center" - stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) - stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit", curses.A_BOLD) - - if status_msg: - stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD) - elif error_msg: - stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) - elif unapplied: - stdscr.addstr(h - 2, 2, "Unapplied changes — press Enter to apply", curses.color_pair(1)) - - # Robot-package selectors are modifiers (their command is appended to the core - # launch), but get their own section so the robot choice reads as a distinct - # group rather than another core toggle. - robots = [p for p in state.packages if p.type == 'modifier' and p.requires_pkg] - cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier'] and not p.requires_pkg] - interfaces = [p for p in state.packages if p.type == 'interface'] - tools = [p for p in state.packages if p.type == 'tool'] - display_list = cores_and_mods + robots + interfaces + tools - - def draw_section(title, color, items, offset, gap=1, indent_all=False): - nonlocal row - stdscr.addstr(row, 2, title, curses.A_BOLD | color) - row += gap - for i, p in enumerate(items): - list_idx = offset + i - prefix = "> " if current_idx == list_idx else " " - checkbox = "[x]" if p.selected else "[ ]" - can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies) - attr = curses.A_NORMAL if can_enable else curses.A_DIM - if p.type == 'core': - attr |= curses.A_BOLD - if p.subitem: - indent = " " - elif indent_all or p.type == 'modifier': - indent = " " - else: - indent = "" - status = get_pkg_status(p) - hint = _nav_hint(p) - _draw_pkg_row(stdscr, row + i, 4, prefix, indent, checkbox, p.name, attr, - status, hint, _status_url(p)) - row += len(items) + 1 - - row = 2 - draw_section("Primary Launch Targets", curses.color_pair(1), cores_and_mods, 0, gap=2) - offset = len(cores_and_mods) - if robots: - draw_section("Robot", curses.color_pair(1), robots, offset, gap=1, indent_all=True) - offset += len(robots) - draw_section("Interfaces", curses.color_pair(3), interfaces, offset, gap=1) - offset += len(interfaces) - draw_section("Tools", curses.color_pair(3), tools, offset, gap=1) - - stdscr.refresh() - return display_list - -def apply_changes(state): - last_launched_window = None - core_pkg = state.get_by_id('core') - - # Check if core modifiers have changed - modifiers_changed = False - if core_pkg and core_pkg.selected: - selected_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.selected) - running_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.is_running) - if selected_modifier_ids != running_modifier_ids: - modifiers_changed = True - - # Force core restart if it's selected but modifiers changed. Tear down - # synchronously (kills gz sim / RViz, not just the window) so the old sim is - # gone before the new core launches. - if modifiers_changed and core_pkg and core_pkg.selected: - run_shell_command(CORE_TEARDOWN) - save_state({"modifiers": []}) - core_pkg.is_running = False - _pkg_start_times.pop('core', None) - _intended_running.discard('core') - for mod in state.packages: - if mod.type == 'modifier': - if mod.is_running and 'stop' in mod.lifecycle_hooks: - run_shell_command(mod.lifecycle_hooks['stop']) - mod.is_running = False - _pkg_start_times.pop(mod.id, None) - _intended_running.discard(mod.id) - - # First Pass: Stop processes that should be turned off (or were forced off). - # Stops run asynchronously so the TUI stays responsive and can show STOPPING - # while a slow shutdown runs; the package leaves STOPPING once its probe reports - # it gone. A modifier with no stop action is just marked stopped (its teardown - # happens via the core restart above). - for pkg in state.packages: - if not pkg.selected and pkg.is_running: - stopping = True - if pkg.is_complex_command(): - run_shell_command_async(pkg.command['stop']) - elif pkg.type == 'core': - run_shell_command_async(CORE_TEARDOWN) - save_state({"modifiers": []}) - for mod in state.packages: - if mod.type == 'modifier': - _pkg_start_times.pop(mod.id, None) - _intended_running.discard(mod.id) - elif pkg.type in ['tool', 'interface']: - run_shell_command_async(f"tmux kill-window -t {TMUX_SESSION}:{pkg.id} 2>/dev/null") - elif pkg.type == 'modifier' and 'stop' in pkg.lifecycle_hooks: - run_shell_command_async(pkg.lifecycle_hooks['stop']) - else: - stopping = False - _pkg_start_times.pop(pkg.id, None) - _intended_running.discard(pkg.id) - if stopping: - _pkg_stop_times[pkg.id] = time.time() - else: - pkg.is_running = False - - # Second Pass: Start processes that should be turned on (never re-launch one that is still shutting down). - # A crashed service (non-zero exit) is relaunched too, after reaping the dead window; a clean exit (STOPPED) is left alone. - for pkg in state.packages: - crashed = pkg.pane_dead and pkg.pane_exit_status != 0 - if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or crashed): - if pkg.pane_dead: - run_shell_command(f"tmux kill-window -t {TMUX_SESSION}:{pkg.id} 2>/dev/null") - pkg.pane_dead = False - pkg.is_running = False - if pkg.is_complex_command(): - run_shell_command(_complex_package_start(pkg)) - # Keep a crashed service's window open with its error (see core). - if pkg.readiness_check: - run_shell_command(f"tmux set-window-option -t {TMUX_SESSION}:{pkg.id} remain-on-exit on 2>/dev/null") - _pkg_start_times[pkg.id] = time.time() - _intended_running.add(pkg.id) - elif pkg.type == 'core': - base_cmd = pkg.command - selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] - modifier_args = [p.command for p in selected_modifiers] - modifier_ids = [p.id for p in selected_modifiers] - full_cmd = f"{base_cmd} {' '.join(modifier_args)}" - run_shell_command(_tmux_new_pixi_window("core", full_cmd, remain_on_exit=True)) - save_state({"modifiers": modifier_ids}) - _pkg_start_times[pkg.id] = time.time() - _intended_running.add(pkg.id) - for mod in selected_modifiers: - _pkg_start_times[mod.id] = time.time() - _intended_running.add(mod.id) - elif pkg.type in ['tool', 'interface']: - if pkg.type == 'interface': - run_shell_command(_tmux_new_pixi_window(pkg.id, pkg.command, remain_on_exit=True)) - else: - run_shell_command( - _tmux_new_pixi_window( - pkg.id, - f'{pkg.command}; echo "--- Process finished, press any key to close ---"; read', - ) - ) - # Only auto-switch to tool windows (e.g. console), not interfaces which manage their own terminal visibility (lucy_cli, control_panel). - if pkg.type == 'tool': - last_launched_window = pkg.id - _pkg_start_times[pkg.id] = time.time() - _intended_running.add(pkg.id) - pkg.is_running = True - - if last_launched_window: - run_shell_command(f"tmux select-window -t {TMUX_SESSION}:{last_launched_window}") - -def restore_selection(state): - """Pre-tick packages from the last applied selection (.lucy_launcher_state.json).""" - saved = load_selection() - if saved is None: - return - robots = [p for p in state.packages if p.requires_pkg] - for pkg in state.packages: - if pkg.requires_pkg: - continue - pkg.selected = pkg.id in saved - # Robot radios: exactly one ticked when saved names a robot. - chosen = next((p for p in robots if p.id in saved), None) - if chosen is not None: - for pkg in robots: - pkg.selected = pkg is chosen - -def default_robot_selection(state): - """Auto-tick a robot-package modifier when none is selected yet (mirrors - lucy.launch.py: sole installed robot, or inmoov_urdf when several are built). - Gated on core being selected so it can be applied alongside core.""" - core = state.get_by_id('core') - robots = [p for p in state.packages if p.requires_pkg] - if not robots or not (core and core.selected): - return - if any(p.selected for p in robots): - return - if len(robots) == 1: - robots[0].selected = True - return - inmoov = state.get_by_id('robot_inmoov') - if inmoov: - inmoov.selected = True - -def main(stdscr): - curses.curs_set(0) - stdscr.nodelay(0) - stdscr.timeout(-1) - curses.start_color() - curses.use_default_colors() - - if curses.has_colors(): - curses.init_pair(1, curses.COLOR_YELLOW, -1) - curses.init_pair(2, curses.COLOR_RED, -1) - curses.init_pair(3, curses.COLOR_CYAN, -1) - curses.init_pair(4, curses.COLOR_GREEN, -1) - - state = LauncherState(load_config()) - restore_selection(state) - default_robot_selection(state) - current_idx = 0 - error_msg = None - status_msg = None - status_msg_until = 0.0 - - if not get_dev_mode(): - # Production: always ensure core + control panel, then start everything - # selected (including any restored selection). - core_pkg = state.get_by_id('core') - lcp_pkg = state.get_by_id('control_panel') - if core_pkg: - core_pkg.selected = True - if lcp_pkg: - lcp_pkg.selected = True - default_robot_selection(state) - apply_changes(state) - save_selection({p.id for p in state.packages if p.selected}) - status_msg = "Starting default services for production mode..." - status_msg_until = time.time() + 3.0 - state.refresh_status() - - while True: - try: - if status_msg and time.time() >= status_msg_until: - status_msg = None - display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg, _has_unapplied_changes(state)) - error_msg = None - - if display_list is None: - # If display_list is None, it means the screen is too small. - # We switch to non-blocking getch to poll for resize events - stdscr.nodelay(1) - stdscr.timeout(100) - key = stdscr.getch() - if key != curses.KEY_RESIZE: - time.sleep(0.1) - continue - else: - # Poll fast while something is still coming up, slow once everything - # we launched is up (so a later crash still surfaces), and block - # entirely when nothing is running. Re-read state on each tick. - if _pkg_start_times or _pkg_stop_times: - poll_ms = 1000 - elif _intended_running: - poll_ms = 5000 - else: - poll_ms = None - if poll_ms is None: - stdscr.nodelay(0) - stdscr.timeout(-1) - else: - stdscr.nodelay(1) - stdscr.timeout(poll_ms) - key = stdscr.getch() - if key == -1: - state.refresh_status() - continue - - if key == curses.KEY_RESIZE: - continue - - if key == curses.KEY_UP: - current_idx = (current_idx - 1) % len(display_list) - elif key == curses.KEY_DOWN: - current_idx = (current_idx + 1) % len(display_list) - elif key == ord(' '): - pkg_to_toggle = display_list[current_idx] - error_msg = state.toggle(pkg_to_toggle.id) - elif key == ord('\n'): - apply_changes(state) - save_selection({p.id for p in state.packages if p.selected}) - status_msg = "Configuration Applied!" - status_msg_until = time.time() + 2.0 - state.refresh_status() - elif key in [ord('x'), ord('X')]: - h, w = stdscr.getmaxyx() - stdscr.addstr(h - 2, 2, "Stop all processes and exit? (y/n)", curses.A_BOLD | curses.color_pair(2)) - stdscr.refresh() - confirm_key = stdscr.getch() - if confirm_key in [ord('y'), ord('Y')]: - return "ExitWorkspace", state - elif key in [ord('q'), ord('Q'), 27]: - return "Quit", None - - except curses.error: - # This will catch errors from addstr if the window is resized - # between the size check and the drawing. - time.sleep(0.1) - continue +from launcher.__main__ import run if __name__ == "__main__": - if curses is None: - print("Error: launcher TUI requires curses (not available on this platform).", file=sys.stderr) - sys.exit(1) - load_workspace_env() - if needs_tmux_session() and not is_in_tmux(): - print(f"Error: launcher.py must run inside the {TMUX_SESSION} tmux session (./launch_lucy.sh).", file=sys.stderr) - sys.exit(1) - os.chdir(WORKSPACE_ROOT) - - status, state = None, None - try: - status, state = curses.wrapper(main) - except Exception as e: - # Clean up curses on any exception - curses.endwin() - print(f"An unexpected error occurred: {e}", file=sys.stderr) - sys.exit(1) - - if status == "ExitWorkspace": - print("\nStopping all processes and exiting workspace...") - if state: - for pkg in state.packages: - if pkg.is_complex_command(): - run_shell_command(pkg.command['stop']) - elif 'stop' in pkg.lifecycle_hooks: - run_shell_command(pkg.lifecycle_hooks['stop']) - if STATE_FILE.is_file(): - STATE_FILE.unlink() - if needs_tmux_session(): - print("Terminating tmux session...") - time.sleep(0.5) - run_shell_command(f"tmux kill-session -t {TMUX_SESSION} 2>/dev/null") - else: - pass + run() diff --git a/launcher/__init__.py b/launcher/__init__.py new file mode 100644 index 0000000..8681656 --- /dev/null +++ b/launcher/__init__.py @@ -0,0 +1,185 @@ +"""Lucy workspace launcher — modular package with backward-compatible exports.""" + +from .apply import apply_changes, default_robot_selection, restore_selection, stop_all_packages +from .config import ( + get_dev_mode, + load_config, + load_selection, + load_state, + load_workspace_env, + save_selection, + save_state, +) +from .constants import ( + CONFIG_DIR, + DEFAULT_CONFIG_FILE, + LOADING_TIMEOUT, + LOCAL_CONFIG_FILE, + LUCY_WS_MARKER, + MIN_TERM_HEIGHT, + MIN_TERM_WIDTH, + NIX_GL_ENV_SCRIPT, + SELECTION_FILE, + STATE_FILE, + STOPPING_TIMEOUT, + TMUX_SESSION, + WORKSPACE_ROOT, + _CONTROL_PANEL_DIR, + _GUI_ENV_KEYS, + _ORPHAN_CLEANUP_DEBOUNCE, + _PIXI_ENV_MARKER, + _norm_path, +) +from .package import Package, _env_enabled, _pkg_visible, _ros_pkg_installed +from .platform import ( + _path_in_text, + _process_workspace_markers, + _read_proc_cwd, + _read_proc_environ, + _read_proc_environ_darwin, + _read_proc_exe, + path_in_text, + process_workspace_markers, + read_proc_cwd, + read_proc_environ, + read_proc_exe, +) +from .process import ( + _child_pids, + _finish_teardown, + _in_lucy_workspace, + _is_gz_sim_cmdline, + _is_vite_orphan, + _iter_processes, + _kill_pid, + _kill_process_tree, + _matches_orphan_signature, + _schedule_orphan_cleanup, + cleanup_lucy_orphan_processes, + find_lucy_orphan_pids, + is_lucy_orphan, + is_lucy_orphan_cmdline, + set_orphan_preserve_windows, + wait_for_orphans_gone, +) +from .shell import ( + _complex_package_start, + _gui_env_exports, + _nix_gl_source, + _pane_exit_status, + _pixi_workspace_script, + _tmux_new_pixi_window, + run_shell_command, + run_shell_command_async, + run_teardown_async, +) +from .state import ( + LauncherState, + _has_unapplied_changes, + _intended_running, + _nav_hint, + _pkg_start_times, + _pkg_stop_times, + _status_url, + get_pkg_status, +) +from .tmux import ( + CORE_TEARDOWN, + _core_teardown_shell, + _stop_core_tmux, + _stop_tmux_window, + _window_teardown_shell, + is_in_tmux, + needs_tmux_session, +) +from .tui import draw_too_small_message, draw_tui, main + +__all__ = [ + "apply_changes", + "cleanup_lucy_orphan_processes", + "CORE_TEARDOWN", + "CONFIG_DIR", + "DEFAULT_CONFIG_FILE", + "draw_too_small_message", + "draw_tui", + "find_lucy_orphan_pids", + "get_dev_mode", + "get_pkg_status", + "is_in_tmux", + "is_lucy_orphan", + "is_lucy_orphan_cmdline", + "LauncherState", + "LOADING_TIMEOUT", + "LOCAL_CONFIG_FILE", + "LUCY_WS_MARKER", + "load_config", + "load_selection", + "load_state", + "load_workspace_env", + "main", + "MIN_TERM_HEIGHT", + "MIN_TERM_WIDTH", + "needs_tmux_session", + "NIX_GL_ENV_SCRIPT", + "Package", + "path_in_text", + "process_workspace_markers", + "read_proc_cwd", + "read_proc_environ", + "read_proc_exe", + "restore_selection", + "default_robot_selection", + "run_shell_command", + "run_shell_command_async", + "run_teardown_async", + "save_selection", + "save_state", + "SELECTION_FILE", + "set_orphan_preserve_windows", + "STATE_FILE", + "STOPPING_TIMEOUT", + "stop_all_packages", + "TMUX_SESSION", + "wait_for_orphans_gone", + "WORKSPACE_ROOT", + "_CONTROL_PANEL_DIR", + "_GUI_ENV_KEYS", + "_ORPHAN_CLEANUP_DEBOUNCE", + "_PIXI_ENV_MARKER", + "_child_pids", + "_complex_package_start", + "_core_teardown_shell", + "_env_enabled", + "_finish_teardown", + "_gui_env_exports", + "_has_unapplied_changes", + "_in_lucy_workspace", + "_intended_running", + "_is_gz_sim_cmdline", + "_is_vite_orphan", + "_iter_processes", + "_kill_pid", + "_kill_process_tree", + "_matches_orphan_signature", + "_nav_hint", + "_nix_gl_source", + "_norm_path", + "_pane_exit_status", + "_path_in_text", + "_pixi_workspace_script", + "_pkg_start_times", + "_pkg_stop_times", + "_pkg_visible", + "_process_workspace_markers", + "_read_proc_cwd", + "_read_proc_environ", + "_read_proc_environ_darwin", + "_read_proc_exe", + "_ros_pkg_installed", + "_schedule_orphan_cleanup", + "_status_url", + "_stop_core_tmux", + "_stop_tmux_window", + "_tmux_new_pixi_window", + "_window_teardown_shell", +] diff --git a/launcher/__main__.py b/launcher/__main__.py new file mode 100644 index 0000000..6699f7f --- /dev/null +++ b/launcher/__main__.py @@ -0,0 +1,63 @@ +"""Entry point for `python -m launcher`.""" + +import os +import sys + +try: + import curses +except ImportError: + curses = None + +from .config import load_config, load_workspace_env +from .constants import STATE_FILE, TMUX_SESSION, WORKSPACE_ROOT +from .apply import stop_all_packages +from .shell import run_shell_command +from .state import LauncherState +from .tmux import is_in_tmux, needs_tmux_session +from .tui import main + + +def run(): + if curses is None: + print( + "Error: launcher TUI requires curses (not available on this platform).", + file=sys.stderr, + ) + sys.exit(1) + load_workspace_env() + if needs_tmux_session() and not is_in_tmux(): + print( + f"Error: launcher must run inside the {TMUX_SESSION} tmux session (./launch_lucy.sh).", + file=sys.stderr, + ) + sys.exit(1) + os.chdir(WORKSPACE_ROOT) + + status, state = None, None + try: + status, state = curses.wrapper(main) + except KeyboardInterrupt: + curses.endwin() + print("\nStopping all processes and exiting workspace...") + status = "ExitWorkspace" + try: + state = LauncherState(load_config()) + except Exception: + state = None + except Exception as e: + curses.endwin() + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) + + if status == "ExitWorkspace": + print("\nStopping all processes and exiting workspace...") + stop_all_packages(state) + if STATE_FILE.is_file(): + STATE_FILE.unlink() + if needs_tmux_session(): + print("Terminating tmux session...") + run_shell_command(f"tmux kill-session -t {TMUX_SESSION} 2>/dev/null") + + +if __name__ == "__main__": + run() diff --git a/launcher/apply.py b/launcher/apply.py new file mode 100644 index 0000000..b0abb11 --- /dev/null +++ b/launcher/apply.py @@ -0,0 +1,226 @@ +"""Apply package selection changes and full teardown.""" + +import time + +from .config import load_selection, save_selection +from .constants import TMUX_SESSION +from .shell import ( + run_shell_command_async, + run_teardown_async, +) +from .state import ( + _intended_running, + _pkg_start_times, + _pkg_stop_times, +) + + +def _package_needs_vite_preserve(pkg) -> bool: + """True when this package's readiness probe or command targets a Vite dev server.""" + if pkg.readiness_check and "vite" in pkg.readiness_check.lower(): + return True + if isinstance(pkg.command, str): + cmd = pkg.command + elif isinstance(pkg.command, dict): + cmd = str(pkg.command.get("start", "")) + else: + cmd = "" + cl = cmd.lower() + return "vite" in cl or "panel-dev" in cl + + +def _orphan_preserve_from_state(state): + """Tmux windows and Vite protection for services that should survive a core restart.""" + windows = set() + protect_vite = False + for pkg in state.packages: + if not pkg.selected or pkg.id == "core": + continue + if pkg.id in _pkg_stop_times: + continue + if not pkg.is_running: + continue + if pkg.type not in ("interface", "tool") and not pkg.is_complex_command(): + continue + windows.add(pkg.id) + if _package_needs_vite_preserve(pkg): + protect_vite = True + return windows, protect_vite + + +def stop_all_packages(state): + """Synchronously tear down every running package and orphan-prone processes.""" + import launcher + + launcher.set_orphan_preserve_windows([], protect_vite=False) + if state: + state.refresh_status() + for pkg in state.packages: + if pkg.id == "core" or not pkg.is_running: + continue + if pkg.is_complex_command(): + launcher.run_shell_command(pkg.command["stop"]) + elif pkg.type in ("tool", "interface"): + launcher._stop_tmux_window(pkg.id) + elif pkg.type == "modifier" and "stop" in pkg.lifecycle_hooks: + launcher.run_shell_command(pkg.lifecycle_hooks["stop"]) + core = state.get_by_id("core") + if core and core.is_running: + launcher._stop_core_tmux() + launcher.save_state({"modifiers": []}) + launcher._finish_teardown() + + +def apply_changes(state): + import launcher + + preserve_windows, protect_vite = _orphan_preserve_from_state(state) + launcher.set_orphan_preserve_windows(preserve_windows, protect_vite=protect_vite) + + last_launched_window = None + core_pkg = state.get_by_id("core") + + modifiers_changed = False + if core_pkg and core_pkg.selected: + selected_modifier_ids = set( + p.id for p in state.packages if p.type == "modifier" and p.selected + ) + running_modifier_ids = set( + p.id for p in state.packages if p.type == "modifier" and p.is_running + ) + if selected_modifier_ids != running_modifier_ids: + modifiers_changed = True + + if modifiers_changed and core_pkg and core_pkg.selected: + launcher._stop_core_tmux() + launcher._finish_teardown( + preserve_package_windows=frozenset(preserve_windows), + protect_vite=protect_vite, + ) + launcher.save_state({"modifiers": []}) + core_pkg.is_running = False + _pkg_start_times.pop("core", None) + _intended_running.discard("core") + for mod in state.packages: + if mod.type == "modifier": + if mod.is_running and "stop" in mod.lifecycle_hooks: + launcher.run_shell_command(mod.lifecycle_hooks["stop"]) + mod.is_running = False + _pkg_start_times.pop(mod.id, None) + _intended_running.discard(mod.id) + + for pkg in state.packages: + if not pkg.selected and pkg.is_running: + stopping = True + if pkg.is_complex_command(): + launcher.run_shell_command_async(pkg.command["stop"], schedule_cleanup=True) + elif pkg.type == "core": + run_teardown_async(launcher._stop_core_tmux) + launcher.save_state({"modifiers": []}) + for mod in state.packages: + if mod.type == "modifier": + _pkg_start_times.pop(mod.id, None) + _intended_running.discard(mod.id) + elif pkg.type in ("tool", "interface"): + run_teardown_async(lambda pid=pkg.id: launcher._stop_tmux_window(pid)) + elif pkg.type == "modifier" and "stop" in pkg.lifecycle_hooks: + launcher.run_shell_command_async(pkg.lifecycle_hooks["stop"], schedule_cleanup=True) + else: + stopping = False + _pkg_start_times.pop(pkg.id, None) + _intended_running.discard(pkg.id) + if stopping: + _pkg_stop_times[pkg.id] = time.time() + else: + pkg.is_running = False + + for pkg in state.packages: + crashed = pkg.pane_dead and pkg.pane_exit_status != 0 + if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or crashed): + if pkg.pane_dead: + launcher.run_shell_command( + f"tmux kill-window -t {TMUX_SESSION}:{pkg.id} 2>/dev/null" + ) + pkg.pane_dead = False + pkg.is_running = False + if pkg.is_complex_command(): + launcher.run_shell_command(launcher._complex_package_start(pkg)) + if pkg.readiness_check: + launcher.run_shell_command( + f"tmux set-window-option -t {TMUX_SESSION}:{pkg.id} remain-on-exit on 2>/dev/null" + ) + _pkg_start_times[pkg.id] = time.time() + _intended_running.add(pkg.id) + elif pkg.type == "core": + base_cmd = pkg.command + selected_modifiers = [ + p for p in state.packages if p.type == "modifier" and p.selected + ] + modifier_args = [p.command for p in selected_modifiers] + modifier_ids = [p.id for p in selected_modifiers] + full_cmd = f"{base_cmd} {' '.join(modifier_args)}" + launcher.run_shell_command( + launcher._tmux_new_pixi_window("core", full_cmd, remain_on_exit=True) + ) + launcher.save_state({"modifiers": modifier_ids}) + _pkg_start_times[pkg.id] = time.time() + _intended_running.add(pkg.id) + for mod in selected_modifiers: + _pkg_start_times[mod.id] = time.time() + _intended_running.add(mod.id) + elif pkg.type in ("tool", "interface"): + if pkg.type == "interface": + launcher.run_shell_command( + launcher._tmux_new_pixi_window( + pkg.id, pkg.command, remain_on_exit=True + ) + ) + else: + launcher.run_shell_command( + launcher._tmux_new_pixi_window( + pkg.id, + f'{pkg.command}; echo "--- Process finished, press any key to close ---"; read', + ) + ) + if pkg.type == "tool": + last_launched_window = pkg.id + _pkg_start_times[pkg.id] = time.time() + _intended_running.add(pkg.id) + pkg.is_running = True + + if last_launched_window: + launcher.run_shell_command( + f"tmux select-window -t {TMUX_SESSION}:{last_launched_window}" + ) + + +def restore_selection(state): + """Pre-tick packages from the last applied selection (.lucy_launcher_state.json).""" + saved = load_selection() + if saved is None: + return + robots = [p for p in state.packages if p.requires_pkg] + for pkg in state.packages: + if pkg.requires_pkg: + continue + pkg.selected = pkg.id in saved + chosen = next((p for p in robots if p.id in saved), None) + if chosen is not None: + for pkg in robots: + pkg.selected = pkg is chosen + + +def default_robot_selection(state): + """Auto-tick a robot-package modifier when none is selected yet.""" + core = state.get_by_id("core") + robots = [p for p in state.packages if p.requires_pkg] + if not robots or not (core and core.selected): + return + if any(p.selected for p in robots): + return + if len(robots) == 1: + robots[0].selected = True + return + inmoov = state.get_by_id("robot_inmoov") + if inmoov: + inmoov.selected = True diff --git a/launcher/config.py b/launcher/config.py new file mode 100644 index 0000000..2bd5227 --- /dev/null +++ b/launcher/config.py @@ -0,0 +1,91 @@ +"""Configuration, state persistence, and workspace environment loading.""" + +import json +import os + +from .constants import ( + DEFAULT_CONFIG_FILE, + LOCAL_CONFIG_FILE, + SELECTION_FILE, + STATE_FILE, + WORKSPACE_ROOT, +) + + +def get_dev_mode(): + env_path = WORKSPACE_ROOT / ".env" + if not os.path.exists(env_path): + return False + with open(env_path, "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + + +def load_workspace_env(): + """Load optional .env into os.environ (ports, GUI overrides, DEV=).""" + import launcher + + env_path = launcher.WORKSPACE_ROOT / ".env" + if not env_path.exists(): + return + with open(env_path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + if not key: + continue + val = val.strip().strip('"').strip("'") + os.environ[key] = val + + +def _launcher_config_path(): + """config/launcher_config.json.local (gitignored) overrides the tracked file.""" + return str(LOCAL_CONFIG_FILE if LOCAL_CONFIG_FILE.exists() else DEFAULT_CONFIG_FILE) + + +def load_config(): + config_path = _launcher_config_path() + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found at {config_path}") + with open(config_path, "r") as f: + return json.load(f) + + +def load_state(): + if not STATE_FILE.is_file(): + return {"modifiers": []} + with open(STATE_FILE, "r") as f: + try: + return json.load(f) + except json.JSONDecodeError: + return {"modifiers": []} + + +def save_state(state_data): + with open(STATE_FILE, "w") as f: + json.dump(state_data, f) + + +def load_selection(): + """Set of package ids the user last applied, or None if never saved.""" + if not os.path.exists(SELECTION_FILE): + return None + try: + with open(SELECTION_FILE) as f: + return set(json.load(f).get("selected", [])) + except (json.JSONDecodeError, OSError): + return None + + +def save_selection(selected_ids): + """Persist the applied tick selection so it is restored on the next launch.""" + try: + with open(SELECTION_FILE, "w") as f: + json.dump({"selected": sorted(selected_ids)}, f) + except OSError: + pass diff --git a/launcher/constants.py b/launcher/constants.py new file mode 100644 index 0000000..47d2027 --- /dev/null +++ b/launcher/constants.py @@ -0,0 +1,52 @@ +"""Workspace paths, timeouts, and markers for the Lucy launcher.""" + +import os +from pathlib import Path + +WORKSPACE_ROOT = Path(__file__).resolve().parent.parent +CONFIG_DIR = WORKSPACE_ROOT / "config" +DEFAULT_CONFIG_FILE = CONFIG_DIR / "launcher_config.json" +LOCAL_CONFIG_FILE = CONFIG_DIR / "launcher_config.json.local" +STATE_FILE = WORKSPACE_ROOT / ".lucy_launcher_modifiers.json" +SELECTION_FILE = WORKSPACE_ROOT / ".lucy_launcher_state.json" +TMUX_SESSION = os.environ.get("LUCY_TMUX_SESSION", "lucy_ws") +MIN_TERM_HEIGHT = 22 +MIN_TERM_WIDTH = 65 + +LOADING_TIMEOUT = 30 # seconds before LOADING transitions to CRASHED +STOPPING_TIMEOUT = 30 # seconds to show STOPPING before giving up + +LUCY_WS_MARKER = str(WORKSPACE_ROOT) + +def _norm_path(s: str) -> str: + return s.replace("\\", "/") + + +PIXI_ENV_MARKER = f"{_norm_path(LUCY_WS_MARKER)}/.pixi/" +CONTROL_PANEL_DIR = f"{_norm_path(LUCY_WS_MARKER)}/src/lucy_control_panel" +ORPHAN_CLEANUP_DEBOUNCE = 1.5 + +NIX_GL_ENV_SCRIPT = WORKSPACE_ROOT / "scripts" / "nix_gl_env.sh" + +# Forward into tmux panes — GUI processes do not inherit the launcher session env. +GUI_ENV_KEYS = ( + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_RUNTIME_DIR", + "QT_QPA_PLATFORM", + "QT_XCB_GL_INTEGRATION", + "LIBGL_ALWAYS_SOFTWARE", + "MESA_LOADER_DRIVER_OVERRIDE", + "LIBGL_DRIVERS_PATH", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "__EGL_VENDOR_LIBRARY_FILENAMES", + "GZ_IP", +) + +# Back-compat aliases used by tests and internal modules. +_PIXI_ENV_MARKER = PIXI_ENV_MARKER +_CONTROL_PANEL_DIR = CONTROL_PANEL_DIR +_ORPHAN_CLEANUP_DEBOUNCE = ORPHAN_CLEANUP_DEBOUNCE +_GUI_ENV_KEYS = GUI_ENV_KEYS diff --git a/launcher/package.py b/launcher/package.py new file mode 100644 index 0000000..5be4489 --- /dev/null +++ b/launcher/package.py @@ -0,0 +1,96 @@ +"""Package model and visibility helpers.""" + +import os + +from .constants import LOADING_TIMEOUT, WORKSPACE_ROOT +from .config import save_state +from .shell import run_shell_command, _pane_exit_status + + +def _env_enabled(var_name): + """True when a package has no env gate, or its `requires_env` var is truthy.""" + if not var_name: + return True + return os.environ.get(var_name, "").strip().lower() in ("1", "true", "yes") + + +def _ros_pkg_installed(pkg_name): + """True when a ROS package is built in the workspace overlay (install/).""" + if not pkg_name: + return True + return (WORKSPACE_ROOT / "install" / pkg_name).is_dir() + + +def _pkg_visible(pkg_config, dev_mode): + """Whether a package appears in the launcher.""" + if pkg_config.get("dev_only") and not dev_mode: + return False + if not _ros_pkg_installed(pkg_config.get("requires_pkg")): + return False + return _env_enabled(pkg_config.get("requires_env")) + + +class Package: + def __init__(self, data, running_modifiers): + self.id = data["id"] + self.name = data["name"] + self.description = data.get("description", "") + self.type = data["type"] + self.dependencies = data.get("dependencies", []) + self.conflicts = data.get("conflicts", []) + self.command = data.get("command", "") + self.lifecycle_hooks = data.get("lifecycle_hooks", {}) + self.selected = data.get("default_on", False) + self.requires_pkg = data.get("requires_pkg") + self.subitem = data.get("subitem", False) + self.readiness_check = data.get("readiness_check") + self.readiness_timeout = data.get("readiness_timeout", LOADING_TIMEOUT) + self.runs_on_vnc = data.get("runs_on_vnc", False) + self.display_switch = data.get("display_switch", False) + self.url = data.get("url") + self.nav_hint = data.get("nav_hint", "") + + self.is_running = False + self.ready = False + self.pane_dead = False + self.pane_exit_status = None + self.update_running_status(running_modifiers) + + if self.type == "modifier" and self.requires_pkg: + self.selected = self.is_running + else: + from .state import _pkg_stop_times + + if self.is_running and self.id not in _pkg_stop_times: + self.selected = True + + def update_running_status(self, running_modifiers): + if self.is_complex_command(): + self.is_running = run_shell_command(self.command["is_running"], capture_output=True) + elif self.type == "modifier": + self.is_running = self.id in running_modifiers + elif self.type == "core": + self.is_running = run_shell_command( + f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", + capture_output=True, + ) + if not self.is_running: + save_state({"modifiers": []}) + elif self.type in ("tool", "interface"): + self.is_running = run_shell_command( + f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", + capture_output=True, + ) + + if not self.is_running: + self.ready = False + elif self.readiness_check: + self.ready = run_shell_command(self.readiness_check, capture_output=True) + else: + self.ready = True + + self.pane_exit_status = _pane_exit_status(self.id) if self.is_running else None + self.pane_dead = self.pane_exit_status is not None + + def is_complex_command(self): + return isinstance(self.command, dict) diff --git a/launcher/platform.py b/launcher/platform.py new file mode 100644 index 0000000..78b2c26 --- /dev/null +++ b/launcher/platform.py @@ -0,0 +1,181 @@ +"""Platform-specific process introspection (Linux, macOS, Windows).""" + +import os +import subprocess +import sys + +from .constants import ( + LUCY_WS_MARKER, + PIXI_ENV_MARKER, + WORKSPACE_ROOT, + _norm_path, +) + + +def path_in_text(text: str) -> bool: + if not text: + return False + return _norm_path(LUCY_WS_MARKER) in _norm_path(text) + + +def _path_in_text(text: str) -> bool: + return path_in_text(text) + + +def read_proc_cwd(pid: int) -> str: + if sys.platform == "win32": + return "" + if sys.platform == "darwin": + result = subprocess.run( + ["lsof", "-a", "-p", str(pid), "-d", "cwd"], + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.splitlines(): + if " cwd " in line: + parts = line.split() + if parts: + return parts[-1] + return "" + try: + return os.readlink(f"/proc/{pid}/cwd") + except OSError: + return "" + + +def _read_proc_cwd(pid: int) -> str: + return read_proc_cwd(pid) + + +def read_proc_environ_darwin(pid: int) -> dict[str, str]: + """Parse env vars from ps eww by searching for KEY=… substrings (handles spaces in values).""" + result = subprocess.run( + ["ps", "eww", "-p", str(pid)], + capture_output=True, + text=True, + check=False, + ) + blob = result.stdout + if not blob.strip(): + return {} + keys = ( + "PIXI_PROJECT_MANIFEST", + "CONDA_PREFIX", + "GZ_SIM_RESOURCE_PATH", + "GZ_SIM_SYSTEM_PLUGIN_PATH", + "PWD", + "PIXI_PROJECT_NAME", + ) + env = {} + for key in keys: + marker = f"{key}=" + start = blob.find(marker) + if start == -1: + continue + val_start = start + len(marker) + end = len(blob) + for other in keys: + if other == key: + continue + pos = blob.find(f" {other}=", val_start) + if pos != -1: + end = min(end, pos) + env[key] = blob[val_start:end].strip() + return env + + +def _read_proc_environ_darwin(pid: int) -> dict[str, str]: + return read_proc_environ_darwin(pid) + + +def read_proc_environ(pid: int) -> dict[str, str]: + if sys.platform == "win32": + return {} + if sys.platform == "darwin": + return read_proc_environ_darwin(pid) + try: + with open(f"/proc/{pid}/environ", "rb") as f: + raw = f.read() + except OSError: + return {} + env = {} + for entry in raw.split(b"\0"): + if b"=" not in entry: + continue + key, _, val = entry.partition(b"=") + try: + env[key.decode()] = val.decode(errors="replace") + except UnicodeDecodeError: + continue + return env + + +def _read_proc_environ(pid: int) -> dict[str, str]: + return read_proc_environ(pid) + + +def read_proc_exe(pid: int) -> str: + if sys.platform == "win32": + return "" + if sys.platform == "darwin": + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "comm="], + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() + try: + return os.readlink(f"/proc/{pid}/exe") + except OSError: + return "" + + +def _read_proc_exe(pid: int) -> str: + return read_proc_exe(pid) + + +def process_workspace_markers(pid: int) -> bool: + """True when cwd, env, or binary ties a short-cmdline process to this workspace.""" + import launcher + + if pid <= 0: + return False + if sys.platform == "win32": + ws = LUCY_WS_MARKER.replace("'", "''") + script = ( + f"$p = Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\"; " + "if (-not $p) { exit 1 }; " + f"if ($p.ExecutablePath -like '*{ws}*') {{ exit 0 }}; " + f"if ($p.CommandLine -like '*{ws}*') {{ exit 0 }}; " + "exit 1" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", script], + capture_output=True, + check=False, + ) + return result.returncode == 0 + if path_in_text(launcher._read_proc_cwd(pid)): + return True + exe = launcher._read_proc_exe(pid) + if path_in_text(exe) or PIXI_ENV_MARKER in _norm_path(exe): + return True + env = launcher._read_proc_environ(pid) + for key in ( + "PIXI_PROJECT_MANIFEST", + "CONDA_PREFIX", + "GZ_SIM_RESOURCE_PATH", + "GZ_SIM_SYSTEM_PLUGIN_PATH", + "PWD", + ): + if path_in_text(env.get(key, "")): + return True + if env.get("PIXI_PROJECT_NAME") == WORKSPACE_ROOT.name: + return True + return False + + +def _process_workspace_markers(pid: int) -> bool: + return process_workspace_markers(pid) diff --git a/launcher/process.py b/launcher/process.py new file mode 100644 index 0000000..b3ce781 --- /dev/null +++ b/launcher/process.py @@ -0,0 +1,314 @@ +"""Orphan process detection, iteration, and cleanup.""" + +import os +import signal +import subprocess +import sys +import threading +import time + +from .constants import ( + CONTROL_PANEL_DIR, + ORPHAN_CLEANUP_DEBOUNCE, + _norm_path, +) +from .platform import ( + path_in_text, + process_workspace_markers, + read_proc_cwd, +) + +_orphan_cleanup_timer = None +_orphan_cleanup_lock = threading.Lock() +_pending_preserve_windows: frozenset[str] = frozenset() +_pending_protect_vite: bool = False + + +def set_orphan_preserve_windows(package_window_ids, protect_vite=False): + """Package tmux window ids to keep alive during the next orphan cleanup pass.""" + global _pending_preserve_windows, _pending_protect_vite + if package_window_ids: + _pending_preserve_windows = frozenset(package_window_ids) + else: + _pending_preserve_windows = frozenset() + _pending_protect_vite = protect_vite + + +def _clear_orphan_preserve(): + global _pending_preserve_windows, _pending_protect_vite + _pending_preserve_windows = frozenset() + _pending_protect_vite = False + + +def _orphan_protected_by_preserve( + cmdline: str, + pid: int, + preserve_package_windows: frozenset[str], + protect_vite: bool, +) -> bool: + """True when an orphan signature matches a package we are intentionally keeping.""" + if not preserve_package_windows and not protect_vite: + return False + if protect_vite and _is_vite_orphan(cmdline, pid): + return True + return False + + +def _is_gz_sim_cmdline(cmdline: str) -> bool: + return "gz sim" in cmdline.lower() + + +def _matches_orphan_signature(cmdline: str) -> bool: + """Cheap cmdline pre-filter — avoids /proc reads for unrelated processes.""" + if not cmdline: + return False + if ( + "launcher.py" in cmdline + or "Lucy.py" in cmdline + or "-m launcher" in cmdline + or "launcher/__main__.py" in cmdline + or "launcher/__main__" in cmdline + ): + return False + cl = cmdline.lower() + if _is_gz_sim_cmdline(cmdline): + return True + if "rosbridge_websocket" in cmdline: + return True + if "lucy.launch.py" in cmdline: + return True + if "rviz2" in cmdline: + return True + if "vite" in cl: + return True + return False + + +def _is_vite_orphan(cmdline: str, pid: int) -> bool: + import launcher + + if "vite" not in cmdline.lower(): + return False + if CONTROL_PANEL_DIR in _norm_path(cmdline): + return True + if pid > 0: + cwd = launcher._read_proc_cwd(pid) + if CONTROL_PANEL_DIR in _norm_path(cwd): + return True + return False + + +def _in_lucy_workspace(cmdline: str, pid: int) -> bool: + import launcher + + if path_in_text(cmdline): + return True + if pid <= 0: + return False + return launcher._process_workspace_markers(pid) + + +def is_lucy_orphan(pid: int, cmdline: str) -> bool: + """True when this process should be reaped during Lucy shutdown.""" + if not _matches_orphan_signature(cmdline): + return False + if not _in_lucy_workspace(cmdline, pid): + return False + if "vite" in cmdline.lower(): + return _is_vite_orphan(cmdline, pid) + return True + + +def is_lucy_orphan_cmdline(cmdline: str) -> bool: + """Cmdline-only check (when pid metadata is unavailable).""" + return is_lucy_orphan(0, cmdline) and path_in_text(cmdline) + + +def _iter_processes(): + """Yield (pid, command_line) for all processes (Linux, macOS, Windows).""" + if sys.platform == "win32": + script = ( + "Get-CimInstance Win32_Process | " + "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", script], + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.splitlines(): + if not line.strip(): + continue + pid_str, _, cmdline = line.partition("\t") + try: + yield int(pid_str.strip()), cmdline.strip() + except ValueError: + continue + return + result = subprocess.run( + ["ps", "ax", "-o", "pid=,command="], + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.splitlines(): + line = line.strip() + if not line: + continue + pid_str, _, cmdline = line.partition(" ") + try: + yield int(pid_str), cmdline.strip() + except ValueError: + continue + + +def _child_pids(pid: int) -> list[int]: + if sys.platform == "win32": + return [] + children = [] + if sys.platform == "darwin": + result = subprocess.run( + ["ps", "-ax", "-o", "pid=,ppid="], + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) >= 2: + try: + cpid, ppid = int(parts[0]), int(parts[1]) + if ppid == pid: + children.append(cpid) + except ValueError: + continue + return children + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + cpid = int(entry) + try: + with open(f"/proc/{cpid}/status") as f: + for line in f: + if line.startswith("PPid:"): + ppid = int(line.split()[1]) + if ppid == pid: + children.append(cpid) + break + except OSError: + continue + return children + + +def _kill_pid(pid: int): + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/F", "/PID", str(pid), "/T"], + capture_output=True, + check=False, + ) + return + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError: + pass + + +def _kill_process_tree(pid: int): + for child in _child_pids(pid): + _kill_process_tree(child) + _kill_pid(pid) + + +def find_lucy_orphan_pids( + *, + exclude_pids=None, + preserve_package_windows=None, + protect_vite=False, +): + """PIDs of workspace-scoped Lucy child processes (never the launcher itself).""" + exclude = {os.getpid(), os.getppid()} + if exclude_pids: + exclude.update(exclude_pids) + preserve = preserve_package_windows or frozenset() + return [ + pid + for pid, cmdline in _iter_processes() + if pid not in exclude + and not _orphan_protected_by_preserve(cmdline, pid, preserve, protect_vite) + and is_lucy_orphan(pid, cmdline) + ] + + +def cleanup_lucy_orphan_processes( + preserve_package_windows=None, + protect_vite=False, +): + """Force-stop workspace-scoped orphan processes (cross-platform, scoped).""" + preserve = preserve_package_windows or frozenset() + for pid in find_lucy_orphan_pids( + preserve_package_windows=preserve, + protect_vite=protect_vite, + ): + _kill_process_tree(pid) + + +def wait_for_orphans_gone( + timeout: float = 5.0, + poll: float = 0.25, + preserve_package_windows=None, + protect_vite=False, +): + """Block until find_lucy_orphan_pids() is empty or timeout elapses.""" + preserve = preserve_package_windows or frozenset() + deadline = time.time() + timeout + while time.time() < deadline: + if not find_lucy_orphan_pids( + preserve_package_windows=preserve, + protect_vite=protect_vite, + ): + return + time.sleep(poll) + + +def _finish_teardown(preserve_package_windows=None, protect_vite=None): + explicit = preserve_package_windows is not None or protect_vite is not None + if explicit: + preserve = preserve_package_windows or frozenset() + vite_protect = protect_vite if protect_vite is not None else False + else: + preserve = _pending_preserve_windows + vite_protect = _pending_protect_vite + cleanup_lucy_orphan_processes( + preserve_package_windows=preserve, + protect_vite=vite_protect, + ) + wait_for_orphans_gone( + timeout=10.0, + preserve_package_windows=preserve, + protect_vite=vite_protect, + ) + cleanup_lucy_orphan_processes( + preserve_package_windows=preserve, + protect_vite=vite_protect, + ) + if not explicit: + _clear_orphan_preserve() + + +def _schedule_orphan_cleanup(): + """Debounced orphan cleanup — coalesces parallel async stops into one pass.""" + global _orphan_cleanup_timer + + def _fire(): + _finish_teardown() + + with _orphan_cleanup_lock: + if _orphan_cleanup_timer is not None: + _orphan_cleanup_timer.cancel() + _orphan_cleanup_timer = threading.Timer(ORPHAN_CLEANUP_DEBOUNCE, _fire) + _orphan_cleanup_timer.daemon = True + _orphan_cleanup_timer.start() diff --git a/launcher/shell.py b/launcher/shell.py new file mode 100644 index 0000000..eba4e59 --- /dev/null +++ b/launcher/shell.py @@ -0,0 +1,123 @@ +"""Shell command execution, Pixi wrapping, and async runners.""" + +import os +import shlex +import subprocess +import threading + +from .constants import ( + GUI_ENV_KEYS, + NIX_GL_ENV_SCRIPT, + TMUX_SESSION, + WORKSPACE_ROOT, +) +from .process import _schedule_orphan_cleanup + + +def _gui_env_exports() -> str: + parts = [] + for key in GUI_ENV_KEYS: + val = os.environ.get(key) + if val: + parts.append(f"export {key}={shlex.quote(val)}") + return "; ".join(parts) + + +def _nix_gl_source() -> str: + """Source hook for NixOS: prepend host Mesa before Pixi conda GL (no-op elsewhere).""" + if os.environ.get("LUCY_NIX_GL", "auto").lower() in ("0", "false", "no", "off"): + return "" + if not NIX_GL_ENV_SCRIPT.is_file(): + return "" + return f"source {shlex.quote(str(NIX_GL_ENV_SCRIPT))}; " + + +def _pixi_workspace_script(user_cmd: str) -> str: + """Shell script body: workspace root + Pixi env (RoboStack + colcon overlay).""" + user_cmd = user_cmd.strip() + nix_gl = _nix_gl_source() + if user_cmd.startswith("pixi "): + pixi_part = user_cmd + elif nix_gl or any(op in user_cmd for op in (";", "&&", "||", "|", "&")): + pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}" + elif user_cmd.startswith("ros2 "): + pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}" + else: + pixi_part = f"pixi run -- {user_cmd}" + body = f"cd {WORKSPACE_ROOT} && {pixi_part}" + exports = _gui_env_exports() + if exports: + body = f"{exports}; {body}" + return body + + +def _tmux_new_pixi_window(window: str, user_cmd: str, remain_on_exit: bool = False) -> str: + """Open a tmux window that runs user_cmd inside pixi run (tmux panes don't inherit pixi).""" + inner = f"bash -lc {shlex.quote(_pixi_workspace_script(user_cmd))}" + cmd = f"tmux new-window -d -t {TMUX_SESSION} -n {window} {inner}" + if remain_on_exit: + cmd += f"; tmux set-window-option -t {TMUX_SESSION}:{window} remain-on-exit on" + return cmd + + +def _complex_package_start(pkg) -> str: + """Legacy complex {start,stop,is_running} entries — route through Pixi when possible.""" + if pkg.id == "control_panel": + return _tmux_new_pixi_window("control_panel", "pixi run panel-dev", remain_on_exit=True) + return pkg.command["start"] + + +def run_shell_command(cmd, capture_output=False): + if capture_output: + return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode == 0 + subprocess.run(cmd, shell=True) + + +def run_shell_command_async(cmd, *, schedule_cleanup=False): + """Fire a shell command without blocking the UI (daemon thread reaps the child). + + Used for stops so the TUI can show STOPPING while a slow shutdown runs. + When schedule_cleanup is True, a debounced orphan cleanup runs afterward.""" + + def _target(): + try: + if cmd: + subprocess.run(cmd, shell=True, check=False) + except Exception: + pass + if schedule_cleanup: + _schedule_orphan_cleanup() + + threading.Thread(target=_target, daemon=True).start() + + +def run_teardown_async(teardown_fn): + """Run a tmux stop callable without blocking the UI; debounced orphan cleanup after.""" + + def _target(): + try: + teardown_fn() + finally: + _schedule_orphan_cleanup() + + threading.Thread(target=_target, daemon=True).start() + + +def _pane_exit_status(pkg_id): + """Exit code of the package's dead tmux pane, or None if it isn't dead. + remain-on-exit keeps the dead pane (and its output) so we can read the code: + 0 is a clean exit (STOPPED), anything else (incl. signal death) a crash (CRASHED).""" + out = subprocess.run( + f"tmux list-panes -t {TMUX_SESSION}:{pkg_id} -F '#{{pane_dead}}:#{{pane_dead_status}}' 2>/dev/null", + shell=True, + capture_output=True, + text=True, + ).stdout + for line in out.splitlines(): + dead, _, status = line.strip().partition(":") + if dead == "1": + try: + return int(status) + except ValueError: + return -1 # signal death reports no status; treat as a crash + return None diff --git a/launcher/state.py b/launcher/state.py new file mode 100644 index 0000000..8fdc80b --- /dev/null +++ b/launcher/state.py @@ -0,0 +1,127 @@ +"""Launcher state, package status tracking, and UI helpers.""" + +import os +import time + +from .constants import LOADING_TIMEOUT, STOPPING_TIMEOUT +from .config import get_dev_mode, load_state +from .package import Package, _pkg_visible + + +_pkg_start_times = {} # pkg_id -> float, timestamp when start was issued +_intended_running = set() # pkg_ids that should be running (for crash detection) +_pkg_stop_times = {} # pkg_id -> float, timestamp when an async stop was issued + + +class LauncherState: + def __init__(self, config_data): + running_state = load_state() + dev_mode = get_dev_mode() + package_configs = [ + p for p in config_data["packages"] if _pkg_visible(p, dev_mode) + ] + self.packages = [Package(p, running_state["modifiers"]) for p in package_configs] + self.package_map = {p.id: p for p in self.packages} + + def get_by_id(self, pkg_id): + return self.package_map.get(pkg_id) + + def refresh_status(self): + """Re-probe running/ready state for all packages without touching selected.""" + running_state = load_state() + for pkg in self.packages: + pkg.update_running_status(running_state["modifiers"]) + + def _enable(self, pkg): + """Tick a package, clearing anything it conflicts with first.""" + for conflict_id in pkg.conflicts: + conflict_pkg = self.get_by_id(conflict_id) + if conflict_pkg and conflict_pkg.selected: + conflict_pkg.selected = False + pkg.selected = True + + def _enable_with_deps(self, pkg): + """Tick a package and any of its (transitive) dependencies that are off.""" + for dep_id in pkg.dependencies: + dep = self.get_by_id(dep_id) + if dep and not dep.selected: + self._enable_with_deps(dep) + self._enable(pkg) + + def _disable_with_dependents(self, pkg): + """Untick a package and any (transitive) dependents.""" + for other_pkg in self.packages: + if pkg.id in other_pkg.dependencies and other_pkg.selected: + self._disable_with_dependents(other_pkg) + pkg.selected = False + + def toggle(self, pkg_id): + pkg = self.get_by_id(pkg_id) + if not pkg: + return None + if pkg_id in _pkg_stop_times and not pkg.selected: + return "Still stopping…" + if not pkg.selected: + self._enable_with_deps(pkg) + else: + self._disable_with_dependents(pkg) + return None + + +def get_pkg_status(pkg): + """Return one of: running, loading, crashed, stopped, stopping.""" + if pkg.id in _pkg_stop_times: + if not pkg.is_running: + _pkg_stop_times.pop(pkg.id, None) + return "stopped" + if time.time() - _pkg_stop_times[pkg.id] < STOPPING_TIMEOUT: + return "stopping" + _pkg_stop_times.pop(pkg.id, None) + if pkg.pane_dead: + _pkg_start_times.pop(pkg.id, None) + if pkg.pane_exit_status == 0: + _intended_running.discard(pkg.id) + return "stopped" + return "crashed" + if pkg.ready: + _pkg_start_times.pop(pkg.id, None) + return "running" + if pkg.id in _intended_running: + timeout = getattr(pkg, "readiness_timeout", LOADING_TIMEOUT) + started = _pkg_start_times.get(pkg.id) + if started is None: + if pkg.is_running: + _pkg_start_times[pkg.id] = time.time() + return "loading" + return "crashed" + if time.time() - started < timeout: + return "loading" + _pkg_start_times.pop(pkg.id, None) + return "crashed" + return "stopped" + + +def _has_unapplied_changes(state): + for pkg in state.packages: + if pkg.id in _pkg_start_times or pkg.id in _pkg_stop_times: + continue + if pkg.selected != pkg.is_running: + return True + return False + + +def _nav_hint(pkg): + """Navigation hint for packages without a web URL (e.g. tmux terminal windows).""" + if not pkg.nav_hint or not pkg.is_running: + return "" + return f"({pkg.nav_hint})" + + +def _status_url(pkg): + """Expanded access URL for a package, or '' if unset env vars leave it unresolved.""" + if not pkg.url: + return "" + expanded = os.path.expandvars(pkg.url) + if "${" in expanded or expanded.endswith(":"): + return "" + return expanded diff --git a/launcher/tmux.py b/launcher/tmux.py new file mode 100644 index 0000000..517c67c --- /dev/null +++ b/launcher/tmux.py @@ -0,0 +1,52 @@ +"""Tmux session and window management.""" + +import os +import sys + +from .constants import TMUX_SESSION + + +def is_in_tmux(): + return "TMUX" in os.environ + + +def needs_tmux_session(): + """tmux launcher is used on Linux/macOS; Windows runs launcher directly.""" + return sys.platform not in ("win32", "cygwin", "msys") and os.name != "nt" + + +def _window_teardown_shell(window: str) -> str: + """Gracefully stop a tmux window: SIGINT, brief poll wait, kill-window.""" + return ( + f"tmux send-keys -t {TMUX_SESSION}:{window} C-c 2>/dev/null; " + "for _ in $(seq 1 8); do sleep 0.25; done; " + f"tmux kill-window -t {TMUX_SESSION}:{window} 2>/dev/null" + ) + + +def _core_teardown_shell() -> str: + return ( + f"tmux send-keys -t {TMUX_SESSION}:core C-c 2>/dev/null; " + "for _ in $(seq 1 20); do " + "pgrep -f '[g]z sim' >/dev/null 2>&1 || pgrep -x rviz2 >/dev/null 2>&1 || break; " + "sleep 0.25; done; " + f"tmux kill-window -t {TMUX_SESSION}:core 2>/dev/null" + ) + + +# Back-compat alias for tests / docs that referenced the shell snippet. +CORE_TEARDOWN = _core_teardown_shell() + + +def _stop_tmux_window(window: str): + import launcher + + if launcher.needs_tmux_session(): + launcher.run_shell_command(launcher._window_teardown_shell(window)) + + +def _stop_core_tmux(): + import launcher + + if launcher.needs_tmux_session(): + launcher.run_shell_command(launcher._core_teardown_shell()) diff --git a/launcher/tui.py b/launcher/tui.py new file mode 100644 index 0000000..abd0c83 --- /dev/null +++ b/launcher/tui.py @@ -0,0 +1,258 @@ +"""Curses TUI rendering and main event loop.""" + +import time + +try: + import curses +except ImportError: + curses = None + +from .apply import apply_changes, default_robot_selection, restore_selection +from .config import get_dev_mode, load_config, save_selection +from .constants import MIN_TERM_HEIGHT, MIN_TERM_WIDTH +from .state import ( + LauncherState, + _has_unapplied_changes, + _intended_running, + _nav_hint, + _pkg_start_times, + _pkg_stop_times, + _status_url, + get_pkg_status, +) + + +def _draw_pkg_row(stdscr, y, x, prefix, indent, checkbox, name, attr, status, hint="", url=""): + base = f"{prefix}{indent}{checkbox} {name}" + stdscr.addstr(y, x, base, attr) + col = x + len(base) + labels = { + "running": (" [RUNNING]", curses.color_pair(4)), + "loading": (" [LOADING]", curses.color_pair(1)), + "stopping": (" [STOPPING]", curses.color_pair(1)), + "crashed": (" [CRASHED]", curses.color_pair(2) | curses.A_BOLD), + "stopped": (" [STOPPED]", curses.A_DIM), + } + status_str, status_attr = labels.get(status, (" [STOPPED]", curses.A_DIM)) + try: + stdscr.addstr(y, col, status_str, status_attr) + col += len(status_str) + except curses.error: + pass + if url and status == "running": + text = f" ({url})" + try: + stdscr.addstr(y, col, text, curses.color_pair(3)) + col += len(text) + except curses.error: + pass + if hint: + text = f" {hint}" + try: + stdscr.addstr(y, col, text, curses.color_pair(3)) + col += len(text) + except curses.error: + pass + + +def draw_too_small_message(stdscr): + h, w = stdscr.getmaxyx() + stdscr.clear() + message = "Please increase terminal size" + message2 = f"({MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} required)" + stdscr.addstr(h // 2 - 1, max(0, (w - len(message)) // 2), message, curses.A_BOLD) + stdscr.addstr(h // 2, max(0, (w - len(message2)) // 2), message2, curses.A_DIM) + stdscr.refresh() + + +def draw_tui(stdscr, state, current_idx, error_msg, status_msg, unapplied=False): + h, w = stdscr.getmaxyx() + if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: + draw_too_small_message(stdscr) + return None + + stdscr.clear() + title = "Lucy Control Center" + stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) + stdscr.addstr( + h - 1, + 2, + "Enter: Apply | Space: Toggle | Q/X/Esc: Stop All & Exit", + curses.A_BOLD, + ) + + if status_msg: + stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD) + elif error_msg: + stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) + elif unapplied: + stdscr.addstr( + h - 2, + 2, + "Unapplied changes — press Enter to apply", + curses.color_pair(1), + ) + + robots = [p for p in state.packages if p.type == "modifier" and p.requires_pkg] + cores_and_mods = [ + p for p in state.packages if p.type in ("core", "modifier") and not p.requires_pkg + ] + interfaces = [p for p in state.packages if p.type == "interface"] + tools = [p for p in state.packages if p.type == "tool"] + display_list = cores_and_mods + robots + interfaces + tools + + def draw_section(title, color, items, offset, gap=1, indent_all=False): + nonlocal row + stdscr.addstr(row, 2, title, curses.A_BOLD | color) + row += gap + for i, p in enumerate(items): + list_idx = offset + i + prefix = "> " if current_idx == list_idx else " " + checkbox = "[x]" if p.selected else "[ ]" + can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies) + attr = curses.A_NORMAL if can_enable else curses.A_DIM + if p.type == "core": + attr |= curses.A_BOLD + if p.subitem: + indent = " " + elif indent_all or p.type == "modifier": + indent = " " + else: + indent = "" + status = get_pkg_status(p) + hint = _nav_hint(p) + _draw_pkg_row( + stdscr, + row + i, + 4, + prefix, + indent, + checkbox, + p.name, + attr, + status, + hint, + _status_url(p), + ) + row += len(items) + 1 + + row = 2 + draw_section("Primary Launch Targets", curses.color_pair(1), cores_and_mods, 0, gap=2) + offset = len(cores_and_mods) + if robots: + draw_section("Robot", curses.color_pair(1), robots, offset, gap=1, indent_all=True) + offset += len(robots) + draw_section("Interfaces", curses.color_pair(3), interfaces, offset, gap=1) + offset += len(interfaces) + draw_section("Tools", curses.color_pair(3), tools, offset, gap=1) + + stdscr.refresh() + return display_list + + +def main(stdscr): + curses.curs_set(0) + stdscr.nodelay(0) + stdscr.timeout(-1) + curses.start_color() + curses.use_default_colors() + + if curses.has_colors(): + curses.init_pair(1, curses.COLOR_YELLOW, -1) + curses.init_pair(2, curses.COLOR_RED, -1) + curses.init_pair(3, curses.COLOR_CYAN, -1) + curses.init_pair(4, curses.COLOR_GREEN, -1) + + state = LauncherState(load_config()) + restore_selection(state) + default_robot_selection(state) + current_idx = 0 + error_msg = None + status_msg = None + status_msg_until = 0.0 + + if not get_dev_mode(): + core_pkg = state.get_by_id("core") + lcp_pkg = state.get_by_id("control_panel") + if core_pkg: + core_pkg.selected = True + if lcp_pkg: + lcp_pkg.selected = True + default_robot_selection(state) + apply_changes(state) + save_selection({p.id for p in state.packages if p.selected}) + status_msg = "Starting default services for production mode..." + status_msg_until = time.time() + 3.0 + state.refresh_status() + + while True: + try: + if status_msg and time.time() >= status_msg_until: + status_msg = None + display_list = draw_tui( + stdscr, + state, + current_idx, + error_msg, + status_msg, + _has_unapplied_changes(state), + ) + error_msg = None + + if display_list is None: + stdscr.nodelay(1) + stdscr.timeout(100) + key = stdscr.getch() + if key != curses.KEY_RESIZE: + time.sleep(0.1) + continue + + if _pkg_start_times or _pkg_stop_times: + poll_ms = 1000 + elif _intended_running: + poll_ms = 5000 + else: + poll_ms = None + if poll_ms is None: + stdscr.nodelay(0) + stdscr.timeout(-1) + else: + stdscr.nodelay(1) + stdscr.timeout(poll_ms) + key = stdscr.getch() + if key == -1: + state.refresh_status() + continue + + if key == curses.KEY_RESIZE: + continue + + if key == curses.KEY_UP: + current_idx = (current_idx - 1) % len(display_list) + elif key == curses.KEY_DOWN: + current_idx = (current_idx + 1) % len(display_list) + elif key == ord(" "): + pkg_to_toggle = display_list[current_idx] + error_msg = state.toggle(pkg_to_toggle.id) + elif key == ord("\n"): + apply_changes(state) + save_selection({p.id for p in state.packages if p.selected}) + status_msg = "Configuration Applied!" + status_msg_until = time.time() + 2.0 + state.refresh_status() + elif key in [ord("x"), ord("X"), ord("q"), ord("Q"), 27]: + h, w = stdscr.getmaxyx() + stdscr.addstr( + h - 2, + 2, + "Stop all processes and exit? (y/n)", + curses.A_BOLD | curses.color_pair(2), + ) + stdscr.refresh() + confirm_key = stdscr.getch() + if confirm_key in [ord("y"), ord("Y")]: + return "ExitWorkspace", state + + except curses.error: + time.sleep(0.1) + continue diff --git a/scripts/ci_tmux_launcher_smoke.sh b/scripts/ci_tmux_launcher_smoke.sh index ae86b3c..66f3d59 100755 --- a/scripts/ci_tmux_launcher_smoke.sh +++ b/scripts/ci_tmux_launcher_smoke.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # CI smoke: tmux + pixi-wrapped core (headless sim) and control panel. -# Exercises the same tmux/pixi paths as launcher.py apply_changes without the TUI. +# Exercises the same tmux/pixi paths as launcher apply_changes without the TUI. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -68,5 +68,15 @@ wait_for() { wait_for '[r]osbridge_websocket' 'rosbridge' 180 wait_for '[v]ite' 'control panel (vite)' 180 +pixi run -- python3 <<'PY' +import os + +os.chdir(os.environ["LUCY_WS_ROOT"]) +from launcher import load_workspace_env, stop_all_packages, LauncherState, load_config + +load_workspace_env() +stop_all_packages(LauncherState(load_config())) +PY + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true echo "ci_tmux_launcher_smoke: OK" diff --git a/scripts/nix_gl_env.sh b/scripts/nix_gl_env.sh index 29fa65b..d3b2d75 100644 --- a/scripts/nix_gl_env.sh +++ b/scripts/nix_gl_env.sh @@ -7,7 +7,7 @@ # 1. Prepends host GL libs (nixGL wrapper, or /run/opengl-driver/lib) # 2. Sets Mesa EGL vendor + GZ_IP for OGRE / Gazebo transport # -# Usage (via scripts/pixi_lucy_launch.sh or launcher.py): +# Usage (via scripts/pixi_lucy_launch.sh or python -m launcher): # pixi run sim # # Do not set LUCY_NIX_GL=0 on NixOS — EGL vars alone are not enough. diff --git a/tests/test_launcher_pixi.py b/tests/test_launcher_pixi.py index 4c0e501..cf65c2b 100644 --- a/tests/test_launcher_pixi.py +++ b/tests/test_launcher_pixi.py @@ -6,11 +6,18 @@ from launcher import ( STATE_FILE, WORKSPACE_ROOT, + CORE_TEARDOWN, + _core_teardown_shell, _gui_env_exports, _pixi_workspace_script, _tmux_new_pixi_window, + _window_teardown_shell, + apply_changes, + is_lucy_orphan, + is_lucy_orphan_cmdline, load_workspace_env, needs_tmux_session, + stop_all_packages, ) @@ -64,3 +71,412 @@ def test_load_workspace_env_reads_dotenv(tmp_path, monkeypatch): def test_needs_tmux_session_false_on_windows(): if os.name == "nt": assert needs_tmux_session() is False + + +def test_window_teardown_sends_sigint_before_kill(): + cmd = _window_teardown_shell("control_panel") + assert "send-keys" in cmd + assert "C-c" in cmd + assert "kill-window" in cmd + assert "control_panel" in cmd + assert "sleep 0.25" in cmd + + +def test_core_teardown_waits_before_kill_window(): + cmd = _core_teardown_shell() + assert "C-c" in cmd + assert "kill-window" in cmd + assert "pgrep" in cmd + assert cmd == CORE_TEARDOWN + + +def test_is_lucy_orphan_cmdline_requires_workspace(): + ws = str(WORKSPACE_ROOT) + assert is_lucy_orphan_cmdline(f"gz sim server {ws}/install/world.sdf") + assert not is_lucy_orphan_cmdline("gz sim server /other/project/world.sdf") + + +def test_is_lucy_orphan_gz_sim_server_via_workspace_markers(monkeypatch): + monkeypatch.setattr(launcher, "_process_workspace_markers", lambda pid: pid == 1120406) + assert is_lucy_orphan(1120406, "gz sim server") + assert not is_lucy_orphan(999, "gz sim server") + + +def test_is_lucy_orphan_gz_sim_gui_via_workspace_markers(monkeypatch): + monkeypatch.setattr(launcher, "_process_workspace_markers", lambda _pid: True) + assert is_lucy_orphan(1, "gz sim gui") + + +def test_is_lucy_orphan_cmdline_excludes_launcher(): + ws = str(WORKSPACE_ROOT) + assert not is_lucy_orphan_cmdline(f"python {ws}/launcher.py") + assert not is_lucy_orphan_cmdline(f"python {ws}/Lucy.py") + + +def test_is_lucy_orphan_skips_unrelated_processes(monkeypatch): + marker_calls = [] + monkeypatch.setattr( + launcher, + "_process_workspace_markers", + lambda pid: marker_calls.append(pid) or False, + ) + assert not is_lucy_orphan(42, "/usr/lib/systemd/systemd --user") + assert not is_lucy_orphan(42, "firefox") + assert marker_calls == [] + + +def test_is_lucy_orphan_vite_short_cmdline_via_cwd(monkeypatch): + cp = f"{WORKSPACE_ROOT}/src/lucy_control_panel" + monkeypatch.setattr(launcher, "_read_proc_cwd", lambda _pid: cp) + assert is_lucy_orphan(123, "node node_modules/vite/bin/vite.js") + + +def test_is_lucy_orphan_vite_rejects_other_app_in_workspace(): + ws = str(WORKSPACE_ROOT) + assert not is_lucy_orphan(0, f"node {ws}/other-app/vite.js") + + +def test_is_lucy_orphan_cmdline_vite_scoped_to_control_panel(): + ws = str(WORKSPACE_ROOT) + assert is_lucy_orphan_cmdline( + f"node {ws}/src/lucy_control_panel/node_modules/vite/bin/vite.js" + ) + assert not is_lucy_orphan_cmdline(f"node {ws}/other-app/vite.js") + + +def test_find_lucy_orphan_pids_preserves_control_panel_vite(monkeypatch): + cp = f"{WORKSPACE_ROOT}/src/lucy_control_panel" + monkeypatch.setattr(launcher, "_read_proc_cwd", lambda _pid: cp) + vite_pid = 999999 + vite_cmd = "node node_modules/vite/bin/vite.js" + + def fake_iter(): + yield vite_pid, vite_cmd + + monkeypatch.setattr(launcher.process, "_iter_processes", fake_iter) + assert launcher.is_lucy_orphan(vite_pid, vite_cmd) + assert vite_pid not in launcher.find_lucy_orphan_pids( + preserve_package_windows=frozenset(["control_panel"]), + protect_vite=True, + ) + assert vite_pid in launcher.find_lucy_orphan_pids( + preserve_package_windows=frozenset(["control_panel"]), + protect_vite=False, + ) + assert vite_pid in launcher.find_lucy_orphan_pids(protect_vite=False) + + +def test_stop_all_packages_single_finish_teardown(monkeypatch): + finish_count = [] + + class FakePkg: + def __init__(self, pid, ptype, running): + self.id = pid + self.type = ptype + self.is_running = running + self.lifecycle_hooks = {} + + def is_complex_command(self): + return False + + class FakeState: + def refresh_status(self): + pass + + def get_by_id(self, pid): + if pid == "core": + return FakePkg("core", "core", True) + return None + + packages = [ + FakePkg("control_panel", "interface", True), + FakePkg("core", "core", True), + ] + + monkeypatch.setattr(launcher, "_stop_tmux_window", lambda _w: None) + monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: None) + monkeypatch.setattr( + launcher, "_finish_teardown", lambda: finish_count.append(1) + ) + monkeypatch.setattr(launcher, "save_state", lambda _data: None) + + stop_all_packages(FakeState()) + assert finish_count == [1] + + +def test_stop_all_packages_always_finishes_teardown(monkeypatch): + calls = [] + monkeypatch.setattr(launcher, "_finish_teardown", lambda: calls.append("finish")) + stop_all_packages(None) + assert calls == ["finish"] + + +def test_stop_all_packages_stops_running_interfaces(monkeypatch): + calls = [] + + class FakePkg: + def __init__(self, pid, ptype, running): + self.id = pid + self.type = ptype + self.is_running = running + self.lifecycle_hooks = {} + + def is_complex_command(self): + return False + + class FakeState: + def refresh_status(self): + pass + + def get_by_id(self, _): + return None + + packages = [ + FakePkg("control_panel", "interface", True), + FakePkg("core", "core", False), + ] + + monkeypatch.setattr( + launcher, "_stop_tmux_window", lambda w: calls.append(f"window:{w}") + ) + monkeypatch.setattr(launcher, "_finish_teardown", lambda: calls.append("finish")) + monkeypatch.setattr(launcher, "save_state", lambda _data: calls.append("save")) + + stop_all_packages(FakeState()) + assert calls == ["window:control_panel", "save", "finish"] + + +def test_stop_all_packages_runs_core_teardown_when_running(monkeypatch): + calls = [] + + class FakePkg: + def __init__(self, pid, ptype, running): + self.id = pid + self.type = ptype + self.is_running = running + self.lifecycle_hooks = {} + + def is_complex_command(self): + return False + + class FakeCore(FakePkg): + pass + + core = FakeCore("core", "core", True) + + class FakeState: + def refresh_status(self): + pass + + def get_by_id(self, pid): + return core if pid == "core" else None + + packages = [core] + + monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: calls.append("core")) + monkeypatch.setattr(launcher, "_finish_teardown", lambda: calls.append("finish")) + monkeypatch.setattr(launcher, "save_state", lambda _data: None) + + stop_all_packages(FakeState()) + assert calls == ["core", "finish"] + + +def test_stop_all_packages_skips_tmux_on_windows(monkeypatch): + monkeypatch.setattr(launcher, "needs_tmux_session", lambda: False) + shell_calls = [] + finish_calls = [] + + monkeypatch.setattr( + launcher, "run_shell_command", lambda cmd: shell_calls.append(cmd) + ) + monkeypatch.setattr( + launcher, "_finish_teardown", lambda: finish_calls.append(True) + ) + + class FakeCore: + id = "core" + type = "core" + is_running = True + lifecycle_hooks = {} + + def is_complex_command(self): + return False + + class FakeState: + packages = [FakeCore()] + + def refresh_status(self): + pass + + def get_by_id(self, _): + return FakeCore() + + stop_all_packages(FakeState()) + assert shell_calls == [] + assert len(finish_calls) >= 1 + + +def test_apply_changes_modifier_restart_calls_finish_teardown(monkeypatch): + """Changing modifiers while core is selected must tear down before relaunch.""" + calls = [] + + class FakePkg: + def __init__(self, pid, ptype, selected, running, command=""): + self.id = pid + self.type = ptype + self.selected = selected + self.is_running = running + self.command = command + self.lifecycle_hooks = {} + self.pane_dead = False + self.pane_exit_status = None + self.readiness_check = None + + def is_complex_command(self): + return False + + core = FakePkg( + "core", + "core", + True, + True, + "ros2 launch lucy_bringup lucy.launch.py", + ) + gazebo = FakePkg("gazebo", "modifier", True, False, "gazebo:=true") + + class FakeState: + packages = [core, gazebo] + + def get_by_id(self, pid): + return {"core": core, "gazebo": gazebo}.get(pid) + + monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: calls.append("core_stop")) + finish_args = [] + monkeypatch.setattr( + launcher, + "_finish_teardown", + lambda preserve_package_windows=None, protect_vite=None: finish_args.append( + (preserve_package_windows, protect_vite) + ), + ) + monkeypatch.setattr(launcher, "save_state", lambda _data: calls.append("save")) + + apply_changes(FakeState()) + assert calls[0] == "core_stop" + assert "save" in calls + assert finish_args == [(frozenset(), False)] + + +def test_apply_changes_modifier_restart_preserves_control_panel(monkeypatch): + finish_args = [] + + class FakePkg: + def __init__(self, pid, ptype, selected, running, command="", readiness_check=None): + self.id = pid + self.type = ptype + self.selected = selected + self.is_running = running + self.command = command + self.lifecycle_hooks = {} + self.pane_dead = False + self.pane_exit_status = None + self.readiness_check = readiness_check + + def is_complex_command(self): + return False + + core = FakePkg( + "core", + "core", + True, + True, + "ros2 launch lucy_bringup lucy.launch.py", + ) + gazebo = FakePkg("gazebo", "modifier", True, False, "gazebo:=true") + control_panel = FakePkg( + "control_panel", + "interface", + True, + True, + "pixi run panel-dev", + readiness_check="pgrep -f '[v]ite' >/dev/null 2>&1", + ) + + class FakeState: + packages = [core, gazebo, control_panel] + + def get_by_id(self, pid): + return {"core": core, "gazebo": gazebo, "control_panel": control_panel}.get(pid) + + monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: None) + monkeypatch.setattr( + launcher, + "_finish_teardown", + lambda preserve_package_windows=None, protect_vite=None: finish_args.append( + (preserve_package_windows, protect_vite) + ), + ) + monkeypatch.setattr(launcher, "save_state", lambda _data: None) + monkeypatch.setattr(launcher, "run_shell_command", lambda _cmd: None) + + apply_changes(FakeState()) + assert finish_args == [(frozenset({"control_panel"}), True)] + + +def test_apply_changes_stopping_core_preserves_running_control_panel(monkeypatch): + preserve_calls = [] + + class FakePkg: + def __init__(self, pid, ptype, selected, running, command="", readiness_check=None): + self.id = pid + self.type = ptype + self.selected = selected + self.is_running = running + self.command = command + self.lifecycle_hooks = {} + self.pane_dead = False + self.pane_exit_status = None + self.readiness_check = readiness_check + + def is_complex_command(self): + return False + + core = FakePkg("core", "core", False, True, "ros2 launch lucy_bringup lucy.launch.py") + control_panel = FakePkg( + "control_panel", + "interface", + True, + True, + "pixi run panel-dev", + readiness_check="pgrep -f '[v]ite' >/dev/null 2>&1", + ) + + class FakeState: + packages = [core, control_panel] + + def get_by_id(self, pid): + return {"core": core, "control_panel": control_panel}.get(pid) + + monkeypatch.setattr( + launcher, + "set_orphan_preserve_windows", + lambda windows, protect_vite=False: preserve_calls.append( + (frozenset(windows), protect_vite) + ), + ) + monkeypatch.setattr(launcher, "run_teardown_async", lambda fn: fn()) + monkeypatch.setattr(launcher, "save_state", lambda _data: None) + monkeypatch.setattr(launcher, "run_shell_command", lambda _cmd: None) + + apply_changes(FakeState()) + assert preserve_calls[0] == (frozenset({"control_panel"}), True) + + +def test_finish_teardown_clears_pending_preserve(monkeypatch): + launcher.set_orphan_preserve_windows(["control_panel"], protect_vite=True) + monkeypatch.setattr(launcher, "cleanup_lucy_orphan_processes", lambda **kwargs: None) + monkeypatch.setattr(launcher, "wait_for_orphans_gone", lambda **kwargs: None) + + launcher._finish_teardown() + assert launcher.process._pending_preserve_windows == frozenset() + assert launcher.process._pending_protect_vite is False diff --git a/windows/install_ops.py b/windows/install_ops.py index ec5996d..98b7add 100644 --- a/windows/install_ops.py +++ b/windows/install_ops.py @@ -246,7 +246,7 @@ def parse_repos(project_root: str, developer: bool, repos_branch: Optional[str] name = repo.get("name", "").strip().strip("\r\n") if not name: continue - branch = (repos_branch or repo.get("branch", DEFAULT_REPOS_BRANCH)).strip().strip("\r\n") + branch = (repo.get("branch") or repos_branch or DEFAULT_REPOS_BRANCH).strip().strip("\r\n") url_https = (repo.get("url_https") or repo.get("url") or "").strip().strip("\r\n") url_ssh = (repo.get("url_ssh") or "").strip().strip("\r\n") url = (url_ssh or url_https) if developer else (url_https or url_ssh) diff --git a/windows/install_runner.py b/windows/install_runner.py index 3c01fc7..36833af 100644 --- a/windows/install_runner.py +++ b/windows/install_runner.py @@ -53,7 +53,7 @@ def main(argv: list[str] | None = None) -> int: help="Install operation to run", ) parser.add_argument("--developer", action="store_true", help="Developer install (requires git, SSH clones)") - parser.add_argument("--repos-branch", default="master", help="Branch for sub-repositories") + parser.add_argument("--repos-branch", default=None, help="Fallback branch for repos without one set") parser.add_argument("--lucy-ws-ref", default="master", help="lucy_ws git ref (branch or tag)") parser.add_argument( "--lucy-ws-ref-type",