From 353c93b5d6eb0af0786c835ca09c4d49f4ff75b8 Mon Sep 17 00:00:00 2001 From: Arcod7 Date: Wed, 2 Sep 2026 00:52:01 +0200 Subject: [PATCH 1/4] fix(pixi): bound ros2 doctor, green the Windows tests, stop stacking publishers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ros2 doctor --report` waits on the middleware, and on the macOS runner Cyclone picked a virtual interface and failed every multicast write to 239.255.0.1, so the job sat there for the full two hours. Scoping discovery to localhost gets a report in 9s. Pixi task tables take only cmd/depends-on/ description/args, so there is nowhere to hang env off a task; scripts/ ros_doctor.py sets it and also caps the runtime, so a stuck middleware can never cost two hours again. The headless launch smoke test ran the same bare command, so point it at the script too. tests: the tegra-release probe compared a str() of a Path, which is backslash-separated on Windows and never matched; compare as posix. Windows has no POSIX mode bits, so only assert 0o700 where it means something. The two detect_jetson.sh tests shell out to `bash`, which on Windows resolves to the WSL launcher — one failed outright and the other passed only because WSL also exits non-zero. Both are POSIX-only, so skip them honestly. pixi_lucy_launch: the /joint_states stand-in was started through `ros2 run`, which leaves the node running when the wrapper is terminated; five of them had piled up from earlier runs, all publishing stale poses against the live stack. Run the node directly, and only start it once the real stack has had time to come up and the topic still has no publisher. --- .github/workflows/install-and-launch.yml | 2 +- pixi.toml | 2 +- scripts/pixi_lucy_launch.py | 82 +++++++++++++++++++++++- scripts/ros_doctor.py | 42 ++++++++++++ tests/test_jetson_platform.py | 14 +++- 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 scripts/ros_doctor.py diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml index fbb86c2..274edce 100644 --- a/.github/workflows/install-and-launch.yml +++ b/.github/workflows/install-and-launch.yml @@ -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/pixi.toml b/pixi.toml index b05a117..77f51ee 100644 --- a/pixi.toml +++ b/pixi.toml @@ -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/pixi_lucy_launch.py b/scripts/pixi_lucy_launch.py index 4581864..6c75fc2 100644 --- a/scripts/pixi_lucy_launch.py +++ b/scripts/pixi_lucy_launch.py @@ -5,11 +5,53 @@ import os import subprocess import sys +import threading 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 +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. + + ``ros2 run`` runs the node as a child of itself, so terminating the wrapper + leaves the node publishing. Repeated runs then pile up stand-ins that all + write /joint_states with stale poses. + """ + 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).""" + try: + out = subprocess.run( + ["ros2", "topic", "info", JOINT_STATES_TOPIC], + capture_output=True, text=True, timeout=30, + ).stdout + except (OSError, subprocess.SubprocessError): + return 0 + for line in out.splitlines(): + if line.lower().startswith("publisher count:"): + return int(line.split(":", 1)[1].strip() or 0) + return 0 def configure_windows_dll_search_path() -> None: @@ -101,7 +143,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 +156,34 @@ def _start_joint_state_fallback(): return started +def _start_joint_state_fallback_when_needed(started: list) -> threading.Thread: + """Start the stand-in only once it is clear nothing else drives /joint_states. + + A second publisher makes the panel alternate between the two poses, so wait + for the real stack to settle and check the graph before joining it. Also + catches publishers left over from an earlier run. + """ + 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 +209,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..9739843 --- /dev/null +++ b/scripts/ros_doctor.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Run `ros2 doctor --report` without letting DDS discovery hang the caller. + +`ros2 doctor` waits on the middleware, and on hosts where multicast cannot be +routed the wait never ends: on a macOS CI runner Cyclone picked a virtual +interface, failed every `ddsi_udp_conn_write` to 239.255.0.1, and the job sat +there until the 2h limit. Scope discovery to localhost so the report is about +this machine, and cap the runtime so a stuck middleware fails fast. +""" + +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 From 779c4adad29c8af866aada3031b94d8cddca90d0 Mon Sep 17 00:00:00 2001 From: Arcod7 Date: Wed, 2 Sep 2026 01:48:58 +0200 Subject: [PATCH 2/4] evol(install): drop install.sh, fix Windows output, gate the joint-state stand-in install.sh only exec'd install.py, so the wrapper is gone and CI, README, the developer guide, launch_lucy.sh, Lucy.py and build_local_realsense.sh now call `python3 install.py` directly. launch_lucy.sh also lost two lines describing a docker/ tree this branch deleted. install.py printed em dashes that a cp1252 Windows console turns into mojibake, and finished by pointing at ./launch_lucy.sh, which does not run on Windows; there it now names the pixi tasks instead. The /joint_states stand-in never started: it probed the topic with `ros2 topic info`, whose daemon inherits the pipe, so capturing the output blocked forever even after the timeout killed the CLI. Ask the graph directly instead. repos.json points at the matching fix branches; move it back to feat-pixi / dev once those merge. Also shortens the .env.example DDS note and the Windows section of the README, which claimed the robot follows the sliders and is not driveable in one breath. --- .env.example | 6 ++-- .github/workflows/install-and-launch.yml | 8 ++--- .gitignore | 2 +- Lucy.py | 4 +-- README.md | 8 ++--- config/repos.json | 4 +-- docs/developer_lucy_packages.md | 30 ++++++++--------- docs/pixi_setup.md | 2 +- install.py | 26 ++++++++------- install.sh | 32 ------------------ launch_lucy.sh | 6 ++-- pixi.toml | 2 +- scripts/build_local_realsense.sh | 2 +- scripts/pixi_lucy_launch.py | 42 ++++++++++++++---------- scripts/ros_doctor.py | 7 ++-- tests/test_repos_config.py | 2 +- windows/README.md | 4 +-- 17 files changed, 80 insertions(+), 107 deletions(-) delete mode 100755 install.sh 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 274edce..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 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 77f51ee..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 = "*" 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 6c75fc2..2d1be10 100644 --- a/scripts/pixi_lucy_launch.py +++ b/scripts/pixi_lucy_launch.py @@ -6,6 +6,7 @@ import subprocess import sys import threading +import time from pathlib import Path @@ -14,15 +15,14 @@ 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. - ``ros2 run`` runs the node as a child of itself, so terminating the wrapper - leaves the node publishing. Repeated runs then pile up stand-ins that all - write /joint_states with stale poses. + That wrapper survives its own node, so stopping it leaves the node publishing. """ from ament_index_python.packages import get_package_prefix @@ -40,18 +40,28 @@ def node_argv(package: str, executable: str) -> list[str]: def joint_states_publisher_count() -> int: - """How many nodes already publish /joint_states (0 if it cannot be read).""" + """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: - out = subprocess.run( - ["ros2", "topic", "info", JOINT_STATES_TOPIC], - capture_output=True, text=True, timeout=30, - ).stdout - except (OSError, subprocess.SubprocessError): + rclpy.init(args=[]) + except Exception: return 0 - for line in out.splitlines(): - if line.lower().startswith("publisher count:"): - return int(line.split(":", 1)[1].strip() or 0) - 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: @@ -157,11 +167,9 @@ def _start_joint_state_fallback(): def _start_joint_state_fallback_when_needed(started: list) -> threading.Thread: - """Start the stand-in only once it is clear nothing else drives /joint_states. + """Start the stand-in only once nothing else drives /joint_states. - A second publisher makes the panel alternate between the two poses, so wait - for the real stack to settle and check the graph before joining it. Also - catches publishers left over from an earlier run. + A second publisher makes the panel alternate between the two poses. """ def wait_then_start() -> None: cancelled.wait(FALLBACK_DELAY_S) diff --git a/scripts/ros_doctor.py b/scripts/ros_doctor.py index 9739843..e4b58e2 100644 --- a/scripts/ros_doctor.py +++ b/scripts/ros_doctor.py @@ -1,11 +1,8 @@ #!/usr/bin/env python3 """Run `ros2 doctor --report` without letting DDS discovery hang the caller. -`ros2 doctor` waits on the middleware, and on hosts where multicast cannot be -routed the wait never ends: on a macOS CI runner Cyclone picked a virtual -interface, failed every `ddsi_udp_conn_write` to 239.255.0.1, and the job sat -there until the 2h limit. Scope discovery to localhost so the report is about -this machine, and cap the runtime so a stuck middleware fails fast. +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 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). From 5183650e78b57fb28f63462b7c5b5bf89edafe29 Mon Sep 17 00:00:00 2001 From: Arcod7 Date: Wed, 2 Sep 2026 02:09:49 +0200 Subject: [PATCH 3/4] fix(installer): package install.py, not the deleted install.sh The NSIS script still listed install.sh as a fatal File entry, so both Windows installer jobs failed at makensis with "no files found". My earlier sweep for references filtered by extension and never looked at .nsi. --- windows/installer/Lucy.nsi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 2e44f144f72143399c41d97c5612b0964acef64a Mon Sep 17 00:00:00 2001 From: Arcod7 Date: Wed, 2 Sep 2026 02:47:49 +0200 Subject: [PATCH 4/4] test: guard against spawning ROS nodes through the ros2 CLI The `ros2` CLI runs the node as its own child, so signalling or timing out the handle you hold orphans it. That one mistake caused five separate bugs on this branch. Fail the suite on any new `ros2 ...` argv built in this repo, with an allowlist naming why the two existing ones cannot orphan or block. Verified it fails: a planted `ros2 run` is reported as file:line. --- tests/test_no_ros2_cli_spawn.py | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/test_no_ros2_cli_spawn.py 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}"