diff --git a/plugin/scripts/dashboard-service.sh b/plugin/scripts/dashboard-service.sh index 396a750..8c840b4 100755 --- a/plugin/scripts/dashboard-service.sh +++ b/plugin/scripts/dashboard-service.sh @@ -126,9 +126,16 @@ spawn_dashboard() { fi cd "$DASHBOARD_DIR" export CLAUDE_SMART_DASHBOARD_WORKSPACE="$WORKSPACE_CWD" - # Invoke Next directly so custom DASHBOARD_PORT/PORT values are honored - # without relying on package.json's fixed start script. - CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached "$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 >>"$LOG_FILE" 2>&1 + # Run next-server under dashboard-supervise.sh so a later silent death (crash, + # or a macOS jetsam pressure-kill of next-server) self-heals within seconds + # instead of waiting for the next SessionStart hook to notice a dead marker. + # The supervisor is the detached session leader and is what we record in + # dashboard.pid, so `stop`'s process-group kill takes it and its next-server + # child down together — an intentional stop never triggers a respawn. Next is + # still invoked directly (inside the supervisor) so custom DASHBOARD_PORT/PORT + # values are honored without relying on package.json's fixed start script. + CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached \ + bash "$HERE/dashboard-supervise.sh" "$NEXT_BIN" "$PORT" >>"$LOG_FILE" 2>&1 dash_pid=$! echo "$dash_pid" > "$PID_FILE" } @@ -214,28 +221,13 @@ case "$CMD" in fi if wait_for_dashboard_marker 5; then claude_smart_clear_dashboard_unavailable - emit_ok; exit 0 - fi - if ! kill -0 "$dash_pid" 2>/dev/null; then - sleep 2 - if port_occupied; then - if marker_responds; then - claude_smart_clear_dashboard_unavailable - else - claude_smart_write_dashboard_unavailable "dashboard process exited and port $PORT is now held by a non-claude-smart listener; see $LOG_FILE" - fi - else - echo "[claude-smart] dashboard: first start exited before readiness; retrying once" >>"$LOG_FILE" - if spawn_dashboard; then - if wait_for_dashboard_marker 5; then - claude_smart_clear_dashboard_unavailable - else - claude_smart_write_dashboard_unavailable "dashboard process spawned but did not respond on http://127.0.0.1:$PORT within 5s; see $LOG_FILE" - fi - fi - fi else - claude_smart_write_dashboard_unavailable "dashboard process spawned but did not respond on http://127.0.0.1:$PORT within 5s; see $LOG_FILE" + # next-server did not answer within 5s. The supervisor keeps retrying it + # in the background (bounded by its crash-loop guard), so surface a soft + # "unavailable" marker for now; a later SessionStart clears it once the + # marker responds. We do NOT re-spawn here — that would start a second + # supervisor racing for the port. + claude_smart_write_dashboard_unavailable "dashboard is still starting or did not respond on http://127.0.0.1:$PORT within 5s; see $LOG_FILE" fi emit_ok ;; diff --git a/plugin/scripts/dashboard-supervise.sh b/plugin/scripts/dashboard-supervise.sh new file mode 100755 index 0000000..b924e2d --- /dev/null +++ b/plugin/scripts/dashboard-supervise.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Supervisor for the claude-smart Next.js dashboard. +# +# Problem it solves: dashboard-service.sh used to fire `next start` detached and +# forget about it. When that process died later (a crash, or a macOS jetsam +# pressure-kill of next-server), nothing restarted it until the next *real* +# SessionStart hook happened to run `start` again — leaving the dashboard down +# for as long as Claude Code stayed closed. This wrapper respawns next-server so +# a silent death self-heals within seconds. +# +# Lifecycle: dashboard-service.sh spawn_dashboard() launches this detached as the +# session leader (setsid) and records THIS pid in dashboard.pid. `stop` +# group-kills that pid, so the TERM trap below breaks the loop and the +# next-server child (same process group) is torn down with us — an intentional +# stop never triggers a respawn. +# +# Crash-loop guard: a broken build that starts and immediately exits must not be +# respawned forever. Exits faster than HEALTHY_SECS are counted as consecutive +# fast failures; after MAX_FAILS of them the supervisor gives up (exit 1) and +# leaves recovery to the next SessionStart. Any run that stays up at least +# HEALTHY_SECS resets the counter, so a long-lived dashboard that eventually +# dies still self-heals. +# +# Note (deliberate limitation): this is an in-process supervisor. A system-wide +# memory-pressure kill can take the whole process group (supervisor + child) +# down at once, in which case recovery still falls to the next SessionStart. A +# launchd/OS-level supervisor would survive that; it is intentionally left as a +# follow-up. +# +# Tunables (env; defaults chosen for production, overridden in tests): +# CLAUDE_SMART_DASHBOARD_RESPAWN_DELAY seconds to wait between respawns (2) +# CLAUDE_SMART_DASHBOARD_HEALTHY_SECS uptime that counts as healthy (30) +# CLAUDE_SMART_DASHBOARD_MAX_FAILS consecutive fast exits allowed (5) +set -u + +NEXT_BIN="${1:?dashboard-supervise.sh: next binary path required}" +PORT="${2:?dashboard-supervise.sh: port required}" + +RESPAWN_DELAY="${CLAUDE_SMART_DASHBOARD_RESPAWN_DELAY:-2}" +HEALTHY_SECS="${CLAUDE_SMART_DASHBOARD_HEALTHY_SECS:-30}" +MAX_FAILS="${CLAUDE_SMART_DASHBOARD_MAX_FAILS:-5}" + +# On stop (TERM/INT — normally delivered to the whole process group by +# `dashboard-service.sh stop`) tear down the current next-server and exit the +# loop instead of respawning. next-server runs in the background and we `wait` +# on it so the trap fires immediately, even if only this supervisor is signaled. +child="" +on_stop() { + [ -n "$child" ] && kill -TERM "$child" 2>/dev/null + echo "[claude-smart] dashboard supervisor: received stop signal; exiting" + exit 0 +} +trap on_stop TERM INT + +fails=0 +while true; do + started="$(date +%s 2>/dev/null || echo 0)" + "$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 & + child=$! + wait "$child" + code=$? + ended="$(date +%s 2>/dev/null || echo 0)" + + # 128+15=143: next-server was SIGTERM'd on its own — normally the port reaper + # in `dashboard-service.sh stop`/reinstall when it could not see this + # supervisor's pid (e.g. a stop issued from a different HOME). Respect an + # intentional stop instead of resurrecting the dashboard. Genuine crashes and + # jetsam SIGKILLs (137) fall through to the respawn path below. (A stop that + # *does* see our pid group-kills this supervisor, handled by the trap above.) + if [ "$code" -eq 143 ]; then + echo "[claude-smart] dashboard supervisor: next-server received SIGTERM (intentional stop); exiting without respawn" + exit 0 + fi + + if [ "$((ended - started))" -ge "$HEALTHY_SECS" ]; then + fails=0 + else + fails=$((fails + 1)) + fi + + if [ "$fails" -ge "$MAX_FAILS" ]; then + echo "[claude-smart] dashboard supervisor: next-server exited (code $code); ${MAX_FAILS} fast failures in a row — giving up (recovery deferred to next SessionStart)" + exit 1 + fi + + echo "[claude-smart] dashboard supervisor: next-server exited (code $code); respawning in ${RESPAWN_DELAY}s (consecutive fast failures: $fails)" + sleep "$RESPAWN_DELAY" +done diff --git a/plugin/scripts/ensure-plugin-root.sh b/plugin/scripts/ensure-plugin-root.sh index 27f12e3..99d20b7 100755 --- a/plugin/scripts/ensure-plugin-root.sh +++ b/plugin/scripts/ensure-plugin-root.sh @@ -84,6 +84,19 @@ write_plugin_root_metadata() { printf '%s\n' "$TARGET" >"$HOME/.reflexio/plugin-root.txt" } +# Keep plugin-root.txt in sync with the symlink for the file-based readers +# (OpenCode server.mts fallback, Codex/Windows-junction consumers) even on the +# paths where we leave an already-valid link untouched. Without this the file +# drifts: the symlink is refreshed every SessionStart but plugin-root.txt was +# only ever written at install time, so after a version change it can point at a +# stale (even deleted) root while the symlink is correct. +reconcile_metadata_from_link() { + [ -L "$LINK" ] || return 0 + linked="$(cd "$LINK" 2>/dev/null && pwd -P || true)" + [ -n "$linked" ] || return 0 + printf '%s\n' "$linked" >"$HOME/.reflexio/plugin-root.txt" +} + write_plugin_root_link() { reason="$1" if claude_smart_is_windows; then @@ -115,6 +128,7 @@ write_plugin_root_link() { fi ln -sfn "$TARGET" "$LINK" + write_plugin_root_metadata echo "[claude-smart] plugin-root → $TARGET${reason:+ ($reason)}" >&2 } @@ -162,6 +176,8 @@ if [ -n "$CURRENT" ]; then TARGET_NORM="${TARGET%/}" if [ "$CURRENT_NORM" != "$TARGET_NORM" ]; then write_plugin_root_link "cache-tracking, was $CURRENT" + else + reconcile_metadata_from_link fi exit 0 ;; @@ -171,6 +187,7 @@ fi # Self-heal path: only rewrite the link if it's missing or its target is # gone/invalid. if [ -f "$LINK/pyproject.toml" ]; then + reconcile_metadata_from_link exit 0 fi diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index 93b71be..d9db2a4 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -534,9 +534,11 @@ def test_detached_spawns_with_log_redirection_preserve_output_on_windows() -> No "CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached env " "CLAUDE_SMART_BOOTSTRAPPING=1" ) in dashboard + # The dashboard is now spawned under the respawn supervisor, but still with + # output preserved and redirected to the shared dashboard log. + assert "CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached" in dashboard assert ( - "CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached " - '"$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 >>"$LOG_FILE" 2>&1' + 'bash "$HERE/dashboard-supervise.sh" "$NEXT_BIN" "$PORT" >>"$LOG_FILE" 2>&1' in dashboard ) @@ -5769,6 +5771,123 @@ def test_dashboard_build_writes_marker_when_npm_missing(tmp_path: Path) -> None: assert "npm is not on PATH" in marker.read_text() +def test_dashboard_service_supervises_next_for_self_heal() -> None: + """dashboard-service.sh runs next-server under the respawn supervisor.""" + service = (REPO_ROOT / "plugin" / "scripts" / "dashboard-service.sh").read_text() + assert "dashboard-supervise.sh" in service + + supervise = ( + REPO_ROOT / "plugin" / "scripts" / "dashboard-supervise.sh" + ).read_text() + # Clean stop: a TERM/INT trap must break the loop so `stop`'s group-kill + # never triggers a respawn. + assert "trap" in supervise + assert "TERM" in supervise and "INT" in supervise + # Crash-loop guard must be present and tunable. + assert "CLAUDE_SMART_DASHBOARD_MAX_FAILS" in supervise + assert "CLAUDE_SMART_DASHBOARD_HEALTHY_SECS" in supervise + # Must not resurrect a dashboard that was intentionally stopped (child + # SIGTERM == 143) — only crashes/SIGKILL are respawned. + assert "143" in supervise + + +def test_dashboard_supervisor_exits_on_intentional_stop_signal( + tmp_path: Path, +) -> None: + """When next-server exits via SIGTERM (143) — i.e. a `stop`/reinstall port + reap — the supervisor exits without respawning, rather than fighting it.""" + next_bin = tmp_path / "next" + _write_executable(next_bin, '#!/bin/sh\necho x >> "$HOME/calls"\nexit 143\n') + env = _isolated_env(tmp_path) + env["PATH"] = _minimal_path(tmp_path, "date", "sleep") + result = subprocess.run( + [ + "/bin/bash", + "--noprofile", + "--norc", + str(REPO_ROOT / "plugin" / "scripts" / "dashboard-supervise.sh"), + str(next_bin), + "3999", + ], + env=env, + text=True, + capture_output=True, + check=False, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "calls").read_text().count("x") == 1 + assert "intentional stop" in result.stdout + + +def test_dashboard_supervisor_respawns_then_gives_up_on_crash_loop( + tmp_path: Path, +) -> None: + """A next-server that keeps dying fast is respawned up to MAX_FAILS, then + the supervisor gives up (exit 1) instead of tight-looping forever.""" + next_bin = tmp_path / "next" + _write_executable(next_bin, '#!/bin/sh\necho x >> "$HOME/calls"\nexit 1\n') + env = _isolated_env(tmp_path) + env["PATH"] = _minimal_path(tmp_path, "date", "sleep") + env["CLAUDE_SMART_DASHBOARD_RESPAWN_DELAY"] = "0" + env["CLAUDE_SMART_DASHBOARD_HEALTHY_SECS"] = "5" + env["CLAUDE_SMART_DASHBOARD_MAX_FAILS"] = "3" + result = subprocess.run( + [ + "/bin/bash", + "--noprofile", + "--norc", + str(REPO_ROOT / "plugin" / "scripts" / "dashboard-supervise.sh"), + str(next_bin), + "3999", + ], + env=env, + text=True, + capture_output=True, + check=False, + timeout=30, + ) + assert result.returncode == 1, result.stderr + calls = (tmp_path / "calls").read_text().count("x") + assert calls == 3, f"expected 3 spawns before giving up, got {calls}" + assert "giving up" in result.stdout + + +def test_ensure_plugin_root_syncs_stale_txt_to_valid_link(tmp_path: Path) -> None: + """A valid symlink with a stale plugin-root.txt (the observed multi-host + drift) must reconcile .txt to the link so the file-based readers agree.""" + target = tmp_path / "plugin" + (target / "scripts").mkdir(parents=True) + (target / "pyproject.toml").write_text("[project]\nname = 'x'\n") + reflexio = tmp_path / ".reflexio" + reflexio.mkdir() + link = reflexio / "plugin-root" + link.symlink_to(target) + stale = reflexio / "plugin-root.txt" + stale.write_text("/nonexistent/claude-smart/0.2.50\n") + + env = _isolated_env(tmp_path) + env["PATH"] = _minimal_path( + tmp_path, "dirname", "uname", "cat", "readlink", "mkdir", "ln" + ) + result = subprocess.run( + [ + "/bin/bash", + "--noprofile", + "--norc", + str(REPO_ROOT / "plugin" / "scripts" / "ensure-plugin-root.sh"), + str(target), + ], + env=env, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert stale.read_text().strip() == str(target.resolve()) + assert link.resolve() == target.resolve() + + def test_codex_claude_compat_translates_claude_contract(tmp_path: Path) -> None: output_file = tmp_path / "captured-output-path" codex = tmp_path / "codex"