diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 4c68e2925..20ab2e15c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -84,6 +84,7 @@ verify_config_integrity() { fi } +# Write an auth profile JSON for the NVIDIA API key so the gateway can authenticate. write_auth_profile() { if [ -z "${NVIDIA_API_KEY:-}" ]; then return @@ -106,6 +107,7 @@ os.chmod(path, 0o600) PYAUTH } +# Print the local and remote dashboard URLs, appending the auth token if available. print_dashboard_urls() { local token chat_ui_base local_url remote_url @@ -202,7 +204,25 @@ while time.time() < DEADLINE: else: print(f'[auto-pair] watcher timed out approvals={APPROVED}') PYAUTOPAIR - echo "[gateway] auto-pair watcher launched (pid $!)" + AUTO_PAIR_PID=$! + echo "[gateway] auto-pair watcher launched (pid $AUTO_PAIR_PID)" +} + +# Forward SIGTERM/SIGINT to child processes for graceful shutdown. +# This script is PID 1 — without a trap, signals interrupt wait and +# children are orphaned until Docker sends SIGKILL after the grace period. +cleanup() { + echo "[gateway] received signal, forwarding to children..." + local gateway_status=0 + kill -TERM "$GATEWAY_PID" 2>/dev/null || true + if [ -n "${AUTO_PAIR_PID:-}" ]; then + kill -TERM "$AUTO_PAIR_PID" 2>/dev/null || true + fi + wait "$GATEWAY_PID" 2>/dev/null || gateway_status=$? + if [ -n "${AUTO_PAIR_PID:-}" ]; then + wait "$AUTO_PAIR_PID" 2>/dev/null || true + fi + exit "$gateway_status" } # ── Proxy environment ──────────────────────────────────────────── @@ -317,8 +337,10 @@ if [ "$(id -u)" -ne 0 ]; then nohup "$OPENCLAW" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" + trap cleanup SIGTERM SIGINT start_auto_pair print_dashboard_urls + wait "$GATEWAY_PID" exit $? fi @@ -366,6 +388,7 @@ done nohup gosu gateway "$OPENCLAW" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" +trap cleanup SIGTERM SIGINT start_auto_pair print_dashboard_urls diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index 24e0b2185..1cecbd277 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -36,3 +36,55 @@ describe("nemoclaw-start non-root fallback", () => { expect(calls.length).toBeGreaterThanOrEqual(3); // definition + 2 call sites }); }); + +describe("nemoclaw-start signal handling", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("defines cleanup() as a single top-level function", () => { + const matches = src.match(/^cleanup\(\)/gm); + expect(matches).toHaveLength(1); + }); + + it("cleanup() forwards SIGTERM to both GATEWAY_PID and AUTO_PAIR_PID", () => { + const cleanup = src.match(/cleanup\(\) \{[\s\S]*?^}/m)?.[0]; + expect(cleanup).toBeDefined(); + expect(cleanup).toMatch(/kill -TERM "\$GATEWAY_PID"/); + expect(cleanup).toMatch(/kill -TERM "\$AUTO_PAIR_PID"/); + }); + + it("cleanup() waits for both child processes", () => { + const cleanup = src.match(/cleanup\(\) \{[\s\S]*?^}/m)?.[0]; + expect(cleanup).toMatch(/wait "\$GATEWAY_PID"/); + expect(cleanup).toMatch(/wait "\$AUTO_PAIR_PID"/); + }); + + it("cleanup() exits with the gateway exit status", () => { + const cleanup = src.match(/cleanup\(\) \{[\s\S]*?^}/m)?.[0]; + expect(cleanup).toMatch(/exit "\$gateway_status"/); + }); + + it("registers trap before start_auto_pair in non-root path", () => { + // trap must appear before start_auto_pair within the non-root block + const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then[\s\S]*?^fi$/m)?.[0]; + expect(nonRootBlock).toBeDefined(); + const trapIdx = nonRootBlock.indexOf("trap cleanup SIGTERM SIGINT"); + const autoIdx = nonRootBlock.indexOf("start_auto_pair"); + expect(trapIdx).toBeGreaterThan(-1); + expect(autoIdx).toBeGreaterThan(-1); + expect(trapIdx).toBeLessThan(autoIdx); + }); + + it("registers trap before start_auto_pair in root path", () => { + // In the root path (after the non-root fi), trap must precede start_auto_pair + const rootBlock = src.split(/^fi$/m).slice(-1)[0]; + const trapIdx = rootBlock.indexOf("trap cleanup SIGTERM SIGINT"); + const autoIdx = rootBlock.indexOf("start_auto_pair"); + expect(trapIdx).toBeGreaterThan(-1); + expect(autoIdx).toBeGreaterThan(-1); + expect(trapIdx).toBeLessThan(autoIdx); + }); + + it("captures AUTO_PAIR_PID from background process", () => { + expect(src).toMatch(/AUTO_PAIR_PID=\$!/); + }); +});