diff --git a/.env.example b/.env.example index fadb7e4..6343b34 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,8 @@ PORT_ROSBRIDGE=9090 PORT_CONTROL_PANEL=4004 -# DDS discovery (scripts/dds_env.sh). macOS defaults to localhost-only unicast: -# tmux daemonizes to PPID 1, so the tmux server holds its own macOS Local Network -# permission, which Homebrew tmux never gets — multicast discovery is then dropped -# silently and nodes never find each other. Loopback is exempt. Linux keeps stock DDS. +# DDS discovery (scripts/dds_env.sh). macOS defaults to localhost-only unicast +# because multicast needs a Local Network permission tmux never gets. # LUCY_DDS_LOCALHOST=0 # LUCY_DDS_INTERFACE=192.168.1.5 # LUCY_DDS_PEERS=hostA,hostB diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml index fbb86c2..398ce40 100644 --- a/.github/workflows/install-and-launch.yml +++ b/.github/workflows/install-and-launch.yml @@ -1,5 +1,5 @@ -# - DEV=false -> install.sh uses url_https from config/repos.json. -# - ./install.sh --skip-build clones sub-repos; colcon build runs via pixi run build. +# - DEV=false -> install.py uses url_https from config/repos.json. +# - python3 install.py --skip-build clones sub-repos; colcon build runs via pixi run build. name: Install, Launch & Release @@ -58,8 +58,8 @@ jobs: - name: Clone sub-repositories (skip colcon) shell: bash run: | - chmod +x install.sh launch_lucy.sh - ./install.sh --skip-build + chmod +x launch_lucy.sh + python3 install.py --skip-build - name: Build workspace (colcon) shell: bash @@ -79,7 +79,7 @@ jobs: - name: Launch smoke test (headless) shell: bash - run: ./launch_lucy.sh --headless ros2 doctor --report + run: ./launch_lucy.sh --headless python scripts/ros_doctor.py - name: Launcher tmux smoke (Linux) if: matrix.pixi_platform == 'linux-64' diff --git a/.gitignore b/.gitignore index d3ed18d..4151621 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Pixi .pixi/ -# Workspace (filled by install.sh) +# Workspace (filled by install.py) src/ # Colcon diff --git a/Lucy.py b/Lucy.py index 285efc3..d8d4ba4 100644 --- a/Lucy.py +++ b/Lucy.py @@ -202,7 +202,7 @@ def main_tui(stdscr): set_dev_mode(is_dev_mode) elif selected_option == "Update": return { - "cmd": ["./install.sh"], + "cmd": [sys.executable, "install.py"], "interactive": False, "name": "Install", "extra_env": INSTALL_ENV, @@ -249,7 +249,7 @@ def check_initial_size(): sys.exit(1) if not wants_install: sys.exit(0) - rc = run_command(["./install.sh"], extra_env=INSTALL_ENV) + rc = run_command([sys.executable, "install.py"], extra_env=INSTALL_ENV) if rc != 0: print(f"\n--- Install finished with exit code {rc} ---") print("Press Enter to exit.") diff --git a/README.md b/README.md index 80111d2..5ee3276 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,10 @@ Run `Lucy.py` and the install scripts **from the repository root** — they read ### Linux / macOS ```bash -./install.sh # thin wrapper around install.py +python3 install.py ``` -**NixOS:** enable [nix-ld](https://github.com/nix-community/nix-ld) so Pixi/RoboStack conda binaries can load the host dynamic linker (required before `./install.sh`): +**NixOS:** enable [nix-ld](https://github.com/nix-community/nix-ld) so Pixi/RoboStack conda binaries can load the host dynamic linker (required before `install.py`): ```nix programs.nix-ld.enable = true; @@ -80,7 +80,7 @@ pixi run control-panel # http://localhost:4004 pixi run rviz # optional viewer ``` -`pixi run core` also starts a `/joint_states` stand-in, because ros2_control's controller_manager currently crashes on Windows. The robot renders and follows the panel's sliders, but is not driveable. See the [developer guide](docs/developer_lucy_packages.md). +`pixi run core` also starts a `/joint_states` stand-in, because ros2_control's controller_manager currently crashes on Windows. The model follows the panel's sliders, but nothing reaches the hardware. See the [developer guide](docs/developer_lucy_packages.md). ## Using the Lucy launcher @@ -117,5 +117,5 @@ For developer mode, Pixi component tasks (`pixi run core`, `sim-headless`, …), ## More -- [`docs/developer_lucy_packages.md`](docs/developer_lucy_packages.md) — developer guide: per-repo docs, all `install.sh` / `launch_lucy.sh` flags, dev mode, ports, environment overrides, packages overview. +- [`docs/developer_lucy_packages.md`](docs/developer_lucy_packages.md) — developer guide: per-repo docs, all `install.py` / `launch_lucy.sh` flags, dev mode, ports, environment overrides, packages overview. - [`docs/launcher_packages.md`](docs/launcher_packages.md) — launcher guide: how to add new packages to the launcher UI and understand the configuration fields. diff --git a/config/repos.json b/config/repos.json index 2f285a3..acabc01 100644 --- a/config/repos.json +++ b/config/repos.json @@ -8,13 +8,13 @@ }, { "name": "lucy_ros_packages", - "branch": "feat-pixi", + "branch": "aes/fix-supervisor-orphaned-children", "url_https": "https://github.com/Sentience-Robotics/lucy_ros_packages.git", "url_ssh": "git@github.com:Sentience-Robotics/lucy_ros_packages.git" }, { "name": "lucy_control_panel", - "branch": "dev", + "branch": "aes/shorten-service-comment", "url_https": "https://github.com/Sentience-Robotics/lucy_control_panel.git", "url_ssh": "git@github.com:Sentience-Robotics/lucy_control_panel.git" } diff --git a/docs/developer_lucy_packages.md b/docs/developer_lucy_packages.md index 7694933..2bfcdf6 100644 --- a/docs/developer_lucy_packages.md +++ b/docs/developer_lucy_packages.md @@ -10,10 +10,10 @@ When enabled: | Behavior | Effect | |----------|--------| -| **SSH clones** | `install.sh` uses `url_ssh` from [`config/repos.json`](../config/repos.json) instead of HTTPS | +| **SSH clones** | `install.py` uses `url_ssh` from [`config/repos.json`](../config/repos.json) instead of HTTPS | | **No auto-launch** | Core and Control Panel are not started automatically on Launch | -SSH keys must be configured for GitHub on your host before running `./install.sh` with `DEV=true`. +SSH keys must be configured for GitHub on your host before running `install.py` with `DEV=true`. ### Local overrides (gitignored) @@ -44,7 +44,7 @@ For a multi-robot dev setup, copy [`config/launcher_config.json.local.example`]( ### Linux -Standard path: `./install.sh` then `python3 Lucy.py`. +Standard path: `python3 install.py` then `python3 Lucy.py`. **Wayland:** RViz/Gazebo may need `xhost +local:` or an X11 session. @@ -66,28 +66,28 @@ Developer CLI equivalents: | Windows | Linux/macOS | |---------|-------------| -| `Lucy-Setup.exe` → Fresh install | `./install.sh` | -| `Lucy-Setup.exe` → Update | `./install.sh` | -| `Lucy-Setup.exe` → Repair | `./install.sh --repair` | +| `Lucy-Setup.exe` → Fresh install | `python3 install.py` | +| `Lucy-Setup.exe` → Update | `python3 install.py` | +| `Lucy-Setup.exe` → Repair | `python3 install.py --repair` | | `Lucy.exe` | `./launch_lucy.sh` | -| `Lucy.exe --cli build-only` | `./install.sh --build-only` | +| `Lucy.exe --cli build-only` | `python3 install.py --build-only` | 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`) +### Workspace install (`install.py`) Pixi installs RoboStack Jazzy; `colcon build --symlink-install` builds `src/`; `yarn install` sets up the control panel. | Command | What it does | |---------|--------------| -| `./install.sh` | Clone missing repos, pull existing ones, `pixi install`, colcon build | -| `./install.sh --repair` | Wipe each repo under `src/` then re-clone and rebuild | -| `./install.sh --build-only` | Skip git; `pixi install` + colcon + panel yarn | -| `./install.sh --skip-build` | Clone/pull only (CI) | +| `python3 install.py` | Clone missing repos, pull existing ones, `pixi install`, colcon build | +| `python3 install.py --repair` | Wipe each repo under `src/` then re-clone and rebuild | +| `python3 install.py --build-only` | Skip git; `pixi install` + colcon + panel yarn | +| `python3 install.py --skip-build` | Clone/pull only (CI) | **Do not use `rosdep`** — it bypasses Pixi/RoboStack. Add deps via `pixi.toml` or clone into `src/`. See [`docs/pixi_setup.md`](pixi_setup.md). -**RealSense** (optional, not in Pixi): after a normal build, run `./scripts/build_local_realsense.sh` or `LUCY_BUILD_REALSENSE=1 ./install.sh`. Primary target is Linux; see script for aarch64 notes. +**RealSense** (optional, not in Pixi): after a normal build, run `./scripts/build_local_realsense.sh` or `LUCY_BUILD_REALSENSE=1 python3 install.py`. Primary target is Linux; see script for aarch64 notes. **Packages under `src/`** (from [`config/repos.json`](../config/repos.json)): @@ -174,7 +174,7 @@ ros2 launch lucy_bringup lucy.launch.py gazebo:=true | Action | Command | |--------|---------| -| Install / update | `./install.sh` | +| Install / update | `python3 install.py` | | Rebuild | `pixi run build` then `pixi run panel-install` | | Launch | `./launch_lucy.sh` | | Dev shell | `pixi run shell` | @@ -205,7 +205,7 @@ From **Configuration → ACTIVATE**, enable **SIMULATION ONLY** to run **VALIDAT | Env var | Default | Purpose | |---------|---------|---------| -| `DEV` | unset | `true` → SSH clones during `install.sh` | +| `DEV` | unset | `true` → SSH clones during `install.py` | | `PORT_CONTROL_PANEL` | auto | Host port for control panel URL | | `PORT_CONTROL_PANEL_CONTAINER` | `VITE_PORT` from `src/lucy_control_panel/.env`, else `4004` | Port the Vite dev server listens on inside the container | | `PORT_ROSBRIDGE` | `9090` | rosbridge WebSocket | diff --git a/docs/pixi_setup.md b/docs/pixi_setup.md index 95b6b11..5d25333 100644 --- a/docs/pixi_setup.md +++ b/docs/pixi_setup.md @@ -25,7 +25,7 @@ When adding or changing workspace packages: pixi install # updates pixi.lock for every platform in pixi.toml ``` -`install.sh` runs `pixi lock` automatically if `pixi.lock` is missing, then `pixi install`. +`install.py` runs `pixi lock` automatically if `pixi.lock` is missing, then `pixi install`. **Pixi ≥ 0.78** is recommended for multi-platform lock resolution (`curl -fsSL https://pixi.sh/install.sh | bash`). diff --git a/install.py b/install.py index f7cead3..7118544 100644 --- a/install.py +++ b/install.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Lucy workspace setup: clone sub-repos, install RoboStack deps via Pixi, colcon build. -Single cross-platform implementation behind ./install.sh (Linux, macOS) and the +Single cross-platform implementation behind the Windows launcher and the Windows flows in windows/install_ops.py. Keep behaviour changes here so no platform drifts from the others. @@ -443,7 +443,7 @@ def confirm_install(req_id: str, prompt: str, auto_env: str) -> None: fail(req_id, f"{prompt} needs confirmation in non-interactive mode. " f"Set {auto_env}=1 or run from an interactive terminal.") if input(f"{prompt} [y/N] ").strip().lower() not in ("y", "yes"): - fail(req_id, f"Aborted — install it manually or set {auto_env}=1.") + fail(req_id, f"Aborted - install it manually or set {auto_env}=1.") def confirm_pixi_install() -> None: @@ -461,8 +461,8 @@ def ensure_pixi(run_command: Callable = default_run_command, log: Log = print) - if env_flag("LUCY_SKIP_PIXI_UPGRADE"): fail("pixi", f"pixi {current} is older than required {minimum}" if current else "pixi not found") - log(f"install: pixi {current} is older than {minimum} — installing latest ..." if current - else "install: pixi not found — installing via pixi.sh ...") + log(f"install: pixi {current} is older than {minimum} - installing latest ..." if current + else "install: pixi not found - installing via pixi.sh ...") log("install: (LUCY_SKIP_PIXI_UPGRADE=1 to abort; LUCY_PIXI_AUTO_UPGRADE=1 to skip prompt)") confirm_pixi_install() @@ -502,7 +502,7 @@ def ensure_msvc(run_command: Callable = default_run_command, log: Log = print) - if shutil.which("winget") is None: fail("msvc", "winget not found, so the build tools cannot be installed automatically.") - log("install: MSVC build tools not found — installing via winget (roughly 1.5 GB) ...") + log("install: MSVC build tools not found - installing via winget (roughly 1.5 GB) ...") log("install: (LUCY_SKIP_MSVC_INSTALL=1 to abort; LUCY_MSVC_AUTO_INSTALL=1 to skip prompt)") confirm_install("msvc", "Install the Visual Studio Build Tools C++ compiler?", "LUCY_MSVC_AUTO_INSTALL") run_command(msvc_install_command()) @@ -645,7 +645,7 @@ def install_repos( effective = "zip" if effective == "zip" and mode == "update": - log("NOTE: ZIP-based install — local changes under src/ were replaced.") + log("NOTE: ZIP-based install - local changes under src/ were replaced.") mark_optional_colcon_ignore(root, repos, log) return effective @@ -739,7 +739,7 @@ def pixi_install( project_root: Path | str, run_command: Callable = default_run_command, log: Log = print ) -> None: if not (Path(project_root) / "pixi.lock").is_file(): - log("No pixi.lock — running pixi lock (solves every platform in pixi.toml) ...") + log("No pixi.lock - running pixi lock (solves every platform in pixi.toml) ...") pixi_run(project_root, ["lock"], run_command) log("Pixi install (RoboStack Jazzy, all workspace platforms) ...") pixi_run(project_root, ["install"], run_command) @@ -752,12 +752,12 @@ def build_local_realsense_optional( ) -> None: """Optional local librealsense build (Linux-targeted shell script).""" if not env_flag("LUCY_BUILD_REALSENSE"): - log("RealSense: local build when needed — scripts/build_local_realsense.sh") + log("RealSense: local build when needed: scripts/build_local_realsense.sh") return if sys.platform == "win32": log("RealSense: LUCY_BUILD_REALSENSE set but the local build is Linux-only; skipping.") return - log("LUCY_BUILD_REALSENSE enabled — building librealsense locally ...") + log("LUCY_BUILD_REALSENSE enabled - building librealsense locally ...") script = Path(project_root) / "scripts" / "build_local_realsense.sh" run_command(["bash", str(script)], cwd=str(project_root)) @@ -870,8 +870,12 @@ def main(argv: Optional[list[str]] = None) -> int: print(f"Install failed: {exc}", file=sys.stderr) return 1 - print("Repos ready. Run 'pixi run build' or re-run without --skip-build." if args.skip_build - else "Install complete. Run './launch_lucy.sh' or Launch in Lucy.py") + if args.skip_build: + print("Repos ready. Run 'pixi run build' or re-run without --skip-build.") + elif sys.platform == "win32": + print("Install complete. Run 'pixi run core', then 'pixi run control-panel'.") + else: + print("Install complete. Run './launch_lucy.sh' or Launch in Lucy.py") return 0 diff --git a/install.sh b/install.sh deleted file mode 100755 index 86f8ab5..0000000 --- a/install.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -# Thin wrapper around install.py, which holds the real (cross-platform) logic. -# -# Usage: -# ./install.sh clone/pull repos + pixi install + build -# ./install.sh --update | update same as above -# ./install.sh --repair wipe build/install/log, re-clone src repos, re-lock Pixi -# ./install.sh --build-only skip git; pixi run build + panel-install -# ./install.sh --skip-build clone/pull only (CI) -# -# On Windows use install.py directly: python install.py [same flags] - -set -e -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# "update" as a bare word predates the flags; install.py only knows --update. -args=() -for a in "$@"; do - case "$a" in - update) args+=("--update") ;; - *) args+=("$a") ;; - esac -done - -for py in python3 python; do - if command -v "$py" &>/dev/null; then - exec "$py" "${SCRIPT_DIR}/install.py" "${args[@]}" - fi -done - -echo "install.sh: python3 not found. Install Python 3: https://www.python.org/downloads/" >&2 -exit 1 diff --git a/launch_lucy.sh b/launch_lucy.sh index 5c0af4f..59c8c76 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Start the Lucy stack (ROS 2 Jazzy + Control Center launcher) via Pixi. # -# Prerequisite: ./install.sh +# Prerequisite: python3 install.py # # Usage: # ./launch_lucy.sh tmux + Control Center launcher (default) @@ -14,8 +14,6 @@ # Ports published on the host: rosbridge 9090, control panel PORT_CONTROL_PANEL (defaults to # VITE_PORT from src/lucy_control_panel/.env, else 4004). Vite proxies /rosbridge to the bridge. # -# Docker platform follows the last ./install.sh run (.lucy-docker-platform; override with LUCY_DOCKER_PLATFORM). -# GPU mode is auto-detected via docker/gpu_detect.sh (jetson / nvidia / dri / software). set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -99,7 +97,7 @@ vite_scheme_from_envfile() { check_cmd pixi if [[ ! -f "$SCRIPT_DIR/install/setup.bash" && ! -f "$SCRIPT_DIR/install/setup.bat" ]]; then - echo "Workspace not built. Run ./install.sh or Install in Lucy.py" >&2 + echo "Workspace not built. Run python3 install.py or Install in Lucy.py" >&2 exit 1 fi diff --git a/pixi.toml b/pixi.toml index b05a117..1a976af 100644 --- a/pixi.toml +++ b/pixi.toml @@ -48,7 +48,7 @@ ros-jazzy-simulation-interfaces = "*" ros-jazzy-backward-ros = "*" # RealSense is not a Pixi dependency — build locally when needed: -# ./scripts/build_local_realsense.sh (or LUCY_BUILD_REALSENSE=1 ./install.sh) +# ./scripts/build_local_realsense.sh (or LUCY_BUILD_REALSENSE=1 python3 install.py) [feature.ros.target.osx-arm64.dependencies] pygraphviz = "*" @@ -108,7 +108,7 @@ panel-dev = { cmd = "yarn dev", cwd = "src/lucy_control_panel" } panel-build = { cmd = "yarn build", cwd = "src/lucy_control_panel" } [feature.dev.tasks] -doctor = "ros2 doctor --report" +doctor = "python scripts/ros_doctor.py" workspace-test = { cmd = "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest tests -q -p pytester -p python -p terminal" } shell = "bash scripts/pixi_dev_shell.sh" diff --git a/scripts/build_local_realsense.sh b/scripts/build_local_realsense.sh index f1d6148..c93ac5f 100755 --- a/scripts/build_local_realsense.sh +++ b/scripts/build_local_realsense.sh @@ -11,7 +11,7 @@ # # Run after a normal workspace build (install/setup.bash must exist). Does not # replace pixi run build — it adds librealsense + realsense-ros to install/. -# install.sh runs this when LUCY_BUILD_REALSENSE=1 (after colcon + panel-install). +# install.py runs this when LUCY_BUILD_REALSENSE=1 (after colcon + panel-install). # # Uses a portable CPU count for cmake -j (nproc on Linux, sysctl on macOS). diff --git a/scripts/pixi_lucy_launch.py b/scripts/pixi_lucy_launch.py index 4581864..2d1be10 100644 --- a/scripts/pixi_lucy_launch.py +++ b/scripts/pixi_lucy_launch.py @@ -5,11 +5,63 @@ import os import subprocess import sys +import threading +import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent JOINT_COMMAND_TOPIC = "/lucy/commanded_joint_states" +JOINT_STATES_TOPIC = "/joint_states" +# Long enough for a working ros2_control to have spawned its broadcaster. +FALLBACK_DELAY_S = 25.0 +DISCOVERY_SETTLE_S = 3.0 +WINDOWS_EXEC_SUFFIXES = (".exe", ".bat", ".cmd") + + +def node_argv(package: str, executable: str) -> list[str]: + """Absolute argv for a node, bypassing the ``ros2 run`` wrapper. + + That wrapper survives its own node, so stopping it leaves the node publishing. + """ + from ament_index_python.packages import get_package_prefix + + lib_dir = Path(get_package_prefix(package)) / "lib" / package + matches = sorted( + (p for p in lib_dir.glob("*") if p.is_file() and p.stem == executable), + key=lambda p: 0 if not p.suffix or p.suffix.lower() in WINDOWS_EXEC_SUFFIXES else 1, + ) + if not matches: + raise RuntimeError(f"{package}/{executable} not found in {lib_dir}") + best = matches[0] + if os.name == "nt" and best.suffix.lower() not in WINDOWS_EXEC_SUFFIXES: + return [sys.executable, str(best)] + return [str(best)] + + +def joint_states_publisher_count() -> int: + """How many nodes already publish /joint_states (0 if it cannot be read). + + Not via `ros2 topic info`: its daemon inherits the pipe and capturing the + output then blocks forever, even after the timeout kills the CLI. + """ + import rclpy + + try: + rclpy.init(args=[]) + except Exception: + return 0 + try: + node = rclpy.create_node("lucy_joint_state_probe") + try: + time.sleep(DISCOVERY_SETTLE_S) + return node.count_publishers(JOINT_STATES_TOPIC) + finally: + node.destroy_node() + except Exception: + return 0 + finally: + rclpy.try_shutdown() def configure_windows_dll_search_path() -> None: @@ -101,7 +153,7 @@ def _start_joint_state_fallback(): commands = [ [sys.executable, str(ROOT / "scripts" / "joint_command_echo.py")], [ - "ros2", "run", "joint_state_publisher", "joint_state_publisher", + *node_argv("joint_state_publisher", "joint_state_publisher"), "--ros-args", "-p", f"source_list:=['{JOINT_COMMAND_TOPIC}']", ], ] @@ -114,6 +166,32 @@ def _start_joint_state_fallback(): return started +def _start_joint_state_fallback_when_needed(started: list) -> threading.Thread: + """Start the stand-in only once nothing else drives /joint_states. + + A second publisher makes the panel alternate between the two poses. + """ + def wait_then_start() -> None: + cancelled.wait(FALLBACK_DELAY_S) + if cancelled.is_set(): + return + existing = joint_states_publisher_count() + if existing: + print( + f"{JOINT_STATES_TOPIC} already has {existing} publisher(s); not " + "starting the stand-in, which would fight them.", + file=sys.stderr, + ) + return + started.extend(_start_joint_state_fallback()) + + cancelled = threading.Event() + thread = threading.Thread(target=wait_then_start, daemon=True) + thread.cancelled = cancelled + thread.start() + return thread + + def _stop(proc) -> None: if proc is None or proc.poll() is not None: return @@ -139,10 +217,18 @@ def main() -> int: *sys.argv[1:], ] - fallback = _start_joint_state_fallback() if _joint_state_fallback_enabled() else [] + fallback: list = [] + waiter = ( + _start_joint_state_fallback_when_needed(fallback) + if _joint_state_fallback_enabled() + else None + ) try: return subprocess.call(command, cwd=ROOT) finally: + if waiter is not None: + waiter.cancelled.set() + waiter.join(timeout=5) for proc in fallback: _stop(proc) diff --git a/scripts/ros_doctor.py b/scripts/ros_doctor.py new file mode 100644 index 0000000..e4b58e2 --- /dev/null +++ b/scripts/ros_doctor.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Run `ros2 doctor --report` without letting DDS discovery hang the caller. + +Where multicast cannot be routed the middleware wait never ends; a macOS runner +burnt its whole 2h limit on it. Scope discovery to localhost and cap the run. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +TIMEOUT_S = float(os.environ.get("LUCY_DOCTOR_TIMEOUT_SEC", "180")) + +# Setting the deprecated ROS_LOCALHOST_ONLY as well would take precedence and +# make rcl ignore this one, so leave it alone. +LOCALHOST_ENV = {"ROS_AUTOMATIC_DISCOVERY_RANGE": "LOCALHOST"} + + +def main() -> int: + env = {**os.environ, **LOCALHOST_ENV} + command = ["ros2", "doctor", "--report", *sys.argv[1:]] + try: + return subprocess.run(command, env=env, timeout=TIMEOUT_S, check=False).returncode + except subprocess.TimeoutExpired: + print( + f"ros2 doctor did not finish within {TIMEOUT_S:.0f}s even with discovery " + "scoped to localhost — the middleware is stuck, not slow.", + file=sys.stderr, + ) + return 1 + except OSError as exc: + print(f"could not run {command[0]}: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_jetson_platform.py b/tests/test_jetson_platform.py index 6edbaa3..b8f3e3e 100644 --- a/tests/test_jetson_platform.py +++ b/tests/test_jetson_platform.py @@ -2,6 +2,7 @@ import os import subprocess +import sys from pathlib import Path import pytest @@ -15,6 +16,12 @@ WORKSPACE_ROOT = Path(__file__).resolve().parent.parent DETECT_JETSON_SH = WORKSPACE_ROOT / "scripts" / "detect_jetson.sh" +# On Windows "bash" resolves to System32ash.exe (the WSL launcher), which +# exits 1 with no distro installed and never runs the script. +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="POSIX-only shell helper" +) + def _run_detect_jetson(env: dict | None = None) -> int: script = ( @@ -61,7 +68,7 @@ def test_is_jetson_from_tegra_release(monkeypatch, tmp_path): monkeypatch.delenv("LUCY_GPU_MODE", raising=False) def fake_is_file(self): - return str(self) == "/etc/nv_tegra_release" + return self.as_posix() == "/etc/nv_tegra_release" monkeypatch.setattr( "launcher.platform.Path.is_file", fake_is_file @@ -80,13 +87,16 @@ def test_ensure_headless_runtime_dir_creates_private_dir(monkeypatch, tmp_path): created = ensure_headless_runtime_dir() assert created == str(runtime) assert runtime.is_dir() - assert oct(runtime.stat().st_mode & 0o777) == oct(0o700) + if sys.platform != "win32": + assert oct(runtime.stat().st_mode & 0o777) == oct(0o700) +@posix_only def test_shell_detect_jetson_matches_python_for_jetson_mode(): assert _run_detect_jetson({"LUCY_GPU_MODE": "jetson"}) == 0 +@posix_only @pytest.mark.skipif(not DETECT_JETSON_SH.is_file(), reason="detect_jetson.sh missing") def test_shell_detect_jetson_rejects_disabled_mode(): assert _run_detect_jetson({"LUCY_GPU_MODE": "0"}) != 0 diff --git a/tests/test_no_ros2_cli_spawn.py b/tests/test_no_ros2_cli_spawn.py new file mode 100644 index 0000000..0075274 --- /dev/null +++ b/tests/test_no_ros2_cli_spawn.py @@ -0,0 +1,74 @@ +"""Guard against spawning ROS nodes through the `ros2` CLI. + +`ros2 run` and `ros2 launch` start the real node as a child of themselves, and +on Windows a console-script shim adds another layer. Signalling the handle you +hold then leaves the node running, and capturing its output blocks past the +timeout because the surviving child still holds the pipe. Resolve the +executable and run it directly instead (`node_argv`), or ask the graph with +rclpy rather than `ros2 topic`. +""" + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SKIP_DIRS = {".pixi", "src", "build", "install", "log", "node_modules", ".git", "dist"} + +# Deliberate uses, with the reason each one cannot orphan a node or block. +ALLOWED = { + # Top-level blocking call: launch owns the terminal and its own shutdown. + ("scripts/pixi_lucy_launch.py", "launch"), + # Output is inherited, not piped, so nothing can block on a surviving child. + ("scripts/ros_doctor.py", "doctor"), +} + + +def _python_files(): + for path in sorted(ROOT.rglob("*.py")): + if not any(part in SKIP_DIRS for part in path.relative_to(ROOT).parts): + yield path + + +def _ros2_invocations(path: Path): + """(verb, lineno) for every list literal that starts with "ros2".""" + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: + return + for node in ast.walk(tree): + if not isinstance(node, ast.List) or not node.elts: + continue + head = node.elts[0] + if isinstance(head, ast.Constant) and head.value == "ros2": + verb = node.elts[1].value if len(node.elts) > 1 and isinstance( + node.elts[1], ast.Constant + ) else "?" + yield verb, node.lineno + + +@pytest.mark.parametrize("path", list(_python_files()), ids=lambda p: str(p.name)) +def test_no_unreviewed_ros2_cli_invocation(path): + rel = path.relative_to(ROOT).as_posix() + unexpected = [ + f"{rel}:{lineno} builds `ros2 {verb} ...`" + for verb, lineno in _ros2_invocations(path) + if (rel, verb) not in ALLOWED + ] + assert not unexpected, ( + "\n".join(unexpected) + + "\n\nThe `ros2` CLI runs the node as its own child, so signalling or " + "timing out what you hold orphans it. Resolve the executable and run it " + "directly, or add it to ALLOWED here with the reason it is safe." + ) + + +def test_allowlist_entries_still_exist(): + """A stale allowlist silently stops guarding.""" + found = { + (path.relative_to(ROOT).as_posix(), verb) + for path in _python_files() + for verb, _ in _ros2_invocations(path) + } + assert ALLOWED <= found, f"allowlist no longer matches the code: {ALLOWED - found}" diff --git a/tests/test_repos_config.py b/tests/test_repos_config.py index cddb185..81a181b 100644 --- a/tests/test_repos_config.py +++ b/tests/test_repos_config.py @@ -1,4 +1,4 @@ -"""Tests for install.sh repos.json parsing (HTTPS vs SSH, optional entries).""" +"""Tests for install.py repos.json parsing (HTTPS vs SSH, optional entries).""" import json import os diff --git a/windows/README.md b/windows/README.md index dbc0f4e..d24d68e 100644 --- a/windows/README.md +++ b/windows/README.md @@ -58,7 +58,7 @@ python windows\Lucy.py --cli install --repos-branch master Or use Pixi directly from Git Bash / WSL: ```bash -./install.sh +python3 install.py pixi run build pixi run panel-install ``` @@ -105,4 +105,4 @@ python -c "from PIL import Image; Image.open('path\to\lucy-logo.jpg').save('wind ## Terminal choice - **Native Windows:** `Lucy.exe` (installed) or `python windows/Lucy.py` (from a clone) — uses Git Bash for launch. -- **Git Bash / WSL:** root `Lucy.py`, `install.sh`, and `launch_lucy.sh` (recommended for developers). +- **Git Bash / WSL:** root `Lucy.py`, `install.py`, and `launch_lucy.sh` (recommended for developers). diff --git a/windows/installer/Lucy.nsi b/windows/installer/Lucy.nsi index 7d55b0a..47b3700 100644 --- a/windows/installer/Lucy.nsi +++ b/windows/installer/Lucy.nsi @@ -255,7 +255,7 @@ Section "Install" File "..\..\dist\${MyAppExeName}" File "..\..\pixi.toml" File "..\..\pixi.lock" - File "..\..\install.sh" + File "..\..\install.py" File "..\..\launch_lucy.sh" File "..\..\Lucy.py" File "..\..\README.md"