Skip to content

fix(desktop): harden startup/shutdown lifecycle + fix V8 JIT white-screen#597

Merged
mrcfps merged 29 commits intomainfrom
fix/desktop-lifecycle-robustness
Mar 27, 2026
Merged

fix(desktop): harden startup/shutdown lifecycle + fix V8 JIT white-screen#597
mrcfps merged 29 commits intomainfrom
fix/desktop-lifecycle-robustness

Conversation

@lefarcen
Copy link
Copy Markdown
Collaborator

@lefarcen lefarcen commented Mar 27, 2026

Relates to #510, #566, #592, #598

What

Comprehensive hardening of the desktop app's startup, shutdown, and update lifecycle, plus a fix for the nightly white-screen regression caused by missing V8 JIT entitlements.

Why

  • White screen on nightly: entitlements.mac.inherit.plist dropped allow-jit, causing V8 MAP_JIT mmap failure → renderer OOM → white screen on macOS 14.7.4+
  • "App is in use" during reinstall: launchd services mmap'd Electron Framework from inside .app bundle, locking it for Finder
  • Stale service attach after version upgrade: no version/identity check, causing new Electron to connect to old controller
  • Dev mode stale services: closing window or Cmd+Q skipped launchd teardown
  • Dirty installs: update proceeded regardless of whether critical file handles were held
  • Infinite restart loops: daemon-supervisor had no circuit breaker

How

V8 JIT White-Screen Fix

  • Restored allow-jit and allow-unsigned-executable-memory in entitlements.mac.inherit.plist alongside inherit
  • Added 15 static regression tests guarding entitlement values, dangerous keys, and electron-builder config

External Node Runner (core fix for "app in use")

  • APFS-clone Electron binary + Frameworks to ~/.nexu/runtime/nexu-runner.app/
  • APFS-clone controller sidecar to ~/.nexu/runtime/controller-sidecar/
  • All extractions use staging dir + atomic rename to prevent half-copies
  • Version-stamped; re-extracts on app update
  • Falls back to in-bundle paths if extraction fails

Evidence-Based Update Install

  • Two sweeps of SIGKILL (15s + 5s) using both launchd labels and pgrep
  • Then lsof +D check on critical paths (.app bundle, runner, sidecars)
  • Decision: no locks → install; critical locks → abort (electron-updater retries next launch)

Version-Aware Attach

  • runtime-ports.json now stores appVersion, userDataPath, buildSource, openclawStateDir
  • Attach refused on any identity mismatch (missing field = mismatch)
  • Stale session detection: dead Electron PID + metadata > 5min → auto-bootout

Unified Teardown

  • Dev mode: window close, Cmd+Q, app.quit() all run teardownLaunchdServices
  • Packaged: no-window branch (renderer crash) also runs full teardown

Other Fixes

  • daemon-supervisor: circuit breaker (MAX_CONSECUTIVE_RESTARTS=10, 120s window)
  • bootoutService: tolerates "already gone" launchctl errors
  • runtime-ports.json: atomic write via tmp + rename
  • Orphan cleanup: prefer launchd label lookup, pgrep with node.* prefix as fallback
  • OpenClaw sidecar extraction: staging dir + atomic rename
  • readBundleExecutableName(): dynamic from Info.plist
  • assertSafeRmTarget(): refuses rm -rf on paths < 3 segments

Affected areas

  • apps/desktop/build/entitlements.mac.inherit.plist
  • apps/desktop/main/services/launchd-bootstrap.ts (core)
  • apps/desktop/main/services/quit-handler.ts
  • apps/desktop/main/updater/update-manager.ts
  • apps/desktop/main/runtime/daemon-supervisor.ts
  • apps/desktop/main/runtime/manifests.ts
  • apps/desktop/main/services/launchd-manager.ts

Checklist

  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (50 files, 550+ tests)
  • Verified APFS clone works on real .app bundle (zero additional disk usage)
  • Verified external runner runs correctly with ELECTRON_RUN_AS_NODE=1
  • Manual test: pnpm start → close → pnpm start (no stale services)
  • Manual test: packaged app install → "Run in Background" → drag new DMG → replace succeeds
  • Manual test: version upgrade with running services → auto-teardown on new launch

🤖 Generated with Claude Code

lefarcen added 23 commits March 26, 2026 15:37
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.
- 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.
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
… 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.
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.
…-based updates, unified teardown

- Extract Electron binary + frameworks to ~/.nexu/runtime/nexu-runner.app/ via
  APFS clone so launchd services never reference the .app bundle, unblocking
  Finder drag-and-drop reinstalls
- Extract controller sidecar to ~/.nexu/runtime/controller-sidecar/ for the
  same reason; openclaw sidecar already extracted by existing logic
- All three extractions use staging dir + atomic rename to prevent half-copies
- Version-aware attach: refuse to attach to services from a different app
  version, build source, userDataPath, or openclawStateDir
- Evidence-based update install: after process sweeps, lsof-check critical
  paths; only abort if .app bundle or sidecar dirs are actually locked
- Unified dev/packaged teardown: Cmd+Q, window close, and no-window exit
  all go through teardownLaunchdServices in both modes
- daemon-supervisor circuit breaker: MAX_CONSECUTIVE_RESTARTS=10 with
  120s window, emits max_restarts_exceeded reason code
- bootoutService tolerates "already gone" errors
- runtime-ports.json atomic write (tmp + rename)
- Tighter orphan cleanup: prefer launchd label + runtime-ports metadata,
  fall back to pgrep with node.* prefix and process tree exclusion
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Mar 27, 2026

Deploying nexu-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 58a1a89
Status: ✅  Deploy successful!
Preview URL: https://8e57260a.nexu-docs.pages.dev
Branch Preview URL: https://fix-desktop-lifecycle-robust.nexu-docs.pages.dev

View logs

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

Async launchd path resolution now accepts appVersion; external runner/sidecar extraction uses staging and atomic move; persisted runtime metadata expanded and written atomically; restart circuit-breaker limits rapid restarts; orphan-process discovery tightened; embedded web server retries ports; update/install aborts when critical paths locked; quit handler forces teardown/exit in more cases.

Changes

Cohort / File(s) Summary
Entry / Bootstrap Call Sites
apps/desktop/main/index.ts, apps/desktop/main/services/index.ts
Call site updated to await async resolveLaunchdPaths(...) and pass app.getVersion(), userDataPath, and buildSource; re-exported checkCriticalPathsLocked.
Launchd Bootstrap Core
apps/desktop/main/services/launchd-bootstrap.ts
Made resolveLaunchdPaths async and accept appVersion; added ensureExternalNodeRunner() and checkCriticalPathsLocked(); extended runtime metadata persisted to runtime-ports.json; atomic write tmp+rename; port-retry logic for embedded server; authoritative launchctl PID discovery with pgrep fallback.
External Runner & Sidecar Extraction
apps/desktop/main/runtime/manifests.ts, apps/desktop/main/services/launchd-bootstrap.ts
Extraction moved to .staging dir with pre-clean, verify-in-staging, .archive-stamp, atomic move to final; external runner extracted to ~/.nexu/... with version stamping and safer rm semantics.
Daemon Supervisor / Restart Logic
apps/desktop/main/runtime/daemon-supervisor.ts, tests/desktop/daemon-supervisor-restart.test.ts
Added restart circuit-breaker: reset attempts after RESTART_WINDOW_MS, increment attempts, cap at MAX_CONSECUTIVE_RESTARTS and mark unit failed with max_restarts_exceeded; tests added.
Launchd Manager & Plist
apps/desktop/main/services/launchd-manager.ts, apps/desktop/main/services/plist-generator.ts
bootoutService tolerates common “service not found” errors instead of throwing; plist OPENCLAW_ELECTRON_EXECUTABLE now uses configured env.nodePath.
Quit / Update Safety
apps/desktop/main/services/quit-handler.ts, apps/desktop/main/updater/update-manager.ts
Quit handler now performs explicit teardown and force-exit in dev and packaged no-window fallback; quitAndInstall uses two-sweep process verification and aborts install if checkCriticalPathsLocked() reports locks.
Types / Shared
apps/desktop/shared/host.ts
Added RuntimeReasonCode member "max_restarts_exceeded".
Entitlements
apps/desktop/build/entitlements.mac.inherit.plist, tests/desktop/entitlements-plist.test.ts
Added com.apple.security.cs.allow-jit and com.apple.security.cs.allow-unsigned-executable-memory to inherit plist; new tests validate entitlements and build config.
Tests
tests/desktop/... (many files, e.g., launchd-bootstrap-*.test.ts, launchd-manager-bootout.test.ts, runtime-manifests.test.ts, update-install.test.ts, etc.)
Extensive test additions/updates covering async path resolution, staged extraction, port-retry, stale-session detection, orphan PID discovery, checkCriticalPathsLocked, bootout tolerance, restart circuit-breaker, and updated quit/update flows.

Sequence Diagram(s)

sequenceDiagram
  participant App as App (main/index)
  participant Resolver as resolveLaunchdPaths
  participant Extractor as ensureExternalNodeRunner
  participant FS as Filesystem
  participant Web as Embedded Web Server
  participant Launchd as launchd (launchctl)

  App->>Resolver: resolveLaunchdPaths(isPackaged, resourcesPath, appVersion)
  alt packaged
    Resolver->>Extractor: extract runner & controller sidecar (staging)
    Extractor->>FS: rm staging, mkdir staging, extract payload.tar.gz
    FS-->>Extractor: verify node_modules/openclaw/openclaw.mjs & .archive-stamp
    Extractor->>FS: rm old extracted root, mv staging -> final (atomic)
    Resolver-->>App: return paths under ~/.nexu/runtime/... 
  else fallback
    Resolver-->>App: return in-bundle .app/Resources/... paths
  end

  App->>Web: startEmbeddedWebServer(preferredPort)
  Web-->>App: success or try adjacent ports (up to 5) or bind port 0

  App->>Launchd: bootstrap services (using resolved paths, runtime-ports.json)
  Launchd-->>App: service status / PIDs (used for orphan detection)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

bug

Suggested reviewers

  • PerishCode
  • mrcfps
  • nettee

Poem

🐰 I dug a staging hole and stamped it true,
moved bits by moonlight, neat and new.
Restarts count hops, then quietly cease,
ports knock next doors, then let the OS please.
A rabbit cheers: clean quits, safe paths, and peace. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.00% 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 'fix(desktop): harden startup/shutdown lifecycle + fix V8 JIT white-screen' accurately and concisely summarizes the main changes: lifecycle hardening (startup/shutdown) and a V8 JIT entitlements fix.
Description check ✅ Passed The PR description comprehensively addresses all template sections: clear 'What' statement, detailed 'Why' with specific problems, comprehensive 'How' with implementation details, all affected areas checked, complete checklist completion, and extensive notes for reviewers.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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

@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: 9

Caution

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

⚠️ Outside diff range comments (1)
tests/desktop/quit-handler-full.test.ts (1)

339-354: ⚠️ Potential issue | 🟡 Minor

Assert the async teardown side effects here.

Right now this test still passes if the handler only calls preventDefault(). Please flush the async branch and assert mockTeardown plus mockApp.exit(0) so the new dev-mode behavior is actually covered.

💡 Minimal follow-up
     const handler = getBeforeQuitHandler();
     const event = { preventDefault: vi.fn() };
     handler(event);

     // Dev mode now prevents default to do async teardown before exiting
     expect(event.preventDefault).toHaveBeenCalled();
+    await flush();
+    expect(mockTeardown).toHaveBeenCalledTimes(1);
+    expect(mockApp.exit).toHaveBeenCalledWith(0);
🤖 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 339 - 354, The test
currently only asserts that preventDefault() was called; update it to also flush
and await the async teardown branch and assert the teardown and exit side
effects: after calling getBeforeQuitHandler() and handler(event), wait for
pending microtasks (e.g., await Promise.resolve() or use
vi.runAllTimers()/flushPromises depending on test setup) so the async path
inside installLaunchdQuitHandler/createQuitOpts runs, then
expect(mockTeardown).toHaveBeenCalled() and
expect(mockApp.exit).toHaveBeenCalledWith(0). Ensure you reference the same
functions used in the diff: installLaunchdQuitHandler, getBeforeQuitHandler,
createQuitOpts, mockTeardown, and mockApp.exit.
🧹 Nitpick comments (2)
apps/desktop/main/runtime/manifests.ts (1)

224-228: The swap still has a destructive gap.

The rm -rf + mv pair avoids a half-extracted destination, but it still deletes the last known-good sidecar before the replacement is live. If the app is killed in that window, cold start loses the previous extraction instead of reusing it. Consider versioned directories plus an atomic symlink flip, or at least a rollback path that restores the old directory if the rename fails.

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

In `@apps/desktop/main/runtime/manifests.ts` around lines 224 - 228, The current
swap using existsSync + execFileSync("rm", ["-rf", extractedSidecarRoot]);
execFileSync("mv", [stagingRoot, extractedSidecarRoot]) deletes the old sidecar
before the new one is atomically activated, risking loss if the process is
killed; change to a safe swap: write staging into a versioned directory (e.g.,
stagingRoot + timestamp or version), then atomically switch an
extractedSidecarRoot symlink to point to the new version (or use fs.rename on
versioned dirs) so the old version remains until the switch; alternatively,
implement a rollback path in the code around execFileSync("mv", ...) that on mv
failure restores the previous directory (do not run rm -rf until the new
directory is activated), and reference extractedSidecarRoot, stagingRoot,
existsSync, execFileSync("mv", ...) and the rm removal call when making the
changes.
tests/desktop/launchd-bootstrap.test.ts (1)

25-35: Fix mock callback shape for promisified execFile.

The callback signature uses separate stdout and stderr parameters, but util.promisify(execFile) expects a callback that receives a single result object { stdout, stderr }. Change cb(null, "", "") to cb(null, { stdout: "", stderr: "" }) to match the contract the production code expects when it awaits execFileAsync.

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

In `@tests/desktop/launchd-bootstrap.test.ts` around lines 25 - 35, The mock for
node:child_process.execFile in the test uses a callback shape (cb(null, "", ""))
that doesn't match util.promisify(execFile) which the production code awaits;
update the vi.mock implementation for execFile so the callback is invoked as
cb(null, { stdout: "", stderr: "" }) (i.e., a single result object) to match the
expected contract used by execFileAsync/promisified calls.
🤖 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/main/runtime/manifests.ts`:
- Around line 197-208: The code currently shells out via execFileSync to
PATH-resolved binaries ("rm", "tar", "mv", "sleep"); replace shell commands with
Node APIs and explicit, resolved binaries: use fs.rmSync or fs.unlinkSync
instead of execFileSync("rm", ...), keep mkdirSync for directory creation, and
replace execFileSync("tar", ["-xzf", ...]) with a programmatic tar extractor
(e.g., require the 'tar' package and call tar.x(...)) so extraction does not
rely on PATH; if any remaining external tool calls are unavoidable (e.g., a
platform-specific helper), resolve their path explicitly via
require.resolve(...) or construct a full path and execute with
process.execPath/child_process.spawn rather than a bare command; update all
usages around stagingRoot, archivePath, execFileSync, existsSync and any later
mv/sleep calls to follow the same pattern.

In `@apps/desktop/main/services/launchd-bootstrap.ts`:
- Around line 934-947: The code currently trusts recovered.electronPid from
readRuntimePorts and only checks isProcessAlive before adding it to allPids,
which can target an unrelated reused PID; update the logic in the loop that
reads runtime-ports (the block using getDefaultPlistDir, readRuntimePorts and
isProcessAlive) to further validate the PID identity before adding it—e.g.,
compare the process command/executable or start time/owner against expected
Electron identifiers (using platform-appropriate means like ps/proc APIs) or
only accept the PID if it matches the session metadata stored in
runtime-ports.json; ensure this change prevents killOrphanNexuProcesses /
ensureNexuProcessesDead from acting on mismatched PIDs and coordinate with
bootstrapWithLaunchd’s stale-session age check so stored PIDs are not treated as
authoritative once the original session is gone.
- Around line 684-717: The loop treating every startEmbeddedWebServer() failure
as a port-conflict must be changed to only retry on bind-related errors: in the
for-loop catch, inspect the caught error (from startEmbeddedWebServer) and if
its code is EADDRINUSE (and optionally EACCES if you want to treat
permission-based bind failures as non-retryable per policy) then log and
continue to the next port; otherwise re-throw immediately so real startup errors
surface. Apply the same check for the final OS-assigned fallback attempt: if the
catch error is not a bind conflict, re-throw it instead of masking it with the
generic "all port attempts exhausted" message; reference startEmbeddedWebServer,
WEB_PORT_ATTEMPTS, webServer, and effectivePorts.webPort when making the change.
- Around line 885-888: The code shells out to bare binaries via execFileAsync
(e.g., calls invoking "pgrep", "launchctl", "lsof", "rm", "cp") which breaks in
packaged desktop apps with a reduced PATH; update all execFileAsync invocations
in launchd-bootstrap.ts to resolve and invoke absolute system binaries (e.g.,
/usr/bin/pgrep, /bin/rm, /bin/cp or resolve via require.resolve if bundling a
shipped binary) or, better, replace filesystem operations with Node fs APIs
(fs.rm/fs.copyFile) and use process.execPath to spawn Node-based helpers where
appropriate; search for all uses of execFileAsync and the helper functions
around process lifecycle/cleanup (the pgrep call shown and the shelling points
around the ranges noted) and change the command strings to absolute paths or
switch to Node-native APIs, ensuring arguments are preserved and errors are
propagated to processLogger or thrown.
- Around line 1027-1039: The lsof output filtering currently uses
!line.includes(String(process.pid)), which can false-positive when the PID
digits appear elsewhere; instead parse the lsof columns to compare the PID
column exactly: after obtaining stdout from execFileAsync for "lsof +D
{criticalPath}", split into lines, locate the header row (startsWith "COMMAND")
to find the PID column index (header.split(/\s+/).indexOf("PID")), then for each
non-header row split by whitespace to extract the PID field and exclude only
rows whose PID === String(process.pid); update the lines filtering logic (the
variable lines and the subsequent lines.some(...) check that leads to pushing
into lockedPaths) to use this PID-accurate comparison so other processes are not
accidentally filtered out.

In `@apps/desktop/main/services/quit-handler.ts`:
- Around line 101-123: The current quit-handler awaits teardownLaunchdServices()
without protecting the force-quit path, so if teardown rejects the app can hang;
update the async IIFE in the event.preventDefault() handler (the block that
calls opts.onBeforeQuit, opts.webServer?.close and teardownLaunchdServices) to
ensure __nexuForceQuit is set and app.exit(0) is always executed by moving those
two calls into a finally block, and wrap teardownLaunchdServices() in try/catch
(or catch its rejection) to log errors (e.g., console.error("Error tearing down
launchd services:", err)) without preventing the finally from running; apply the
same change to the equivalent quit logic elsewhere that calls
teardownLaunchdServices().

In `@tests/desktop/daemon-supervisor-restart.test.ts`:
- Around line 16-26: The test is asserting local literals instead of verifying
the production circuit-breaker logic; replace this brittle duplication by either
exporting a small pure helper from daemon-supervisor (e.g., the decision
function currently duplicated as evaluateAutoRestart or the constants
MAX_CONSECUTIVE_RESTARTS / RESTART_WINDOW_MS) and import it in the test, or
drive the real exit handling path by invoking the actual restart/evaluate logic
(evaluateAutoRestart or the supervisor's restart handler) with mocked timers and
a fake process to observe restarts and windowing; update the test to call that
exported helper or the real handler and assert behavior rather than asserting
local literals.

In `@tests/desktop/launchd-bootstrap-lifecycle.test.ts`:
- Around line 12-37: Replace the current unit-only checks with an
integration-style test that invokes bootstrapWithLaunchd() and verifies behavior
via mocks: stub/mock the launchd-dependent helpers (isProcessAlive if exported,
the bootoutService implementation, and startEmbeddedWebServer) and any
retry/threshold timers, call bootstrapWithLaunchd(), then assert that
bootoutService and startEmbeddedWebServer were called with the expected
arguments and number of retries; update tests currently using direct
process.kill checks to instead set up these mocks and validate observed calls to
bootstrapWithLaunchd()’s collaborators.

In `@tests/desktop/launchd-startup-scenarios.test.ts`:
- Around line 200-213: The beforeEach currently calls vi.clearAllMocks(), which
preserves queued mockResolvedValueOnce and mockImplementation state and causes
test leakage; replace vi.clearAllMocks() with vi.resetAllMocks() (or explicitly
reset readFile and execFile mocks) in the beforeEach that sets up mockWebServer
to ensure readRuntimePorts, execFile and readFile have no queued responses or
persistent implementations before each test; update other beforeEach occurrences
noted (around uses of readRuntimePorts/execFile/readFile) to the same reset
approach.

---

Outside diff comments:
In `@tests/desktop/quit-handler-full.test.ts`:
- Around line 339-354: The test currently only asserts that preventDefault() was
called; update it to also flush and await the async teardown branch and assert
the teardown and exit side effects: after calling getBeforeQuitHandler() and
handler(event), wait for pending microtasks (e.g., await Promise.resolve() or
use vi.runAllTimers()/flushPromises depending on test setup) so the async path
inside installLaunchdQuitHandler/createQuitOpts runs, then
expect(mockTeardown).toHaveBeenCalled() and
expect(mockApp.exit).toHaveBeenCalledWith(0). Ensure you reference the same
functions used in the diff: installLaunchdQuitHandler, getBeforeQuitHandler,
createQuitOpts, mockTeardown, and mockApp.exit.

---

Nitpick comments:
In `@apps/desktop/main/runtime/manifests.ts`:
- Around line 224-228: The current swap using existsSync + execFileSync("rm",
["-rf", extractedSidecarRoot]); execFileSync("mv", [stagingRoot,
extractedSidecarRoot]) deletes the old sidecar before the new one is atomically
activated, risking loss if the process is killed; change to a safe swap: write
staging into a versioned directory (e.g., stagingRoot + timestamp or version),
then atomically switch an extractedSidecarRoot symlink to point to the new
version (or use fs.rename on versioned dirs) so the old version remains until
the switch; alternatively, implement a rollback path in the code around
execFileSync("mv", ...) that on mv failure restores the previous directory (do
not run rm -rf until the new directory is activated), and reference
extractedSidecarRoot, stagingRoot, existsSync, execFileSync("mv", ...) and the
rm removal call when making the changes.

In `@tests/desktop/launchd-bootstrap.test.ts`:
- Around line 25-35: The mock for node:child_process.execFile in the test uses a
callback shape (cb(null, "", "")) that doesn't match util.promisify(execFile)
which the production code awaits; update the vi.mock implementation for execFile
so the callback is invoked as cb(null, { stdout: "", stderr: "" }) (i.e., a
single result object) to match the expected contract used by
execFileAsync/promisified calls.
🪄 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: 92617c49-e8b0-49cb-b675-340152795f5c

📥 Commits

Reviewing files that changed from the base of the PR and between d67c73a and cf8dd99.

📒 Files selected for processing (19)
  • apps/desktop/main/index.ts
  • apps/desktop/main/runtime/daemon-supervisor.ts
  • apps/desktop/main/runtime/manifests.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/plist-generator.ts
  • apps/desktop/main/services/quit-handler.ts
  • apps/desktop/main/updater/update-manager.ts
  • apps/desktop/shared/host.ts
  • tests/desktop/daemon-supervisor-restart.test.ts
  • tests/desktop/launchd-bootstrap-edge.test.ts
  • tests/desktop/launchd-bootstrap-lifecycle.test.ts
  • tests/desktop/launchd-bootstrap.test.ts
  • tests/desktop/launchd-manager-bootout.test.ts
  • tests/desktop/launchd-startup-scenarios.test.ts
  • tests/desktop/lifecycle-teardown.test.ts
  • tests/desktop/quit-handler-full.test.ts
  • tests/desktop/update-install.test.ts

Comment thread apps/desktop/main/runtime/manifests.ts
Comment thread apps/desktop/main/services/launchd-bootstrap.ts
Comment thread apps/desktop/main/services/launchd-bootstrap.ts
Comment thread apps/desktop/main/services/launchd-bootstrap.ts
Comment thread apps/desktop/main/services/launchd-bootstrap.ts
Comment thread apps/desktop/main/services/quit-handler.ts Outdated
Comment thread tests/desktop/daemon-supervisor-restart.test.ts
Comment thread tests/desktop/launchd-bootstrap-lifecycle.test.ts
Comment thread tests/desktop/launchd-startup-scenarios.test.ts
…itative PID tests

- ensureNexuProcessesDead: test launchctl-label-only discovery path
- update-install: test critical path lock check (abort vs proceed)
- attach identity: test buildSource/userDataPath/appVersion mismatch teardown
- quit-handler: test no-window packaged teardown, dev before-quit teardown
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: 1

Caution

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

⚠️ Outside diff range comments (1)
tests/desktop/launchd-startup-scenarios.test.ts (1)

822-857: ⚠️ Potential issue | 🟡 Minor

Duplicate scenario numbers — "Scenario 19" and "Scenario 20" appear twice.

Lines 822-840 are labeled "Scenario 19" but line 752 already defines Scenario 19 (build identity mismatch). Similarly, lines 843-857 are labeled "Scenario 20" but line 791 already defines Scenario 20 (partial attach). This causes confusion when referencing test failures or discussing specific scenarios.

Consider renumbering to maintain a unique sequence:

  • Lines 822-840: Scenario 25 (web server startup failure)
  • Lines 843-857: Scenario 26 (prod mode labels)
🔧 Proposed fix
   // -----------------------------------------------------------------------
-  // Scenario 19: Web server both attempts fail → bootstrap throws
+  // Scenario 25: Web server all attempts fail → bootstrap throws
   // -----------------------------------------------------------------------
-  it("Scenario 19: web server startup failure propagates as error", async () => {
+  it("Scenario 25: web server startup failure propagates as error", async () => {
     const webServerMock = await import(
   // -----------------------------------------------------------------------
-  // Scenario 20: Prod labels used when isDev=false
+  // Scenario 26: Prod labels used when isDev=false
   // -----------------------------------------------------------------------
-  it("Scenario 20: prod mode uses non-dev labels and plist dir", async () => {
+  it("Scenario 26: prod mode uses non-dev labels and plist dir", async () => {
     const { bootstrapWithLaunchd } = await import(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/launchd-startup-scenarios.test.ts` around lines 822 - 857, The
test file has duplicate scenario numbers in the comment headers and the it(...)
titles for the web server failure and prod-mode tests (the blocks with
it("Scenario 19: web server startup failure propagates as error", ...) and
it("Scenario 20: prod mode uses non-dev labels and plist dir", ...)); rename
those to unique numbers (e.g., change the header comments and the it(...)
strings to "Scenario 25" and "Scenario 26" respectively) so they no longer
collide with the earlier Scenario 19 and 20 tests; also scan for any other
duplicate "Scenario 19"/"Scenario 20" strings in tests to keep numbering
consistent.
🧹 Nitpick comments (2)
tests/desktop/quit-handler-full.test.ts (2)

342-363: Lock down these direct-teardown before-quit branches with a no-dialog assertion.

Both tests still pass if the handler briefly routes through showMessageBox before tearing down and exiting. Adding a negative assertion would pin the intended “no prompt” behavior to the new packaged no-window and dev-mode paths.

Suggested assertions
    expect(event.preventDefault).toHaveBeenCalled();
    await flush();
+   expect(mockDialog.showMessageBox).not.toHaveBeenCalled();
    expect(opts.onBeforeQuit).toHaveBeenCalledTimes(1);
    expect(opts.webServer.close).toHaveBeenCalledTimes(1);
    expect(event.preventDefault).toHaveBeenCalled();
    await flush();
+   expect(mockDialog.showMessageBox).not.toHaveBeenCalled();
    expect(mockTeardown).toHaveBeenCalledTimes(1);
    expect(mockApp.exit).toHaveBeenCalledWith(0);

Also applies to: 368-385

🤖 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 342 - 363, The test
must assert that no user prompt is shown when packaged/no-window or dev-mode
paths run; after calling installLaunchdQuitHandler and invoking
getBeforeQuitHandler(event) add a negative assertion that the Electron dialog
showMessageBox (or the test double used for it) was not called (e.g.,
expect(dialog.showMessageBox).not.toHaveBeenCalled()), then keep the existing
assertions for opts.onBeforeQuit, opts.webServer.close, mockTeardown and
mockApp.exit; apply the same no-dialog assertion to the other test covered (the
dev-mode path) so both branches explicitly verify no prompt is displayed.

122-131: Consider restoring mutable mock implementations explicitly in beforeEach.

If this suite is only relying on vi.clearAllMocks(), mockWindow.isVisible.mockReturnValue(false) from the packaged before-quit case will leak into later tests because clearAllMocks does not restore implementations. Re-seeding the default true return here keeps each case self-contained regardless of the global Vitest mock-reset settings.

Suggested change
  beforeEach(() => {
    vi.clearAllMocks();
    closeHandlers.length = 0;
    mockGetAllWindows.mockReturnValue([mockWindow]);
+   mockWindow.isVisible.mockReturnValue(true);
    mockApp.__nexuForceQuit = false;
  beforeEach(() => {
    vi.clearAllMocks();
    closeHandlers.length = 0;
    mockGetAllWindows.mockReturnValue([mockWindow]);
+   mockWindow.isVisible.mockReturnValue(true);
    mockApp.__nexuForceQuit = false;

Also applies to: 414-421

🤖 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 122 - 131, The test
setup only calls vi.clearAllMocks() which does not restore mock implementations,
so explicitly re-seed mutable mock functions in beforeEach: call
mockWindow.isVisible.mockReturnValue(true) (and any other mutable mocks like
mockWindow.isDestroyed/mockWindow.isMinimized if used elsewhere) after
vi.clearAllMocks(), keep mockGetAllWindows.mockReturnValue([mockWindow]) and
mockDialog.showMessageBox.mockResolvedValue(...) as shown, and apply the same
explicit re-seeding in the other beforeEach block referenced (the later suite
around the packaged `before-quit` case) to avoid cross-test leakage.
🤖 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 252-266: The test's mockImplementationOnce is consumed by the
initial rm -rf call because existsSync is mocked true; update the test so the
execFile mock targets the copy commands instead of being a one-time stub:
replace mockImplementationOnce with mockImplementation((cmd, args, cb) => { if
(cmd === "cp") { if (cb) cb(new Error("disk full"), "", ""); return { stdout:
"", stderr: "disk full" }; } if (cb) cb(null, "", ""); return { stdout: "",
stderr: "" }; }) or alternatively set the existsSync mock to return false so the
staging rm is not invoked; ensure the failing branch specifically matches "cp"
(and both "cp -c" and plain "cp") so ensureExternalNodeRunner() will take the
in-bundle fallback path.

---

Outside diff comments:
In `@tests/desktop/launchd-startup-scenarios.test.ts`:
- Around line 822-857: The test file has duplicate scenario numbers in the
comment headers and the it(...) titles for the web server failure and prod-mode
tests (the blocks with it("Scenario 19: web server startup failure propagates as
error", ...) and it("Scenario 20: prod mode uses non-dev labels and plist dir",
...)); rename those to unique numbers (e.g., change the header comments and the
it(...) strings to "Scenario 25" and "Scenario 26" respectively) so they no
longer collide with the earlier Scenario 19 and 20 tests; also scan for any
other duplicate "Scenario 19"/"Scenario 20" strings in tests to keep numbering
consistent.

---

Nitpick comments:
In `@tests/desktop/quit-handler-full.test.ts`:
- Around line 342-363: The test must assert that no user prompt is shown when
packaged/no-window or dev-mode paths run; after calling
installLaunchdQuitHandler and invoking getBeforeQuitHandler(event) add a
negative assertion that the Electron dialog showMessageBox (or the test double
used for it) was not called (e.g.,
expect(dialog.showMessageBox).not.toHaveBeenCalled()), then keep the existing
assertions for opts.onBeforeQuit, opts.webServer.close, mockTeardown and
mockApp.exit; apply the same no-dialog assertion to the other test covered (the
dev-mode path) so both branches explicitly verify no prompt is displayed.
- Around line 122-131: The test setup only calls vi.clearAllMocks() which does
not restore mock implementations, so explicitly re-seed mutable mock functions
in beforeEach: call mockWindow.isVisible.mockReturnValue(true) (and any other
mutable mocks like mockWindow.isDestroyed/mockWindow.isMinimized if used
elsewhere) after vi.clearAllMocks(), keep
mockGetAllWindows.mockReturnValue([mockWindow]) and
mockDialog.showMessageBox.mockResolvedValue(...) as shown, and apply the same
explicit re-seeding in the other beforeEach block referenced (the later suite
around the packaged `before-quit` case) to avoid cross-test leakage.
🪄 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: cd7eecd4-d45f-4b5d-a828-d9696c532aa3

📥 Commits

Reviewing files that changed from the base of the PR and between cf8dd99 and de57495.

📒 Files selected for processing (5)
  • tests/desktop/launchd-bootstrap-edge.test.ts
  • tests/desktop/launchd-startup-scenarios.test.ts
  • tests/desktop/lifecycle-teardown.test.ts
  • tests/desktop/quit-handler-full.test.ts
  • tests/desktop/update-install.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/desktop/lifecycle-teardown.test.ts

Comment thread tests/desktop/launchd-bootstrap-edge.test.ts
…ion guard tests

Root cause of nightly white-screen: entitlements.mac.inherit.plist dropped
allow-jit, causing V8 to fail mmap with MAP_JIT on macOS 14.7.4+ → renderer
OOM → white screen.

Fix: add back allow-jit and allow-unsigned-executable-memory alongside inherit.

Tests: 8 static analysis tests guard against future entitlement regressions,
verifying both parent and inherit plists have the required V8 JIT keys.
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.

🧹 Nitpick comments (1)
tests/desktop/launchd-bootstrap-edge.test.ts (1)

30-39: Default mock callback signature inconsistent with promisify semantics.

The default mockExecFile invokes the callback with three arguments cb(null, "", ""), but later overrides correctly use the object form callback?.(null, { stdout: "", stderr: "" }). While this may not cause failures since most tests override the mock, the inconsistency could confuse future contributors or cause subtle issues if a new test relies on the default behavior.

♻️ Suggested fix for consistency
 const mockExecFile = vi.fn(
   (
     _cmd: string,
     _args: string[],
-    cb?: (err: Error | null, stdout: string, stderr: string) => void,
+    cb?: (
+      err: Error | null,
+      result: { stdout: string; stderr: string },
+    ) => void,
   ) => {
-    if (cb) cb(null, "", "");
+    if (cb) cb(null, { stdout: "", stderr: "" });
     return { stdout: "", stderr: "" };
   },
 );

Based on learnings: "When mocking execFile in desktop tests, mocks should invoke the callback as (error, { stdout, stderr }) ... and should not call it as (error, stdout, stderr)."

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

In `@tests/desktop/launchd-bootstrap-edge.test.ts` around lines 30 - 39, The
mockExecFile default callback uses the old Node callback shape cb(null, "", "")
but tests and promisified code expect the object form cb(null, { stdout, stderr
}); update the default implementation of mockExecFile to call cb(null, { stdout:
"", stderr: "" }) (and return a matching object) so it matches the overrides and
promisify semantics; locate the mock by the mockExecFile variable in the test
and change the callback invocation and return value accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/desktop/launchd-bootstrap-edge.test.ts`:
- Around line 30-39: The mockExecFile default callback uses the old Node
callback shape cb(null, "", "") but tests and promisified code expect the object
form cb(null, { stdout, stderr }); update the default implementation of
mockExecFile to call cb(null, { stdout: "", stderr: "" }) (and return a matching
object) so it matches the overrides and promisify semantics; locate the mock by
the mockExecFile variable in the test and change the callback invocation and
return value accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31aacb08-3b40-4ab9-b78e-626a6682e834

📥 Commits

Reviewing files that changed from the base of the PR and between de57495 and 10e1269.

📒 Files selected for processing (5)
  • apps/desktop/build/entitlements.mac.inherit.plist
  • tests/desktop/data-directory-runtime.test.ts
  • tests/desktop/entitlements-plist.test.ts
  • tests/desktop/launchd-bootstrap-edge.test.ts
  • tests/desktop/runtime-manifests.test.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/desktop/data-directory-runtime.test.ts

…g, ProgramArguments order

- Entitlements: verify <true/> values (not just key presence), no dangerous
  entitlements (disable-executable-page-protection, get-task-allow), no
  duplicate keys, hardenedRuntime enabled in electron-builder
- ProgramArguments: exact ordering for controller [node, entry] and
  openclaw [node, openclaw.mjs, gateway, run], dev --auth none insertion
- Openclaw completeness: WorkingDirectory, StandardErrorPath, KeepAlive
  SuccessfulExit, ThrottleInterval, RunAtLoad=false
- XML escaping: &, <, >, ", ' in path fields correctly escaped
@lefarcen lefarcen changed the title fix(desktop): harden lifecycle robustness — external runner, evidence-based updates, unified teardown fix(desktop): harden startup/shutdown lifecycle + fix V8 JIT white-screen Mar 27, 2026
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: 1

Caution

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

⚠️ Outside diff range comments (1)
tests/desktop/launchd-startup-scenarios.test.ts (1)

822-846: ⚠️ Potential issue | 🟡 Minor

Duplicate scenario numbers cause confusion.

Scenarios 19 and 20 each appear twice:

  • Lines 752 & 825: Both labeled "Scenario 19"
  • Lines 791 & 846: Both labeled "Scenario 20"

The tests at lines 825 and 846 should be renumbered (e.g., to 21 and 22) to maintain unique scenario identifiers across the suite.

✏️ Suggested renumbering
   // -----------------------------------------------------------------------
-  // Scenario 19: Web server both attempts fail → bootstrap throws
+  // Scenario 21: Web server all attempts fail → bootstrap throws
   // -----------------------------------------------------------------------
-  it("Scenario 19: web server startup failure propagates as error", async () => {
+  it("Scenario 21: web server startup failure propagates as error", async () => {
   // -----------------------------------------------------------------------
-  // Scenario 20: Prod labels used when isDev=false
+  // Scenario 22: Prod labels used when isDev=false
   // -----------------------------------------------------------------------
-  it("Scenario 20: prod mode uses non-dev labels and plist dir", async () => {
+  it("Scenario 22: prod mode uses non-dev labels and plist dir", async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/launchd-startup-scenarios.test.ts` around lines 822 - 846, Two
test cases reuse the same scenario numbers causing confusion; rename the
duplicate "Scenario 19" and "Scenario 20" test titles to unique identifiers
(e.g., "Scenario 21" and "Scenario 22") so each it(...) description is distinct.
Locate the it(...) calls with titles starting "Scenario 19: web server startup
failure propagates as error" (the bootstrapWithLaunchd test) and "Scenario 20:
prod mode uses non-dev labels and plist dir" and update their leading scenario
numbers in the string descriptions to new unique numbers; no other logic changes
are needed.
🧹 Nitpick comments (3)
tests/desktop/entitlements-plist.test.ts (2)

113-120: Narrow parsed package.json instead of stacking unchecked casts.

JSON.parse() data is being asserted straight through to nested Record<string, unknown> objects, which sidesteps the runtime narrowing this repo asks for. A tiny helper here would keep the test type-safe and fail with a clearer message if build.mac disappears.

♻️ Possible cleanup
+function asRecord(value: unknown, errorMessage: string): Record<string, unknown> {
+  if (typeof value !== "object" || value === null || Array.isArray(value)) {
+    throw new Error(errorMessage);
+  }
+  return value as Record<string, unknown>;
+}
...
-    const config = JSON.parse(packageJson) as Record<string, unknown>;
-    const build = config.build as Record<string, unknown>;
-    const mac = build.mac as Record<string, unknown>;
+    const config = asRecord(JSON.parse(packageJson), "desktop package.json must be an object");
+    const build = asRecord(config.build, "desktop package.json is missing build config");
+    const mac = asRecord(build.mac, "desktop package.json is missing build.mac config");

As per coding guidelines, "Never use any type. Use unknown with narrowing or z.infer<typeof schema>".

Also applies to: 232-240

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

In `@tests/desktop/entitlements-plist.test.ts` around lines 113 - 120, The test is
unsafely casting JSON.parse(...) through nested Record<string, unknown> types;
replace the unchecked casts by parsing into unknown and using a small runtime
type-narrowing helper (e.g., an assertIsObject or isRecord type guard) to
validate and extract config, build, and mac before accessing their fields in the
"electron-builder config references both entitlement files" test — locate the
JSON.parse call and variables packageJson, config, build, and mac and update the
code to parse into unknown then call the helper to narrow to the expected shaped
objects (failing with a clear error if build or mac are missing) instead of
stacking casts.

131-145: These tests don't actually validate XML structure.

They only check for a few substrings, so malformed plist markup can still pass. Parse the plist once and assert on the root/dict structure if you want this to be a real XML guard.

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

In `@tests/desktop/entitlements-plist.test.ts` around lines 131 - 145, Replace the
brittle substring checks in the "parent plist is valid plist XML" and "inherit
plist is valid plist XML" tests: parse parentPlist and inheritPlist with a real
plist/XML parser (e.g., plist.parse or an XML parser) and assert the returned
value is an object and contains the expected root dictionary keys (i.e.,
top-level is a dictionary/object), rather than just checking for string
fragments; update the assertions in those two it() blocks to validate the parsed
structure (use parentPlist and inheritPlist as the inputs to the parser and
assert the parser result is truthy and of type object and contains the expected
dict keys).
tests/desktop/plist-generator.test.ts (1)

225-234: Assert the KeepAlive wrapper, not just SuccessfulExit.

This passes as long as SuccessfulExit=false appears somewhere in the plist. It won't catch SuccessfulExit being emitted outside the KeepAlive dictionary, which is the part launchd actually uses for restart semantics.

♻️ Possible cleanup
-    expect(plist).toContain("<key>SuccessfulExit</key>");
-    expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
+    expect(plist).toMatch(
+      /<key>KeepAlive<\/key>\s*<dict>[\s\S]*<key>SuccessfulExit<\/key>\s*<false\/>[\s\S]*<\/dict>/,
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/desktop/plist-generator.test.ts` around lines 225 - 234, The test is
only asserting that <key>SuccessfulExit</key><false/> appears anywhere in the
plist, so update the assertion to ensure SuccessfulExit=false lives inside the
KeepAlive dictionary produced by generatePlist("openclaw", mockEnv);
specifically, replace the loose SuccessfulExit assertion with one that matches
the KeepAlive block containing SuccessfulExit false (e.g. use a regex that looks
for
<key>KeepAlive</key>\s*<dict>...<key>SuccessfulExit<\/key>\s*<false\/>...<\/dict>
or otherwise parse the plist and assert the KeepAlive object's SuccessfulExit is
false) so the test verifies the correct restart semantics.
🤖 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-startup-scenarios.test.ts`:
- Around line 963-1002: The test creates legacyPorts as a JSON string
(legacyPorts = JSON.stringify(...)) but then mocks fs.readFile to resolve with
JSON.stringify(legacyPorts) (double-stringifying); update the fsMock.readFile
mocks to return legacyPorts (not JSON.stringify(legacyPorts)) so
readRuntimePorts/JSON.parse receives a single JSON string. Look for the
legacyPorts variable and the two mockResolvedValueOnce calls on fsMock.readFile
and change them to mockResolvedValueOnce(legacyPorts); the rest of the test
(bootstrapWithLaunchd, assertions) can remain unchanged.

---

Outside diff comments:
In `@tests/desktop/launchd-startup-scenarios.test.ts`:
- Around line 822-846: Two test cases reuse the same scenario numbers causing
confusion; rename the duplicate "Scenario 19" and "Scenario 20" test titles to
unique identifiers (e.g., "Scenario 21" and "Scenario 22") so each it(...)
description is distinct. Locate the it(...) calls with titles starting "Scenario
19: web server startup failure propagates as error" (the bootstrapWithLaunchd
test) and "Scenario 20: prod mode uses non-dev labels and plist dir" and update
their leading scenario numbers in the string descriptions to new unique numbers;
no other logic changes are needed.

---

Nitpick comments:
In `@tests/desktop/entitlements-plist.test.ts`:
- Around line 113-120: The test is unsafely casting JSON.parse(...) through
nested Record<string, unknown> types; replace the unchecked casts by parsing
into unknown and using a small runtime type-narrowing helper (e.g., an
assertIsObject or isRecord type guard) to validate and extract config, build,
and mac before accessing their fields in the "electron-builder config references
both entitlement files" test — locate the JSON.parse call and variables
packageJson, config, build, and mac and update the code to parse into unknown
then call the helper to narrow to the expected shaped objects (failing with a
clear error if build or mac are missing) instead of stacking casts.
- Around line 131-145: Replace the brittle substring checks in the "parent plist
is valid plist XML" and "inherit plist is valid plist XML" tests: parse
parentPlist and inheritPlist with a real plist/XML parser (e.g., plist.parse or
an XML parser) and assert the returned value is an object and contains the
expected root dictionary keys (i.e., top-level is a dictionary/object), rather
than just checking for string fragments; update the assertions in those two it()
blocks to validate the parsed structure (use parentPlist and inheritPlist as the
inputs to the parser and assert the parser result is truthy and of type object
and contains the expected dict keys).

In `@tests/desktop/plist-generator.test.ts`:
- Around line 225-234: The test is only asserting that
<key>SuccessfulExit</key><false/> appears anywhere in the plist, so update the
assertion to ensure SuccessfulExit=false lives inside the KeepAlive dictionary
produced by generatePlist("openclaw", mockEnv); specifically, replace the loose
SuccessfulExit assertion with one that matches the KeepAlive block containing
SuccessfulExit false (e.g. use a regex that looks for
<key>KeepAlive</key>\s*<dict>...<key>SuccessfulExit<\/key>\s*<false\/>...<\/dict>
or otherwise parse the plist and assert the KeepAlive object's SuccessfulExit is
false) so the test verifies the correct restart semantics.
🪄 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: f61b2d63-2f9e-4c62-b5c0-b7c470ed4fe6

📥 Commits

Reviewing files that changed from the base of the PR and between 10e1269 and 9bf6ba7.

📒 Files selected for processing (3)
  • tests/desktop/entitlements-plist.test.ts
  • tests/desktop/launchd-startup-scenarios.test.ts
  • tests/desktop/plist-generator.test.ts

Comment thread tests/desktop/launchd-startup-scenarios.test.ts
…EADDRINUSE

- Extract runTeardownAndExit() helper: dev-close, dev-before-quit,
  packaged-quit, packaged-no-window all share one code path with
  try/finally to guarantee app.exit(0) even if teardown throws
- Restore onForceQuit semantic: only fires on explicit "Quit Completely"
  user choice, not on dev-mode or no-window exits
- Add step-level console.warn for onBeforeQuit/webServer.close failures
- lsof: parse PID column by field position instead of substring matching
- Web port retry: only retry on EADDRINUSE, re-throw other errors immediately
@mrcfps mrcfps merged commit f37b8ea into main Mar 27, 2026
9 checks passed
@mrcfps mrcfps deleted the fix/desktop-lifecycle-robustness branch March 27, 2026 04:59
@lefarcen lefarcen mentioned this pull request Mar 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants