Skip to content

fix(desktop): robust lifecycle teardown for quit and update-install#563

Merged
lefarcen merged 21 commits intomainfrom
fix/desktop-lifecycle-robustness
Mar 26, 2026
Merged

fix(desktop): robust lifecycle teardown for quit and update-install#563
lefarcen merged 21 commits intomainfrom
fix/desktop-lifecycle-robustness

Conversation

@lefarcen
Copy link
Copy Markdown
Collaborator

@lefarcen lefarcen commented Mar 26, 2026

Relates to #566, #510

What

Comprehensive desktop lifecycle robustness overhaul: startup, shutdown, update-install, process management, and data directory correctness.

Why

Users reported:

  1. Update install blocked: macOS "app is still running" because launchd processes survive quit
  2. Can't reopen after quit: Residual processes occupy ports
  3. Dock icon flood: pnpm start spawns 10+ Dock icons (LSUIElement not patched)
  4. Data directory split: bootstrap.ts overwrites NEXU_HOME, causing fresh config on every pnpm start
  5. tsc watcher residual: pnpm stop leaves tsc --watch printing to terminal

How

Bugs fixed (5)

  • dev-launchd.sh bypassed dev-env.sh → Dock icon proliferation. Now routes through dev-env.sh for LSUIElement patch + LS cache flush.
  • bootstrap.ts unconditionally overwrote NEXU_HOME → data directory split. Now respects externally-set NEXU_HOME.
  • quitAndInstall didn't bootout launchd services → macOS installer blocked. Now uses teardownLaunchdServices + ensureNexuProcessesDead verification gate.
  • waitForExit lost PID after bootout → SIGKILL fallback failed. New bootoutAndWaitForExit captures PID before bootout.
  • stopPeriodicCheck race condition → periodic checks fired during teardown. Now saves initial setTimeout ID.

Architecture improvements (3)

  • P0-2: Unified gracefulShutdown(reason) — single authoritative teardown for before-quit, SIGTERM, SIGINT. Idempotent + 8s hard timeout. dev-launchd.sh stop now sends SIGTERM first (triggers graceful shutdown), SIGKILL only as fallback.
  • P1-2: removeAllListeners("before-quit")removeListener — prevents future listener collisions.
  • Orphan kill now respects launchd-managed services — won't SIGKILL healthy services during relaunch-after-crash (PR review fix).

Test suite (net +261 tests: 199 → 460)

Category Count How
Real launchd integration (vitest) 36 Actual launchctl bootstrap/bootout/kickstart, real processes
Real launchd e2e (shell) 17 Full lifecycle + spaces/unicode paths
Stop smoke checks (shell) 10 Process/port/state residual detection
Update server integration 14 Real HTTP server + feed URL verification
Daemon supervisor 27 Start/stop/restart, SIGKILL escalation, auto-restart backoff
Lifecycle teardown 17 teardown sequence, ensureNexuProcessesDead, PID dedup
Update install 14 quitAndInstall 3-phase, try/catch, verification gate
Quit handler 13 Dialog flow, force-quit, dev bypass, Cmd+Q
Update manager 47 Events, checkNow, periodic, feed config
Plist/data directory 27 Every env var in real plist XML output
Dev toolchain invariants 23 Prevent script bypass regressions
Other (bootstrap edge, etc.) 15

53 real launchd tests run on macOS CI runners (not mocked).
46 garbage/duplicate tests deleted (grep-source-code, mock-plumbing, duplicates).

CI improvements

  • ci.yml: added pnpm test job (was missing)
  • desktop-ci-dev.yml + desktop-ci-dist.yml: added pnpm test (runs real launchd tests on macOS), comprehensive path filters covering apps/controller/**, tests/desktop/**, scripts/dev-launchd.sh, vitest.config.ts
  • CODEOWNERS: @lefarcen required for test changes

Affected areas

  • Desktop app (Electron shell)
  • Controller (backend / API)
  • Web dashboard (React UI)
  • OpenClaw runtime
  • Skills
  • Shared schemas / packages
  • Build / CI / Tooling

Checklist

  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (458 passed, 2 skipped)
  • pnpm generate-types run — N/A
  • No credentials or tokens in code or logs
  • No any types introduced

Notes for reviewers

Key files to review:

  • apps/desktop/main/index.tsgracefulShutdown() function + signal handlers
  • apps/desktop/main/services/launchd-bootstrap.tsteardownLaunchdServices, ensureNexuProcessesDead
  • apps/desktop/main/updater/update-manager.ts — 3-phase quitAndInstall
  • tests/desktop/launchd-integration.test.ts — 36 real launchd tests

The update-install path previously called orchestrator.dispose() without
properly booting out launchd services, causing macOS to report "app is
still running" when the installer tried to replace the .app bundle.
The quit-completely path had a similar issue: after bootout, waitForExit
could not SIGKILL processes whose launchd label was already unregistered.

Changes:

- LaunchdManager.bootoutAndWaitForExit: captures PID before bootout so
  the SIGKILL fallback works even after the label is unregistered.
- LaunchdManager.waitForExit: accepts optional knownPid parameter; uses
  process.kill(pid, 0) to verify death when launchctl print returns
  "unknown".
- teardownLaunchdServices: new shared function used by both quit-handler
  and update-manager. Bootouts each service with PID-aware waiting,
  deletes runtime-ports.json, and kills orphan processes via pgrep.
- ensureNexuProcessesDead: polling verification gate that loops pgrep +
  SIGKILL until all Nexu sidecar processes are confirmed dead (max 15s).
- quitAndInstall: now three-phase — (1) teardown + dispose wrapped in
  try/catch so failures never block the install, (2) ensureNexuProcessesDead
  as the hard verification gate, (3) autoUpdater.quitAndInstall.
- Bootstrap adds killOrphanNexuProcesses on cold start to clean up
  residual processes from a previously failed update.

Tests: 27 new tests across 3 files covering teardown, PID-aware
shutdown, verification gate, and the full update-install sequence.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds PID-aware launchd shutdown and a unified teardown API with orphan Nexu process cleanup; integrates these into quit and update flows, updates UpdateManager to use the launchd context, and expands tests, CI, and macOS launchd e2e scripts for lifecycle validation.

Changes

Cohort / File(s) Summary
Launchd core & orchestration
apps/desktop/main/services/launchd-manager.ts, apps/desktop/main/services/launchd-bootstrap.ts
Made exit-wait PID-aware, added bootoutAndWaitForExit and isProcessAlive, introduced PID-targeted SIGKILL fallback, proactive killOrphanNexuProcesses, and new exports teardownLaunchdServices and ensureNexuProcessesDead.
Lifecycle integration & updater
apps/desktop/main/index.ts, apps/desktop/main/services/quit-handler.ts, apps/desktop/main/updater/update-manager.ts
Pass launchd context into UpdateManager; replace inline bootout/runtime-ports logic with teardownLaunchdServices; rework quitAndInstall() to run teardown, verify via ensureNexuProcessesDead, then trigger autoUpdater.quitAndInstall.
Exports surface
apps/desktop/main/services/index.ts
Re-exported teardownLaunchdServices and ensureNexuProcessesDead.
Runtime spawn tweak
apps/desktop/main/runtime/daemon-supervisor.ts
Inject ELECTRON_RUN_AS_NODE=1 into child env when spawning Electron binaries.
Dev tooling & scripts
apps/desktop/scripts/dev-env.sh, scripts/desktop-check-dev.sh, scripts/desktop-stop-smoke.sh, scripts/dev-launchd.sh
Improved dev-env plist handling and error tolerance; added post-stop smoke check and smoke script to assert no leftover processes/ports/runtime-ports.json; adjusted dev launch wrapper and build cleanup.
CI & E2E
.github/workflows/ci.yml, .github/workflows/desktop-ci-dist.yml, scripts/launchd-lifecycle-e2e.sh
Added test CI job; added pre-build controller build and launchd lifecycle e2e step; added macOS launchd lifecycle e2e script exercising bootstrap/teardown/restart/orphan scenarios.
Runtime orchestrator tests & spawning
tests/desktop/daemon-supervisor.test.ts
New comprehensive tests for RuntimeOrchestrator spawn/stop behavior, env injection, restart/backoff, and lifecycle transitions.
Teardown, manager, and updater tests
tests/desktop/lifecycle-teardown.test.ts, tests/desktop/launchd-manager-pid-aware.test.ts, tests/desktop/update-install.test.ts, tests/desktop/quit-handler.test.ts, tests/desktop/update-manager-full.test.ts
Added PID-aware shutdown unit tests, teardown/verification suites, update-install sequencing tests, quit-handler behavior tests, and expanded UpdateManager coverage.
Other tests & invariants
tests/desktop/... (multiple new test files)
Added many test suites covering plist env parity, launchd ops, bootstrap edge cases, dev-toolchain invariants, embedded web server, and lifecycle full flows.

Sequence Diagram

sequenceDiagram
    participant App as Desktop App
    participant UpdateMgr as UpdateManager
    participant Bootstrap as launchd-bootstrap
    participant LaunchMgr as LaunchdManager
    participant Nexu as Nexu Processes
    participant AutoUpdater as electron-updater

    App->>UpdateMgr: quitAndInstall()
    rect rgba(100,200,150,0.5)
      Note over UpdateMgr,Bootstrap: Phase 1 — best-effort teardown
      UpdateMgr->>Bootstrap: teardownLaunchdServices(launchd, labels, plistDir)
      Bootstrap->>LaunchMgr: bootoutAndWaitForExit(openclaw)
      LaunchMgr-->>Bootstrap: result
      Bootstrap->>LaunchMgr: bootoutAndWaitForExit(controller)
      LaunchMgr-->>Bootstrap: result
      Bootstrap->>Bootstrap: deleteRuntimePorts(plistDir)
      Bootstrap->>Bootstrap: killOrphanNexuProcesses()
      Bootstrap->>Nexu: SIGKILL (pgrep-driven)
      Nexu-->>Bootstrap: terminate responses
      Bootstrap-->>UpdateMgr: teardown complete (errors logged/ignored)
    end
    rect rgba(150,150,200,0.5)
      Note over UpdateMgr,Bootstrap: Phase 2 — verification & install
      UpdateMgr->>Bootstrap: ensureNexuProcessesDead()
      Bootstrap->>Nexu: pgrep -> kill -> recheck (loop until timeout)
      Bootstrap-->>UpdateMgr: {clean, remainingPids}
      UpdateMgr->>App: set app.__nexuForceQuit = true
      UpdateMgr->>AutoUpdater: quitAndInstall(false, true)
      AutoUpdater-->>App: install & restart
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • PerishCode
  • mrcfps

Poem

🐰 A rabbit's note on tidy ends:
I sniff the PIDs, then give a knock —
A gentle bootout, then a firm SIGKILL stock.
Plists cleared, ports freed, services rest —
Updates hop in, the system's dressed.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: robust lifecycle teardown for quit and update-install flows on the desktop app.
Description check ✅ Passed The PR description is comprehensive and follows the template structure with clear sections for What, Why, How, affected areas, and a detailed checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/desktop-lifecycle-robustness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a947308b3b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/main/services/launchd-bootstrap.ts Outdated
Comment thread apps/desktop/main/services/quit-handler.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/desktop/main/services/launchd-bootstrap.ts (1)

725-743: Consider resolving pgrep path programmatically.

The pgrep command is invoked via execFileAsync("pgrep", ...) which relies on the system PATH. Per coding guidelines for apps/desktop/**/*.ts, shell commands should not rely on the user's PATH. While pgrep is typically at /usr/bin/pgrep on macOS, consider using an absolute path for robustness in packaged apps.

🔧 Suggested path resolution
+const PGREP_PATH = "/usr/bin/pgrep";
+
 async function findNexuProcessPids(): Promise<number[]> {
   const allPids = new Set<number>();
 
   for (const pattern of NEXU_PROCESS_PATTERNS) {
     try {
-      const { stdout } = await execFileAsync("pgrep", ["-f", pattern]);
+      const { stdout } = await execFileAsync(PGREP_PATH, ["-f", pattern]);
       for (const line of stdout.trim().split("\n")) {

As per coding guidelines: "Desktop packaged app: never use npx, npm, pnpm, or any shell command that relies on the user's PATH."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/services/launchd-bootstrap.ts` around lines 725 - 743, The
function findNexuProcessPids currently calls execFileAsync("pgrep", ...) which
relies on the user's PATH; update it to call pgrep via an absolute, validated
path (e.g. define a PGREP_PATH constant like "/usr/bin/pgrep"), use that
constant in the execFileAsync call instead of "pgrep", and validate
existence/readability of PGREP_PATH (fs.access or similar) before calling;
reference the function name findNexuProcessPids, the execFileAsync invocation,
and NEXU_PROCESS_PATTERNS when making the change so the replacement is localized
and robust for packaged desktop apps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/desktop/update-install.test.ts`:
- Around line 110-120: The tests leak mock implementations because
vi.clearAllMocks()/vi.restoreAllMocks() don't remove custom implementations on
vi.fn() mocks; in the test setup add a reset for the specific mock by calling
mockAutoUpdater.quitAndInstall.mockReset() (or mockClear if you prefer) inside
the beforeEach block so the custom mockImplementation assigned to
mockAutoUpdater.quitAndInstall (used around lines where quitAndInstall is
stubbed) is cleared before each test, ensuring isolation between test cases and
preventing persisted behavior across runs.

---

Nitpick comments:
In `@apps/desktop/main/services/launchd-bootstrap.ts`:
- Around line 725-743: The function findNexuProcessPids currently calls
execFileAsync("pgrep", ...) which relies on the user's PATH; update it to call
pgrep via an absolute, validated path (e.g. define a PGREP_PATH constant like
"/usr/bin/pgrep"), use that constant in the execFileAsync call instead of
"pgrep", and validate existence/readability of PGREP_PATH (fs.access or similar)
before calling; reference the function name findNexuProcessPids, the
execFileAsync invocation, and NEXU_PROCESS_PATTERNS when making the change so
the replacement is localized and robust for packaged desktop apps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 837831c0-5280-4068-951d-5208c6159c4d

📥 Commits

Reviewing files that changed from the base of the PR and between 75b5353 and a947308.

📒 Files selected for processing (11)
  • apps/desktop/main/index.ts
  • apps/desktop/main/services/index.ts
  • apps/desktop/main/services/launchd-bootstrap.ts
  • apps/desktop/main/services/launchd-manager.ts
  • apps/desktop/main/services/quit-handler.ts
  • apps/desktop/main/updater/update-manager.ts
  • tests/desktop/launchd-bootstrap.test.ts
  • tests/desktop/launchd-manager-pid-aware.test.ts
  • tests/desktop/launchd-startup-scenarios.test.ts
  • tests/desktop/lifecycle-teardown.test.ts
  • tests/desktop/update-install.test.ts

Comment thread apps/desktop/main/updater/update-manager.ts
Comment thread tests/desktop/update-install.test.ts
- ci.yml: add a `test` job that runs `pnpm test` on ubuntu-latest,
  covering all 247+ vitest unit tests that were previously not run in CI.

- desktop-ci-dist.yml: add real launchd lifecycle e2e test that runs on
  macOS CI runners before the packaged app build. The test exercises:
  1. Bootstrap: register plist → kickstart → verify running + port
  2. Teardown: bootout → verify label unregistered → verify process dead
  3. SIGKILL fallback: bootout → saved-PID SIGKILL → verify dead
  4. Orphan detection: spawn fake orphan → detect via lsof → SIGKILL
  5. Re-bootstrap: fresh cold start after full cleanup

- scripts/launchd-lifecycle-e2e.sh: standalone e2e test script (15 checks)
  that validates the launchd process management primitives used by the
  desktop app's quit and update-install paths.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/desktop-ci-dist.yml (1)

4-37: ⚠️ Potential issue | 🟠 Major

Include apps/controller/** in the trigger paths.

Lines 83-88 now build @nexu/controller and run the launchd e2e against it, but controller-only PRs still won't match either paths: block. That means the new gate can be skipped by the code it is meant to protect.

🧩 Suggested fix
   pull_request:
     paths:
+      - "apps/controller/**"
       - "apps/desktop/**"
       - "apps/web/**"
@@
   push:
     branches:
       - main
     paths:
+      - "apps/controller/**"
       - "apps/desktop/**"
       - "apps/web/**"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/desktop-ci-dist.yml around lines 4 - 37, Add the missing
apps/controller/** pattern to both paths: blocks so controller-only changes will
trigger this workflow; update the pull_request.paths and push.paths arrays (the
two paths: blocks in the shown workflow) to include "apps/controller/**"
alongside the existing apps/desktop/** and apps/web/** entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/launchd-lifecycle-e2e.sh`:
- Around line 357-387: The test currently verifies the orphan by lsof and the
retained ORPHAN_PID, which bypasses the actual pgrep matcher used in production;
replace the lsof check with an invocation of the same pgrep matcher the
production code uses (run the pgrep command/pattern used by the
killOrphanNexuProcesses logic) against the spawned process and assert that the
pgrep output contains ORPHAN_PID (using CONTROLLER_PORT or the same pattern
string as the matcher), so the test fails if the matcher cannot find the orphan;
remove or replace the lsof | grep check with this pgrep-based assertion and then
proceed to kill the PID as before.
- Line 37: The script currently defines OPENCLAW_ENTRY and unconditionally fails
if that artifact is missing, but this job only exercises the controller path
(per the "only exercises the controller path" comment); update the prereq logic
so the missing openclaw artifact does not cause an exit for controller-only
runs: either remove the unconditional existence check for OPENCLAW_ENTRY, or
wrap it in a runtime-specific guard (e.g., only test for the file when launching
the runtime component), by changing the check that references OPENCLAW_ENTRY to
first verify that the script intends to start the openclaw runtime instead of
controller-only.
- Around line 96-107: wait_for_exit currently sleeps 0.5s but increments elapsed
by 1, halving the intended timeout; fix this by switching to half-step integer
math: introduce e.g. elapsed_half and timeout_half=$((timeout * 2)), keep sleep
0.5, increment elapsed_half by 1 each loop and compare elapsed_half to
timeout_half, leaving function name wait_for_exit and parameters (pid, timeout)
unchanged so behavior matches the documented seconds.

---

Outside diff comments:
In @.github/workflows/desktop-ci-dist.yml:
- Around line 4-37: Add the missing apps/controller/** pattern to both paths:
blocks so controller-only changes will trigger this workflow; update the
pull_request.paths and push.paths arrays (the two paths: blocks in the shown
workflow) to include "apps/controller/**" alongside the existing apps/desktop/**
and apps/web/** entries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9d053d4-b8a8-43ed-b440-448f2db91c06

📥 Commits

Reviewing files that changed from the base of the PR and between a947308 and fadc4a2.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • .github/workflows/desktop-ci-dist.yml
  • scripts/launchd-lifecycle-e2e.sh

Comment thread scripts/launchd-lifecycle-e2e.sh Outdated
Comment thread scripts/launchd-lifecycle-e2e.sh
Comment thread scripts/launchd-lifecycle-e2e.sh
Fixes:
- dev-env.sh: add Launch Services cache flush (lsregister) after
  patching LSUIElement=true on the dev Electron binary. Without this,
  macOS uses cached plist data and still shows Dock icons for child
  processes. Add verification logging on success/failure.
- daemon-supervisor: force ELECTRON_RUN_AS_NODE=1 on all spawn() calls
  that use process.execPath (Electron binary) as a safety net, even if
  the manifest env omits it.

Tests:
- daemon-supervisor.test.ts (10 tests): constructor, startAutoStart,
  stopUnit SIGTERM/SIGKILL escalation, 5s deadline, stopAll parallel,
  dispose, skip non-managed, ELECTRON_RUN_AS_NODE safety net,
  stoppedByUser suppresses restart, dependent stop order.
- quit-handler.test.ts (8 tests): quit-completely full sequence,
  __nexuForceQuit flag, app.exit(0), error resilience for onBeforeQuit
  and webServer.close, no-plistDir skip, run-in-background hide.
- desktop-stop-smoke.sh: post-stop verification script — checks no
  residual processes, free ports, no launchd labels, no stale state.
  Integrated into desktop-check-dev.sh for CI.
dev-launchd.sh (pnpm start) was launching Electron directly without
going through dev-env.sh, bypassing the LSUIElement=true plist patch
and Launch Services cache flush. This is the direct cause of the Dock
icon proliferation reported by users running pnpm start.

Now pnpm start → dev-launchd.sh → dev-env.sh → electron, matching the
same path that pnpm dev uses via dev.sh → dev-run.sh → dev-env.sh.
Static analysis tests that guard critical invariants across the launch,
environment, and shutdown scripts. These catch regressions like the
dev-launchd.sh bypass of dev-env.sh that caused Dock icon proliferation.

19 invariant checks covering:
- Launch paths: all Electron launch commands go through dev-env.sh
- LSUIElement: plist patch + LS cache flush present
- ELECTRON_RUN_AS_NODE: set in plists, manifests, daemon-supervisor,
  openclaw-process, and catalog-manager
- Shutdown: bootout, orphan kill, port wait, teardown via shared
  function, try/catch wrapping, verification gate ordering
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (5)
scripts/desktop-stop-smoke.sh (1)

107-112: Anchor runtime-ports.json check to script location, not caller CWD.

Line 107 uses a relative path; invoking this script outside repo root can produce false results.

Proposed change
-RUNTIME_PORTS=".tmp/launchd/runtime-ports.json"
+RUNTIME_PORTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.tmp/launchd/runtime-ports.json"
 if [ ! -f "$RUNTIME_PORTS" ]; then
   pass "no stale runtime-ports.json"
 else
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/desktop-stop-smoke.sh` around lines 107 - 112, The check for
RUNTIME_PORTS uses a relative path and must be anchored to the script's
directory; compute the script base directory (e.g., using dirname/realpath of
"$0") and build RUNTIME_PORTS from that base instead of the current working
directory so the if [ ! -f "$RUNTIME_PORTS" ] check always targets
.tmp/launchd/runtime-ports.json inside the repository; update the RUNTIME_PORTS
variable assignment in scripts/desktop-stop-smoke.sh and ensure subsequent
references use that anchored path.
tests/desktop/quit-handler.test.ts (2)

87-104: The “full sequence” test name promises order, but order is not asserted.

Line 87 describes strict sequencing, yet Lines 96-104 only assert that calls happened. Add explicit ordering assertions to catch regressions in teardown flow.

✅ Example assertion pattern
   it("quit-completely calls onBeforeQuit → webServer.close → teardown → exit", async () => {
+    const calls: string[] = [];
     const opts = createQuitOpts();
+    opts.onBeforeQuit = vi.fn(async () => {
+      calls.push("onBeforeQuit");
+    });
+    opts.webServer.close = vi.fn(async () => {
+      calls.push("webServer.close");
+    });
+    mockTeardown.mockImplementation(async () => {
+      calls.push("teardown");
+    });
+    mockApp.exit.mockImplementation(() => {
+      calls.push("exit");
+    });
@@
     expect(mockApp.exit).toHaveBeenCalledWith(0);
+    expect(calls).toEqual([
+      "onBeforeQuit",
+      "webServer.close",
+      "teardown",
+      "exit",
+    ]);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/quit-handler.test.ts` around lines 87 - 104, Test
"quit-completely calls onBeforeQuit → webServer.close → teardown → exit" claims
a strict call order but only checks presence; modify the test around
quitWithDecision to assert call ordering between opts.onBeforeQuit,
opts.webServer.close, mockTeardown, and mockApp.exit (e.g. compare their mock
invocationCallOrder or inspect mock.calls indices) so the test explicitly
verifies that onBeforeQuit ran before webServer.close, which ran before
mockTeardown, which ran before mockApp.exit.

61-70: Replace unsafe as never casts with proper QuitHandlerOptions typing.

The createQuitOpts function uses as never casts to suppress type errors instead of properly typing its return value. Since QuitHandlerOptions is exported from the quit-handler module, the function should be explicitly typed to return QuitHandlerOptions, and test calls should pass the result without casts.

♻️ Proposed refactor
-function createQuitOpts(overrides?: Record<string, unknown>) {
+function createQuitOpts(overrides?: Partial<QuitHandlerOptions>): QuitHandlerOptions {
   return {
-    launchd: {} as never,
+    launchd: {} as QuitHandlerOptions["launchd"],
     labels: { controller: "io.nexu.controller", openclaw: "io.nexu.openclaw" },
     plistDir: "/tmp/test-plist",
     webServer: { close: vi.fn().mockResolvedValue(undefined) },
     onBeforeQuit: vi.fn().mockResolvedValue(undefined),
     onForceQuit: vi.fn(),
     ...overrides,
-  };
+  } as QuitHandlerOptions;
 }
-    await quitWithDecision("quit-completely", opts as never);
+    await quitWithDecision("quit-completely", opts);

Also applies to lines 94–95, 121–122, 135–136, 152–153, 173–174, 189–190, 205–206, 222–223.

Run pnpm typecheck and pnpm lint after making these changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/quit-handler.test.ts` around lines 61 - 70, The createQuitOpts
helper currently uses unsafe `as never` casts; change its signature to return
the exported QuitHandlerOptions type (e.g. function createQuitOpts(overrides?:
Partial<QuitHandlerOptions>): QuitHandlerOptions) and replace the casted fields
(launchd, webServer, onBeforeQuit, onForceQuit, labels, plistDir, etc.) with
properly typed mock values that satisfy QuitHandlerOptions (use Partial for
overrides and merge into a fully typed object), then remove any call-site casts
that relied on `as never` (the other createQuitOpts usages referenced in the
comment). After updating createQuitOpts and all test usages, run pnpm typecheck
and pnpm lint to confirm types and linting pass.
tests/desktop/daemon-supervisor.test.ts (1)

309-326: Cover the two suffix fallbacks as well.

This only exercises command === process.execPath. The production guard also relies on /Electron and /electron suffix matches, so those new heuristics can regress without any test failure.

Suggested test shape
-it("forces ELECTRON_RUN_AS_NODE=1 when command is process.execPath", async () => {
+it.each([
+  process.execPath,
+  "/Applications/Electron.app/Contents/MacOS/Electron",
+  "/usr/local/bin/electron",
+])("forces ELECTRON_RUN_AS_NODE=1 when command is %s", async (command) => {
   const { RuntimeOrchestrator } = await import(
     "../../apps/desktop/main/runtime/daemon-supervisor"
   );

   const orchestrator = new RuntimeOrchestrator([
     makeManagedManifest("controller", {
-      command: process.execPath,
+      command,
       // Deliberately omit ELECTRON_RUN_AS_NODE from manifest env
       env: { PORT: "50800" },
     }),
   ] as never[]);

   await orchestrator.startAutoStartManagedUnits();

   const spawnEnv = mockSpawn.mock.calls[0][2].env;
   expect(spawnEnv.ELECTRON_RUN_AS_NODE).toBe("1");
-});
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/daemon-supervisor.test.ts` around lines 309 - 326, The test
only asserts the exact match case (command === process.execPath) but misses the
two suffix-fallback heuristics that also should set ELECTRON_RUN_AS_NODE; update
the test in daemon-supervisor.test.ts (the "forces ELECTRON_RUN_AS_NODE=1 when
command is process.execPath" case) to include two additional scenarios where
RuntimeOrchestrator is constructed with manifests whose command strings end with
"/Electron" and "/electron" respectively (using makeManagedManifest to build
each manifest), call startAutoStartManagedUnits for each, and assert
mockSpawn.mock.calls[].env.ELECTRON_RUN_AS_NODE === "1" for those calls so all
three heuristics (exact process.execPath, "/Electron" suffix, "/electron"
suffix) are covered.
apps/desktop/main/runtime/daemon-supervisor.ts (1)

1007-1019: Extend the Electron fallback to utility-process launches (defensive measure).

Currently, no managed units use runner: "utility-process", but this predicate checks only manifest.command, which is not consulted by utilityProcess.fork(). For future safety, extend the Electron detection to also cover the utility-process runner.

Suggested change
     const isElectronBinary =
+      manifest.runner === "utility-process" ||
       manifest.command === process.execPath ||
       manifest.command?.endsWith("/Electron") ||
       manifest.command?.endsWith("/electron");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/main/runtime/daemon-supervisor.ts` around lines 1007 - 1019, The
Electron-detection predicate (isElectronBinary) only checks manifest.command but
misses cases where the unit uses the utility-process runner; update
isElectronBinary to also return true when the manifest indicates runner:
"utility-process" (e.g., manifest.runner === "utility-process" or presence of
manifest.utilityProcess) so that the ELECTRON_RUN_AS_NODE fallback is applied to
utilityProcess.fork launches as well; change the isElectronBinary expression in
daemon-supervisor.ts to include this runner check while keeping the existing
command-based checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/scripts/dev-env.sh`:
- Around line 42-45: The success log "[dev-env] patched Electron
LSUIElement=true + flushed LS cache" can be misleading because the lsregister
invocation uses "|| true" and always returns success; change the logic around
the lsregister call (the invocation referencing lsregister and the variable
electron_app) to capture its exit status and only echo the "flushed LS cache"
message when lsregister succeeds, otherwise emit an explicit failure log (and
preserve stderr redirection as needed) so the dev-env script accurately reports
whether the LS cache flush succeeded or failed.

In `@scripts/desktop-check-dev.sh`:
- Around line 26-34: The fixed 2s sleep in desktop-check-dev.sh causes teardown
races; replace it with a bounded polling loop that repeatedly checks for
shutdown completion (e.g., no remaining target processes or ports in use) before
invoking bash scripts/desktop-stop-smoke.sh, using a configurable timeout and
short sleep interval and exiting with a nonzero exit_code if the timeout is
reached; keep references to the existing variables exit_code and smoke_code so
you update exit_code on failure as the current logic does.

In `@scripts/desktop-stop-smoke.sh`:
- Around line 48-55: The process-sweep loop currently iterates patterns
["controller/dist/index.js", "openclaw.mjs gateway", "openclaw-gateway"] and
therefore misses the web sidecar and the native OpenClaw binary; update the
for-loop's pattern list (the patterns used by the variable pattern in that loop)
to also include the web-sidecar pattern(s) used by apps/desktop/dev.sh (e.g.,
the web sidecar entry point such as "web/dist/index.js" or "web-sidecar") and
the native OpenClaw binary name (e.g., plain "openclaw" or the platform-specific
native binary name), so pgrep -f will detect those residual processes and the
script will correctly fail when they remain.

In `@tests/desktop/quit-handler.test.ts`:
- Around line 78-82: Replace vi.clearAllMocks() with vi.resetAllMocks() in the
beforeEach block to fully reset mocks and implementations between tests; update
the beforeEach that references vi.clearAllMocks(), mockApp.__nexuForceQuit and
mockTeardown to call vi.resetAllMocks() so custom implementations like
mockApp.exit.mockImplementation() do not leak into later tests.

---

Nitpick comments:
In `@apps/desktop/main/runtime/daemon-supervisor.ts`:
- Around line 1007-1019: The Electron-detection predicate (isElectronBinary)
only checks manifest.command but misses cases where the unit uses the
utility-process runner; update isElectronBinary to also return true when the
manifest indicates runner: "utility-process" (e.g., manifest.runner ===
"utility-process" or presence of manifest.utilityProcess) so that the
ELECTRON_RUN_AS_NODE fallback is applied to utilityProcess.fork launches as
well; change the isElectronBinary expression in daemon-supervisor.ts to include
this runner check while keeping the existing command-based checks.

In `@scripts/desktop-stop-smoke.sh`:
- Around line 107-112: The check for RUNTIME_PORTS uses a relative path and must
be anchored to the script's directory; compute the script base directory (e.g.,
using dirname/realpath of "$0") and build RUNTIME_PORTS from that base instead
of the current working directory so the if [ ! -f "$RUNTIME_PORTS" ] check
always targets .tmp/launchd/runtime-ports.json inside the repository; update the
RUNTIME_PORTS variable assignment in scripts/desktop-stop-smoke.sh and ensure
subsequent references use that anchored path.

In `@tests/desktop/daemon-supervisor.test.ts`:
- Around line 309-326: The test only asserts the exact match case (command ===
process.execPath) but misses the two suffix-fallback heuristics that also should
set ELECTRON_RUN_AS_NODE; update the test in daemon-supervisor.test.ts (the
"forces ELECTRON_RUN_AS_NODE=1 when command is process.execPath" case) to
include two additional scenarios where RuntimeOrchestrator is constructed with
manifests whose command strings end with "/Electron" and "/electron"
respectively (using makeManagedManifest to build each manifest), call
startAutoStartManagedUnits for each, and assert
mockSpawn.mock.calls[].env.ELECTRON_RUN_AS_NODE === "1" for those calls so all
three heuristics (exact process.execPath, "/Electron" suffix, "/electron"
suffix) are covered.

In `@tests/desktop/quit-handler.test.ts`:
- Around line 87-104: Test "quit-completely calls onBeforeQuit → webServer.close
→ teardown → exit" claims a strict call order but only checks presence; modify
the test around quitWithDecision to assert call ordering between
opts.onBeforeQuit, opts.webServer.close, mockTeardown, and mockApp.exit (e.g.
compare their mock invocationCallOrder or inspect mock.calls indices) so the
test explicitly verifies that onBeforeQuit ran before webServer.close, which ran
before mockTeardown, which ran before mockApp.exit.
- Around line 61-70: The createQuitOpts helper currently uses unsafe `as never`
casts; change its signature to return the exported QuitHandlerOptions type (e.g.
function createQuitOpts(overrides?: Partial<QuitHandlerOptions>):
QuitHandlerOptions) and replace the casted fields (launchd, webServer,
onBeforeQuit, onForceQuit, labels, plistDir, etc.) with properly typed mock
values that satisfy QuitHandlerOptions (use Partial for overrides and merge into
a fully typed object), then remove any call-site casts that relied on `as never`
(the other createQuitOpts usages referenced in the comment). After updating
createQuitOpts and all test usages, run pnpm typecheck and pnpm lint to confirm
types and linting pass.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60942df0-8f14-426e-8182-7d1df969cf15

📥 Commits

Reviewing files that changed from the base of the PR and between fadc4a2 and 321ce84.

📒 Files selected for processing (7)
  • .github/workflows/desktop-ci-dev.yml
  • apps/desktop/main/runtime/daemon-supervisor.ts
  • apps/desktop/scripts/dev-env.sh
  • scripts/desktop-check-dev.sh
  • scripts/desktop-stop-smoke.sh
  • tests/desktop/daemon-supervisor.test.ts
  • tests/desktop/quit-handler.test.ts
✅ Files skipped from review due to trivial changes (1)
  • .github/workflows/desktop-ci-dev.yml

Comment thread apps/desktop/scripts/dev-env.sh Outdated
Comment thread scripts/desktop-check-dev.sh
Comment thread scripts/desktop-stop-smoke.sh Outdated
Comment thread tests/desktop/quit-handler.test.ts Outdated
… paths

Bring test coverage for the 6 core lifecycle files from 63.5% to 83.8%
overall statements, with function coverage at 93.8%.

Per-file improvements:
- update-manager.ts: 63.1% → 98.5% (bindEvents, checkNow, downloadUpdate,
  periodicCheck, setChannel/setSource, send to webviews)
- quit-handler.ts: 36.1% → 97.0% (installLaunchdQuitHandler dialog flow,
  force-quit bypass, dev-mode bypass, Cmd+Q interception, dialogOpen guard)
- launchd-manager.ts: 72.7% → 94.9% (uninstallService, stopServiceGracefully
  SIGKILL escalation, restartService, rebootstrapFromPlist, hasPlistFile)
- daemon-supervisor.ts: 46.5% → 69.5% (startUnit port probe, auto-restart
  backoff, refreshDelegatedUnits pgrep, stdout/stderr capture, queryEvents)
- launchd-bootstrap.ts: 92.6% → 93.1% (isLaunchdBootstrapEnabled packaged
  heuristic, ensureNexuProcessesDead edge cases)
- plist-generator.ts: 100% (unchanged)

New test files: daemon-supervisor.test.ts (27), quit-handler-full.test.ts (13),
update-manager-full.test.ts (41), launchd-manager-ops-extended.test.ts (11),
launchd-bootstrap-edge.test.ts (12).

Total: 391 tests across 40 files, all passing.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
tests/desktop/embedded-web-server.test.ts (2)

33-35: Temp directory is not cleaned up after tests.

The webRoot temp directory created via mkdtempSync is never removed, leaving orphan files on disk. Consider cleaning up in afterAll.

🧹 Proposed fix to clean up temp directory
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
   afterAll(async () => {
     await server?.close();
+    if (webRoot) {
+      rmSync(webRoot, { recursive: true, force: true });
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/embedded-web-server.test.ts` around lines 33 - 35, The test
creates a temporary webRoot directory via mkdtempSync but never cleans it up;
update the afterAll block (where server?.close() is called) to also remove the
temporary directory (webRoot) when tests finish — e.g., check that webRoot is
defined and then remove it recursively/forcefully using the fs API (fs.rmSync or
fs.rmdirSync with recursive) to avoid orphaned files; reference the webRoot
variable and the existing afterAll teardown to place the cleanup.

17-17: Fixed port may cause test flakiness in CI.

If port 51777 is already in use by another process or parallel test run, startEmbeddedWebServer will fail. Consider using port 0 to let the OS assign an available port, then read the actual port from the server instance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/embedded-web-server.test.ts` at line 17, The test uses a fixed
TEST_PORT (const TEST_PORT = 51777) which can cause CI flakiness; change the
test to pass port 0 to startEmbeddedWebServer so the OS picks an available port,
then read the actual bound port from the returned server instance (or
server.address().port) and use that dynamic port in subsequent
requests/assertions; update any references to TEST_PORT and the
startEmbeddedWebServer call to use 0 and replace usages with the server's actual
port.
tests/desktop/quit-handler-full.test.ts (1)

75-88: Tighten createQuitOpts typing to eliminate as never casts.

The loose Record<string, unknown> return type in createQuitOpts forces 14 as never casts across the test file. Since QuitHandlerOptions is exported from quit-handler.ts, type the helper or fixture to match it directly—either by importing QuitHandlerOptions or using Parameters<typeof installLaunchdQuitHandler>[0]—so type mismatches fail at compile time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/quit-handler-full.test.ts` around lines 75 - 88, The
createQuitOpts helper currently returns a loose Record and uses `as never`
casts; change its signature to return the concrete type used by the handler
(e.g. import and use `QuitHandlerOptions` or use `Parameters<typeof
installLaunchdQuitHandler>[0]`) and type the `overrides` parameter as
`Partial<QuitHandlerOptions>` (or the equivalent Parameters-based type); update
the function return type accordingly and remove all `as never` casts inside the
fixture so TypeScript will catch mismatches at compile time while preserving the
mock shapes (e.g. keep vi.fn() values but typed to match the corresponding
properties).
tests/desktop/launchd-manager-ops-extended.test.ts (1)

83-377: Consolidate overlapping suites to reduce maintenance drift.

This file largely duplicates scenarios already covered in tests/desktop/launchd-manager-ops.test.ts (Line 348-573) for the same five methods. Consider keeping one detailed suite and trimming the other to non-overlapping smoke coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/launchd-manager-ops-extended.test.ts` around lines 83 - 377,
This test file duplicates full coverage of the same five LaunchdManager
behaviors (uninstallService, stopServiceGracefully, restartService,
rebootstrapFromPlist, hasPlistFile); please consolidate by trimming this file to
non-overlapping smoke tests only (or remove redundant it blocks) and keep one
authoritative, detailed suite elsewhere: identify the describe blocks named
"uninstallService", "stopServiceGracefully", "restartService",
"rebootstrapFromPlist", and "hasPlistFile" in this file, remove or reduce
duplicate it(...) cases that mirror the other suite, and retain only minimal
assertions that exercise happy-path behavior (using existing setupExecFile and
fs mocks) or unique edge cases not covered elsewhere so maintenance won’t drift.
tests/desktop/update-manager-full.test.ts (1)

513-600: Add coverage before the first periodic timer fires.

Both timer specs only interact after initialDelayMs has elapsed. A regression that double-schedules the startup timeout on repeated startPeriodicCheck() calls, or forgets to cancel that startup timeout in stopPeriodicCheck(), would still pass here.

🧪 Suggested cases
 describe("startPeriodicCheck", () => {
+  it("is a no-op when called twice before initialDelayMs elapses", async () => {
+    vi.useFakeTimers();
+    mockAutoUpdater.checkForUpdates.mockResolvedValue({
+      updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" },
+    });
+
+    const { mgr } = await createManager(undefined, {
+      initialDelayMs: 1000,
+      checkIntervalMs: 5000,
+    });
+
+    mgr.startPeriodicCheck();
+    mgr.startPeriodicCheck();
+
+    await vi.advanceTimersByTimeAsync(1000);
+    expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
+  });
+
   it("calling startPeriodicCheck twice after timer is set is a no-op", async () => {
     vi.useFakeTimers();
     mockAutoUpdater.checkForUpdates.mockResolvedValue({
       updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" },
     });
@@
 describe("stopPeriodicCheck", () => {
+  it("cancels the startup timeout before the first check", async () => {
+    vi.useFakeTimers();
+    mockAutoUpdater.checkForUpdates.mockResolvedValue({
+      updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" },
+    });
+
+    const { mgr } = await createManager(undefined, {
+      initialDelayMs: 1000,
+      checkIntervalMs: 5000,
+    });
+
+    mgr.startPeriodicCheck();
+    mgr.stopPeriodicCheck();
+
+    await vi.advanceTimersByTimeAsync(1000);
+    expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled();
+  });
+
   it("clears the interval and prevents further checks", async () => {
     vi.useFakeTimers();
     mockAutoUpdater.checkForUpdates.mockResolvedValue({
       updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" },
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/update-manager-full.test.ts` around lines 513 - 600, Add tests
in tests/desktop/update-manager-full.test.ts that assert behavior before the
initialDelayMs fires: (1) calling mgr.startPeriodicCheck() twice before the
initial delay should not schedule duplicate startup timeouts (advance timers by
less than initialDelayMs then by initialDelayMs and verify checkForUpdates
called only once, then advance by checkIntervalMs to verify only a single
interval), and (2) mgr.stopPeriodicCheck() called after startPeriodicCheck but
before the initialDelayMs should cancel the pending startup timeout (start, call
stopPeriodicCheck(), advance past initialDelayMs and ensure
mockAutoUpdater.checkForUpdates was never called). Reference the
startPeriodicCheck and stopPeriodicCheck methods on mgr and the
initialDelayMs/checkIntervalMs options when adding these specs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/desktop/launchd-bootstrap-edge.test.ts`:
- Around line 83-92: Tests mutate process.env/platform/execPath but do not call
vi.resetModules(), so dynamic imports of isLaunchdBootstrapEnabled may return a
cached module that used original environment; add a beforeEach that calls
vi.resetModules() (and optionally reassigns any mocked process properties) so
each test re-imports the module fresh; update the test suite around
isLaunchdBootstrapEnabled to include beforeEach(() => vi.resetModules()) so
changes to process.env, process.platform, or process.execPath are picked up by
the module under test.
- Around line 186-227: The tests call resolveLaunchdPaths via dynamic import but
don't reset the module cache, so memoized or module-initialized values can leak
between cases; before each dynamic import of
"../../apps/desktop/main/services/launchd-bootstrap" call vi.resetModules()
(e.g., at the top of each it block or in a beforeEach) so resolveLaunchdPaths is
re-imported fresh; ensure you add vi.resetModules() immediately prior to the
import that returns resolveLaunchdPaths.
- Around line 243-274: The mock implementation in setupPgrepSequence should
match Node's execFile callback signature: change the
mockExecFile.mockImplementation callback type to (cmd: string, _args: string[],
callback: (error: Error | null, stdout: string, stderr: string) => void) and
invoke callback with separate stdout and stderr args (e.g., callback(new
ErrorWithCode, "", "") or callback(null, pids.join("\n"), "")); keep the early
return for pgrep and for non-pgrep commands call callback(null, "", "") so
lsof/other branches also use the same (error, stdout, stderr) signature.

In `@tests/desktop/launchd-manager-ops-extended.test.ts`:
- Around line 77-81: Save the original process.platform before you overwrite it
in the existing beforeEach, then add an afterEach that restores it;
specifically, in the "LaunchdManager — extended operations" describe block
capture const originalPlatform = process.platform (or store it on a variable
scoped to the describe) before calling Object.defineProperty(process,
"platform", { value: "darwin" }) in beforeEach, and add an afterEach calling
Object.defineProperty(process, "platform", { value: originalPlatform }) to
restore the platform and prevent test bleed.

In `@tests/desktop/update-manager-full.test.ts`:
- Around line 112-126: The helper function createManager is forcing a channel
value by always merging { channel: "stable", feedUrl: null, ...options } into
the UpdateManager constructor, preventing tests from exercising the
constructor's real defaults; change createManager so it does not inject a
default channel (and avoid forcing feedUrl unless required) and instead pass
only the options provided (i.e., construct new UpdateManager(win, orchestrator,
options ?? {}) ), so tests that need explicit channels set them themselves and
other tests exercise UpdateManager's own defaults.

---

Nitpick comments:
In `@tests/desktop/embedded-web-server.test.ts`:
- Around line 33-35: The test creates a temporary webRoot directory via
mkdtempSync but never cleans it up; update the afterAll block (where
server?.close() is called) to also remove the temporary directory (webRoot) when
tests finish — e.g., check that webRoot is defined and then remove it
recursively/forcefully using the fs API (fs.rmSync or fs.rmdirSync with
recursive) to avoid orphaned files; reference the webRoot variable and the
existing afterAll teardown to place the cleanup.
- Line 17: The test uses a fixed TEST_PORT (const TEST_PORT = 51777) which can
cause CI flakiness; change the test to pass port 0 to startEmbeddedWebServer so
the OS picks an available port, then read the actual bound port from the
returned server instance (or server.address().port) and use that dynamic port in
subsequent requests/assertions; update any references to TEST_PORT and the
startEmbeddedWebServer call to use 0 and replace usages with the server's actual
port.

In `@tests/desktop/launchd-manager-ops-extended.test.ts`:
- Around line 83-377: This test file duplicates full coverage of the same five
LaunchdManager behaviors (uninstallService, stopServiceGracefully,
restartService, rebootstrapFromPlist, hasPlistFile); please consolidate by
trimming this file to non-overlapping smoke tests only (or remove redundant it
blocks) and keep one authoritative, detailed suite elsewhere: identify the
describe blocks named "uninstallService", "stopServiceGracefully",
"restartService", "rebootstrapFromPlist", and "hasPlistFile" in this file,
remove or reduce duplicate it(...) cases that mirror the other suite, and retain
only minimal assertions that exercise happy-path behavior (using existing
setupExecFile and fs mocks) or unique edge cases not covered elsewhere so
maintenance won’t drift.

In `@tests/desktop/quit-handler-full.test.ts`:
- Around line 75-88: The createQuitOpts helper currently returns a loose Record
and uses `as never` casts; change its signature to return the concrete type used
by the handler (e.g. import and use `QuitHandlerOptions` or use
`Parameters<typeof installLaunchdQuitHandler>[0]`) and type the `overrides`
parameter as `Partial<QuitHandlerOptions>` (or the equivalent Parameters-based
type); update the function return type accordingly and remove all `as never`
casts inside the fixture so TypeScript will catch mismatches at compile time
while preserving the mock shapes (e.g. keep vi.fn() values but typed to match
the corresponding properties).

In `@tests/desktop/update-manager-full.test.ts`:
- Around line 513-600: Add tests in tests/desktop/update-manager-full.test.ts
that assert behavior before the initialDelayMs fires: (1) calling
mgr.startPeriodicCheck() twice before the initial delay should not schedule
duplicate startup timeouts (advance timers by less than initialDelayMs then by
initialDelayMs and verify checkForUpdates called only once, then advance by
checkIntervalMs to verify only a single interval), and (2)
mgr.stopPeriodicCheck() called after startPeriodicCheck but before the
initialDelayMs should cancel the pending startup timeout (start, call
stopPeriodicCheck(), advance past initialDelayMs and ensure
mockAutoUpdater.checkForUpdates was never called). Reference the
startPeriodicCheck and stopPeriodicCheck methods on mgr and the
initialDelayMs/checkIntervalMs options when adding these specs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2966befa-6949-4a89-b297-34fcec1b01b9

📥 Commits

Reviewing files that changed from the base of the PR and between 06277d1 and 0f82a16.

📒 Files selected for processing (9)
  • tests/desktop/daemon-supervisor.test.ts
  • tests/desktop/embedded-web-server.test.ts
  • tests/desktop/launchd-bootstrap-edge.test.ts
  • tests/desktop/launchd-bootstrap.test.ts
  • tests/desktop/launchd-manager-ops-extended.test.ts
  • tests/desktop/launchd-manager-ops.test.ts
  • tests/desktop/plist-env-parity.test.ts
  • tests/desktop/quit-handler-full.test.ts
  • tests/desktop/update-manager-full.test.ts
✅ Files skipped from review due to trivial changes (2)
  • tests/desktop/plist-env-parity.test.ts
  • tests/desktop/daemon-supervisor.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/desktop/launchd-bootstrap.test.ts

Comment thread tests/desktop/launchd-bootstrap-edge.test.ts
Comment thread tests/desktop/launchd-bootstrap-edge.test.ts
Comment thread tests/desktop/launchd-bootstrap-edge.test.ts
Comment thread tests/desktop/launchd-manager-ops-extended.test.ts Outdated
Comment thread tests/desktop/update-manager-full.test.ts
lefarcen added 15 commits March 26, 2026 18:52
Fixes:
- update-manager: save initial setTimeout ID so stopPeriodicCheck can
  cancel it during the initial delay window. Previously, calling
  stopPeriodicCheck before the initial delay expired was a no-op,
  allowing the interval to start during teardown.
- update-manager: call stopPeriodicCheck() at the start of
  quitAndInstall() to prevent periodic checks from firing mid-teardown.

Tests:
- launchd-integration.test.ts: 8 tests that run REAL launchd on macOS
  (skipped on other platforms). Covers:
  1. installService + startService → real service running on real port
  2. bootoutAndWaitForExit → real process confirmed dead
  3. teardownLaunchdServices → full sequence against real launchd
  4. ensureNexuProcessesDead → real orphan process spawned and killed
  5. getServiceStatus → real PID from launchctl print
  6. getServiceStatus → unknown for non-existent label
  7. installService → detects plist content change and re-bootstraps
  8. stopServiceGracefully → real SIGTERM stops service

CI:
- desktop-ci-dist.yml: add `pnpm test` step on macOS runners so real
  launchd integration tests run in CI alongside the shell e2e tests.
Expand launchd-integration.test.ts from 8 to 16 real launchd tests:

9.  Full cycle: start → bootout → verify clean → cold re-start
10. Attach: detect already-running service from previous session
11. KeepAlive: service auto-restarts after SIGKILL (crash simulation)
12. Rapid start/stop cycles leave no orphan processes
13. Port conflict: occupied port → bootout still cleans up
14. bootout on non-registered label is idempotent (no throw)
15. teardownLaunchdServices with non-existent labels is safe
16. waitForExit handles process dying during bootout (race condition)

CI: add pnpm test to desktop-ci-dev.yml (macOS-14) so real launchd
integration tests also run in the dev CI path, not just dist CI.
…Manager methods

Expand from 16 to 33 real launchd tests, covering every LaunchdManager
public method and critical lifecycle scenario against actual launchctl:

Methods:
- installService (fresh, idempotent, content-change re-bootstrap)
- startService, stopService (SIGTERM), restartService (kickstart -k)
- bootoutService, bootoutAndWaitForExit (with PID-aware fallback)
- uninstallService (bootout + delete plist, idempotent)
- stopServiceGracefully (SIGTERM → poll → SIGKILL escalation)
- rebootstrapFromPlist (re-register after bootout)
- getServiceStatus (running/unknown, PID parsing, env parsing)
- isServiceRegistered, hasPlistFile, isServiceInstalled
- waitForExit (with knownPid after bootout)
- getDomain, getPlistDir

Scenarios:
- Full start→stop→restart cycle
- Attach to already-running service from previous session
- KeepAlive auto-restart after SIGKILL (crash simulation)
- Rapid start/stop cycles with no orphans
- Port conflict: bootout cleans up even when port is blocked
- Double bootout is idempotent
- Process dying during bootout (race condition)
- Multiple services: start two, teardown both
- ensureNexuProcessesDead: no-op when clean, kills orphans
- teardownLaunchdServices: non-existent labels are safe
Spin up a local HTTP server that mimics the desktop release CDN and
verify the update feed resolution + YAML serving end-to-end:

1.  Feed URL resolves to valid fetchable URL (stable/arm64)
2.  Server serves latest-mac.yml at correct path
3.  YAML contains all electron-updater required fields
4.  All 3 channels × 2 architectures serve valid responses
5.  404 for invalid paths
6.  Explicit feedUrl overrides default
7.  Custom feed URL pointed at local server is fetchable
8.  Version comparison: server version > current = update available
9.  Download artifact URL serves content
10. Server request logging works
11. GitHub source returns github:// URL
12. NEXU_UPDATE_FEED_URL env takes highest priority

These catch real HTTP/YAML issues that mocked autoUpdater tests miss.
bootstrap.ts configureLocalDevPaths() unconditionally overwrote
process.env.NEXU_HOME with userData/.nexu, clobbering the value
passed by dev-launchd.sh (pnpm start). This caused controller to
read config from a fresh empty directory instead of the intended
.tmp/desktop/nexu-home/, making every pnpm start feel like a
first-time setup.

Fix: only set NEXU_HOME as fallback when not already provided.

Packaged users are unaffected — configureLocalDevPaths() returns
early when app.isPackaged is true (line 47).

Also adds 27 data-directory-invariants tests that guard:
- bootstrap.ts path configuration guards (isPackaged, env respect)
- runtime-config.ts NEXU_HOME resolution order
- controller env.ts data file locations under NEXU_HOME
- plist NEXU_HOME and OPENCLAW_STATE_DIR presence
- dev-launchd.sh path consistency
- AGENTS.md directory layout contract
- Packaged mode backward compatibility with 0.1.7
- OpenClaw state directory separation from NEXU_HOME
Fix: bootstrap.ts configureLocalDevPaths() now respects externally-set
NEXU_HOME instead of unconditionally overwriting it. This fixes pnpm
start creating a fresh config directory on every launch.

70 runtime tests verify every data path by calling real functions and
checking real output:

Controller plist env vars (26 tests):
  NEXU_HOME, OPENCLAW_STATE_DIR, OPENCLAW_CONFIG_PATH,
  OPENCLAW_SKILLS_DIR, OPENCLAW_EXTENSIONS_DIR,
  SKILLHUB_STATIC_SKILLS_DIR, PLATFORM_TEMPLATES_DIR, OPENCLAW_BIN,
  OPENCLAW_ELECTRON_EXECUTABLE, NODE_PATH, TMPDIR, PORT, HOST,
  WEB_URL, OPENCLAW_GATEWAY_PORT, OPENCLAW_GATEWAY_TOKEN,
  ELECTRON_RUN_AS_NODE, RUNTIME_MANAGE_OPENCLAW_PROCESS,
  RUNTIME_GATEWAY_PROBE_ENABLED, OPENCLAW_DISABLE_BONJOUR,
  NODE_ENV (dev+prod), HOME, PATH, NEXU_HOME omission

OpenClaw plist env vars (12 tests):
  ELECTRON_RUN_AS_NODE, OPENCLAW_CONFIG, OPENCLAW_CONFIG_PATH,
  OPENCLAW_STATE_DIR, OPENCLAW_LAUNCHD_LABEL (dev+prod),
  OPENCLAW_SERVICE_MARKER, HOME, PATH, NODE_PATH,
  no NEXU_HOME, no PORT

Plist structure (11 tests):
  ProgramArguments, WorkingDirectory, StandardOutPath,
  StandardErrorPath, KeepAlive, RunAtLoad, Label (dev+prod),
  gateway run args, --auth none (dev only), OtherJobEnabled

Path resolution (12 tests):
  desktop-paths.ts helpers (6), resolveLaunchdPaths dev+packaged (4),
  getDefaultPlistDir dev+prod, getLogDir dev+prod

Directory separation (5 tests):
  packaged NEXU_HOME ≠ userData, dev NEXU_HOME ≠ userData,
  NEXU_HOME under home, userData under Application Support,
  dev state repo-scoped

Config resolution (4 tests):
  NEXU_HOME default, env override, runtime-config priority chain
…, not path.resolve

Replace 70 trivial path.resolve assertions with 27 behavior-focused
tests that call real functions and check real output:

generatePlist output (25 tests):
  Call the real function with production-realistic inputs, parse the
  XML, and verify every env var value matches expected paths.
  Key checks:
  - NEXU_HOME → ~/.nexu (not under userData)
  - OPENCLAW_STATE_DIR → under userData (not under NEXU_HOME)
  - OPENCLAW_CONFIG_PATH consistent with OPENCLAW_STATE_DIR
  - OPENCLAW_SKILLS_DIR consistent with OPENCLAW_STATE_DIR
  - OpenClaw plist has NO NEXU_HOME (it doesn't use it)
  These would have caught the #526 bug.

runtime-config resolution (2 tests):
  Call getDesktopRuntimeConfig with different env objects, verify
  the NEXU_HOME priority chain: env > buildConfig > ~/.nexu default.

Real launchd env verification (macOS, 2 tests):
  Start a real launchd service, read launchctl print output, parse
  the environment block, verify NEXU_HOME and OPENCLAW_STATE_DIR
  are what we set and are different directories.
Deleted:
- quit-handler.test.ts (8 tests superseded by quit-handler-full.test.ts)
- data-directory-invariants.test.ts (27 grep-source-code tests, worthless)
- launchd-manager-ops-extended.test.ts (11 tests duplicated in ops.test.ts)

Added edge cases:
- daemon-supervisor: partial failure (one unit hangs), double dispose,
  child.error event handling
- lifecycle-teardown: process.kill EPERM handling, PID deduplication
  across multiple pgrep patterns
- update-install: stopPeriodicCheck before teardown verification,
  ensureNexuProcessesDead throw propagation
- launchd-integration (real launchd): NEXU_HOME with spaces,
  NEXU_HOME with Chinese unicode, OtherJobEnabled cascading behavior
- launchd-lifecycle-e2e.sh: Phase 6 (spaces) + Phase 7 (unicode)
  using .cjs scripts for ESM-safe execution

Net: -878 lines of garbage, +537 lines of behavior-focused tests.
17/17 real launchd e2e checks passing.
Required for running test coverage reports locally and in CI.
Add missing paths that affect launchd/lifecycle behavior:

- apps/controller/** — controller process management, env parsing,
  openclaw process spawning. Changes here can break launchd services.
- scripts/dev-launchd.sh — pnpm start/stop/restart entry point.
- scripts/kill-all.sh — global process cleanup.
- scripts/desktop-stop-smoke.sh — added to dist CI (was only in dev).
- scripts/launchd-lifecycle-e2e.sh — added to dev CI (was only in dist).
- tests/desktop/** — test changes should trigger CI to verify they pass.
- vitest.config.ts — test framework config changes could break all tests.

This ensures any change that could affect launchd, process lifecycle,
or the test suite triggers the macOS CI runners.
P0-2: Extract single authoritative gracefulShutdown(reason) function.

- Idempotent: shutdownInProgress guard prevents double teardown.
- 8-second hard timeout: if teardown hangs, process.exit(1) fires.
- Handles both launchd mode (teardownLaunchdServices) and orchestrator
  mode (orchestrator.dispose) in one function.
- SIGTERM + SIGINT handlers registered on process, route to
  gracefulShutdown then app.exit(0). This covers:
  - External kill (Activity Monitor, scripts, systemd)
  - Ctrl+C in terminal
  - System shutdown sending SIGTERM
- dev-launchd.sh stop now sends SIGTERM first (triggers graceful
  shutdown in Electron), waits up to 10s, then SIGKILL as fallback.
  Previously it used SIGKILL immediately, bypassing all cleanup.

P1-2: Replace removeAllListeners("before-quit") with removeListener.

- Store the specific handler reference (beforeQuitHandler).
- Only remove that handler, not all before-quit listeners.
- Prevents future listeners (telemetry, flush, etc.) from being
  accidentally removed.

Also fixes: dev.sh kill_residual_processes patterns were outdated
(referenced .tmp/sidecars/ paths that no longer exist), causing
CI stop smoke test failures. Updated to match current process patterns.
PR review fixes:
- launchd-bootstrap: orphan kill now only runs when neither service is
  registered with launchd, preventing SIGKILL of healthy managed services
  during relaunch-after-crash scenarios.
- quit-handler: teardownLaunchdServices always runs on quit-completely,
  even if plistDir is absent (plistDir only affects runtime-ports cleanup).
- desktop-stop-smoke.sh: add web sidecar pattern to process checks.
- desktop-check-dev.sh: replace fixed 2s sleep with bounded polling
  (max 10s) to avoid teardown race flakes.
- dev-env.sh: lsregister success/failure logged accurately.
- dev.sh: kill_residual_processes patterns updated to match current
  process paths (was using stale .tmp/sidecars/ paths).

CODEOWNERS: require @lefarcen review for test changes only.
Tests define the quality gate — source code anyone can change,
but acceptance criteria changes need review.
dev-launchd.sh stop_services now kills the tsc --watch and web watcher
background processes. These were only cleaned by the EXIT trap (which
fires when the start_services function's shell exits), but `pnpm stop`
calls stop_services directly without triggering the trap — leaving
watchers printing to the terminal after stop.

Also adds tsc watcher residual check to desktop-stop-smoke.sh.
- Revert CODEOWNERS to original (only api/migrations).
- Remove OPENCLAW_ENTRY prerequisite from launchd-lifecycle-e2e.sh —
  the script only tests controller, never launches openclaw.
@lefarcen lefarcen merged commit 0ecf216 into main Mar 26, 2026
9 checks passed
@mrcfps mrcfps deleted the fix/desktop-lifecycle-robustness branch March 26, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants