From bf1a5c330397613e02380ad7513cf37cba85c7f2 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 26 May 2026 21:39:05 -0700 Subject: [PATCH] fix(install): bound finalization probes (#86997) Bounds nonessential installer finalization probes so npm prefix and daemon-status checks warn and fall back instead of hanging setup. Thanks @giodl73-repo! --- scripts/install.sh | 53 +++++++++++++++++++++-- test/scripts/install-sh.test.ts | 74 +++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index a417bff39bf4..430384f77010 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2269,7 +2269,7 @@ ensure_user_local_bin_on_path() { npm_global_bin_dir() { local prefix="" - prefix="$(npm prefix -g 2>/dev/null || true)" + prefix="$(bounded_probe_output "npm prefix -g" npm prefix -g || true)" if [[ -n "$prefix" ]]; then if [[ "$prefix" == /* ]]; then echo "${prefix%/}/bin" @@ -2277,7 +2277,7 @@ npm_global_bin_dir() { fi fi - prefix="$(npm config get prefix 2>/dev/null || true)" + prefix="$(bounded_probe_output "npm config get prefix" npm config get prefix || true)" if [[ -n "$prefix" && "$prefix" != "undefined" && "$prefix" != "null" ]]; then if [[ "$prefix" == /* ]]; then echo "${prefix%/}/bin" @@ -2496,6 +2496,51 @@ maybe_nodenv_rehash() { fi } +bounded_probe_output() { + local label="$1" + shift + local timeout_seconds="${OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS:-5}" + local output_file status_file timeout_file pid watchdog status + output_file="$(mktemp)" + status_file="$(mktemp)" + timeout_file="$(mktemp)" + TMPFILES+=("$output_file" "$status_file" "$timeout_file") + + ( + "$@" >"$output_file" 2>/dev/null + printf '%s' "$?" >"$status_file" + ) & + pid="$!" + + ( + sleep "$timeout_seconds" + if kill -0 "$pid" 2>/dev/null; then + printf '1' >"$timeout_file" + kill "$pid" 2>/dev/null || true + sleep 0.1 + kill -9 "$pid" 2>/dev/null || true + printf 'timeout' >"$status_file" + fi + ) & + watchdog="$!" + + wait "$pid" 2>/dev/null || true + kill "$watchdog" 2>/dev/null || true + wait "$watchdog" 2>/dev/null || true + + status="$(cat "$status_file" 2>/dev/null || true)" + if [[ -s "$timeout_file" || "$status" == "timeout" ]]; then + echo "Warning: timed out during installer finalization probe: ${label}" >&2 + return 124 + fi + + cat "$output_file" 2>/dev/null || true + if [[ -n "$status" && "$status" =~ ^[0-9]+$ ]]; then + return "$status" + fi + return 1 +} + warn_openclaw_not_found() { ui_warn "Installed, but openclaw is not discoverable on PATH in this shell" echo " Try: hash -r (bash) or rehash (zsh), then retry." @@ -2509,7 +2554,7 @@ warn_openclaw_not_found() { fi local npm_prefix="" - npm_prefix="$(npm prefix -g 2>/dev/null || true)" + npm_prefix="$(bounded_probe_output "npm prefix -g" npm prefix -g || true)" local npm_bin="" npm_bin="$(npm_global_bin_dir 2>/dev/null || true)" if [[ -n "$npm_prefix" ]]; then @@ -2906,7 +2951,7 @@ is_gateway_daemon_loaded() { fi local status_json="" - status_json="$("$claw" daemon status --json 2>/dev/null || true)" + status_json="$(bounded_probe_output "openclaw daemon status --json" "$claw" daemon status --json || true)" if [[ -z "$status_json" ]]; then return 1 fi diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index aba98c017aab..4cc98960c1ee 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -138,7 +138,9 @@ describe("install.sh", () => { expect(script).toContain( 'run_quiet_step "Installing Node.js" sudo apk add --no-cache nodejs npm', ); - expect(script).toContain('run_quiet_step "Installing nodejs-current" apk add --no-cache nodejs-current npm'); + expect(script).toContain( + 'run_quiet_step "Installing nodejs-current" apk add --no-cache nodejs-current npm', + ); expect(script).toContain("if ! node_is_at_least_required; then"); const apkIndex = script.indexOf("if command -v apk &> /dev/null && is_alpine_linux; then"); @@ -208,7 +210,9 @@ describe("install.sh", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("step:Installing Node.js|apk add --no-cache nodejs npm"); expect(result.stdout).toContain("warn:Alpine nodejs package installed v20.15.1"); - expect(result.stdout).toContain("step:Installing nodejs-current|apk add --no-cache nodejs-current npm"); + expect(result.stdout).toContain( + "step:Installing nodejs-current|apk add --no-cache nodejs-current npm", + ); expect(result.stdout).toContain("finish-linux-node"); }); @@ -247,8 +251,12 @@ describe("install.sh", () => { expect(result.status).toBe(1); expect(result.stdout).toContain("warn:Alpine nodejs package installed v20.15.1"); - expect(result.stdout).toContain("step:Installing nodejs-current|apk add --no-cache nodejs-current npm"); - expect(result.stdout).toContain("error:Alpine apk repositories did not provide Node.js v22.19+"); + expect(result.stdout).toContain( + "step:Installing nodejs-current|apk add --no-cache nodejs-current npm", + ); + expect(result.stdout).toContain( + "error:Alpine apk repositories did not provide Node.js v22.19+", + ); expect(result.stdout).toContain("Use Alpine 3.21+ or install Node.js 24 manually"); }); @@ -758,6 +766,64 @@ describe("install.sh", () => { expect(result.stdout).not.toContain("[4/3] Verifying installation"); }); + it("bounds installer npm prefix probes during finalization helpers", () => { + const result = runInstallShell( + [ + `source ${JSON.stringify(SCRIPT_PATH)}`, + "npm() {", + ' if [[ "$1" == "prefix" && "$2" == "-g" ]]; then sleep 2; return 0; fi', + ' if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then printf "/tmp/openclaw-npm\\n"; return 0; fi', + " return 1", + "}", + "npm_global_bin_dir", + ].join("\n"), + { OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.1" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/tmp/openclaw-npm/bin"); + expect(result.stderr).toContain("timed out during installer finalization probe: npm prefix -g"); + }); + + it("bounds daemon status probes during finalization helpers", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-probe-")); + const claw = join(tmp, "openclaw"); + writeFileSync( + claw, + [ + "#!/usr/bin/env bash", + 'if [[ "$1" == "daemon" && "$2" == "status" && "$3" == "--json" ]]; then', + " sleep 2", + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + ); + chmodSync(claw, 0o755); + try { + const result = runInstallShell( + [ + `source ${JSON.stringify(SCRIPT_PATH)}`, + `if is_gateway_daemon_loaded ${JSON.stringify(claw)}; then`, + ' printf "loaded\\n"', + "else", + ' printf "not-loaded\\n"', + "fi", + ].join("\n"), + { OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.1" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("not-loaded"); + expect(result.stderr).toContain( + "timed out during installer finalization probe: openclaw daemon status --json", + ); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("loads nvm before checking Node.js so stale system Node does not win", () => { expect(script).toMatch( /# Step 2: Node\.js\s+load_nvm_for_node_detection\s+if ! check_node; then/,