fix(desktop): harden startup/shutdown lifecycle + fix V8 JIT white-screen#597
fix(desktop): harden startup/shutdown lifecycle + fix V8 JIT white-screen#597
Conversation
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
Deploying nexu-docs with
|
| 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 |
📝 WalkthroughWalkthroughAsync 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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟡 MinorAssert the async teardown side effects here.
Right now this test still passes if the handler only calls
preventDefault(). Please flush the async branch and assertmockTeardownplusmockApp.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+mvpair 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 promisifiedexecFile.The callback signature uses separate
stdoutandstderrparameters, bututil.promisify(execFile)expects a callback that receives a single result object{ stdout, stderr }. Changecb(null, "", "")tocb(null, { stdout: "", stderr: "" })to match the contract the production code expects when it awaitsexecFileAsync.🤖 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
📒 Files selected for processing (19)
apps/desktop/main/index.tsapps/desktop/main/runtime/daemon-supervisor.tsapps/desktop/main/runtime/manifests.tsapps/desktop/main/services/index.tsapps/desktop/main/services/launchd-bootstrap.tsapps/desktop/main/services/launchd-manager.tsapps/desktop/main/services/plist-generator.tsapps/desktop/main/services/quit-handler.tsapps/desktop/main/updater/update-manager.tsapps/desktop/shared/host.tstests/desktop/daemon-supervisor-restart.test.tstests/desktop/launchd-bootstrap-edge.test.tstests/desktop/launchd-bootstrap-lifecycle.test.tstests/desktop/launchd-bootstrap.test.tstests/desktop/launchd-manager-bootout.test.tstests/desktop/launchd-startup-scenarios.test.tstests/desktop/lifecycle-teardown.test.tstests/desktop/quit-handler-full.test.tstests/desktop/update-install.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
There was a problem hiding this comment.
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 | 🟡 MinorDuplicate 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-teardownbefore-quitbranches with a no-dialog assertion.Both tests still pass if the handler briefly routes through
showMessageBoxbefore 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 inbeforeEach.If this suite is only relying on
vi.clearAllMocks(),mockWindow.isVisible.mockReturnValue(false)from the packagedbefore-quitcase will leak into later tests becauseclearAllMocksdoes not restore implementations. Re-seeding the defaulttruereturn 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
📒 Files selected for processing (5)
tests/desktop/launchd-bootstrap-edge.test.tstests/desktop/launchd-startup-scenarios.test.tstests/desktop/lifecycle-teardown.test.tstests/desktop/quit-handler-full.test.tstests/desktop/update-install.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/desktop/lifecycle-teardown.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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/desktop/launchd-bootstrap-edge.test.ts (1)
30-39: Default mock callback signature inconsistent with promisify semantics.The default
mockExecFileinvokes the callback with three argumentscb(null, "", ""), but later overrides correctly use the object formcallback?.(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
execFilein 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
📒 Files selected for processing (5)
apps/desktop/build/entitlements.mac.inherit.plisttests/desktop/data-directory-runtime.test.tstests/desktop/entitlements-plist.test.tstests/desktop/launchd-bootstrap-edge.test.tstests/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
There was a problem hiding this comment.
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 | 🟡 MinorDuplicate 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 parsedpackage.jsoninstead of stacking unchecked casts.
JSON.parse()data is being asserted straight through to nestedRecord<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 ifbuild.macdisappears.♻️ 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
anytype. Useunknownwith narrowing orz.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 theKeepAlivewrapper, not justSuccessfulExit.This passes as long as
SuccessfulExit=falseappears somewhere in the plist. It won't catchSuccessfulExitbeing emitted outside theKeepAlivedictionary, 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
📒 Files selected for processing (3)
tests/desktop/entitlements-plist.test.tstests/desktop/launchd-startup-scenarios.test.tstests/desktop/plist-generator.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
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
entitlements.mac.inherit.plistdroppedallow-jit, causing V8MAP_JITmmap failure → renderer OOM → white screen on macOS 14.7.4+How
V8 JIT White-Screen Fix
allow-jitandallow-unsigned-executable-memoryinentitlements.mac.inherit.plistalongsideinheritExternal Node Runner (core fix for "app in use")
~/.nexu/runtime/nexu-runner.app/~/.nexu/runtime/controller-sidecar/Evidence-Based Update Install
lsof +Dcheck on critical paths (.app bundle, runner, sidecars)Version-Aware Attach
runtime-ports.jsonnow storesappVersion,userDataPath,buildSource,openclawStateDirUnified Teardown
app.quit()all runteardownLaunchdServicesOther Fixes
daemon-supervisor: circuit breaker (MAX_CONSECUTIVE_RESTARTS=10, 120s window)bootoutService: tolerates "already gone" launchctl errorsruntime-ports.json: atomic write via tmp + renamenode.*prefix as fallbackreadBundleExecutableName(): dynamic from Info.plistassertSafeRmTarget(): refuses rm -rf on paths < 3 segmentsAffected areas
apps/desktop/build/entitlements.mac.inherit.plistapps/desktop/main/services/launchd-bootstrap.ts(core)apps/desktop/main/services/quit-handler.tsapps/desktop/main/updater/update-manager.tsapps/desktop/main/runtime/daemon-supervisor.tsapps/desktop/main/runtime/manifests.tsapps/desktop/main/services/launchd-manager.tsChecklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (50 files, 550+ tests)ELECTRON_RUN_AS_NODE=1pnpm start→ close →pnpm start(no stale services)🤖 Generated with Claude Code