fix(desktop): robust lifecycle teardown for quit and update-install#563
fix(desktop): robust lifecycle teardown for quit and update-install#563
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.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds PID-aware launchd shutdown and a unified teardown API with orphan Nexu process cleanup; integrates these into quit and update flows, updates UpdateManager to use the launchd context, and expands tests, CI, and macOS launchd e2e scripts for lifecycle validation. Changes
Sequence DiagramsequenceDiagram
participant App as Desktop App
participant UpdateMgr as UpdateManager
participant Bootstrap as launchd-bootstrap
participant LaunchMgr as LaunchdManager
participant Nexu as Nexu Processes
participant AutoUpdater as electron-updater
App->>UpdateMgr: quitAndInstall()
rect rgba(100,200,150,0.5)
Note over UpdateMgr,Bootstrap: Phase 1 — best-effort teardown
UpdateMgr->>Bootstrap: teardownLaunchdServices(launchd, labels, plistDir)
Bootstrap->>LaunchMgr: bootoutAndWaitForExit(openclaw)
LaunchMgr-->>Bootstrap: result
Bootstrap->>LaunchMgr: bootoutAndWaitForExit(controller)
LaunchMgr-->>Bootstrap: result
Bootstrap->>Bootstrap: deleteRuntimePorts(plistDir)
Bootstrap->>Bootstrap: killOrphanNexuProcesses()
Bootstrap->>Nexu: SIGKILL (pgrep-driven)
Nexu-->>Bootstrap: terminate responses
Bootstrap-->>UpdateMgr: teardown complete (errors logged/ignored)
end
rect rgba(150,150,200,0.5)
Note over UpdateMgr,Bootstrap: Phase 2 — verification & install
UpdateMgr->>Bootstrap: ensureNexuProcessesDead()
Bootstrap->>Nexu: pgrep -> kill -> recheck (loop until timeout)
Bootstrap-->>UpdateMgr: {clean, remainingPids}
UpdateMgr->>App: set app.__nexuForceQuit = true
UpdateMgr->>AutoUpdater: quitAndInstall(false, true)
AutoUpdater-->>App: install & restart
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
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 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.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a947308b3b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/desktop/main/services/launchd-bootstrap.ts (1)
725-743: Consider resolvingpgreppath programmatically.The
pgrepcommand is invoked viaexecFileAsync("pgrep", ...)which relies on the system PATH. Per coding guidelines forapps/desktop/**/*.ts, shell commands should not rely on the user's PATH. Whilepgrepis typically at/usr/bin/pgrepon macOS, consider using an absolute path for robustness in packaged apps.🔧 Suggested path resolution
+const PGREP_PATH = "/usr/bin/pgrep"; + async function findNexuProcessPids(): Promise<number[]> { const allPids = new Set<number>(); for (const pattern of NEXU_PROCESS_PATTERNS) { try { - const { stdout } = await execFileAsync("pgrep", ["-f", pattern]); + const { stdout } = await execFileAsync(PGREP_PATH, ["-f", pattern]); for (const line of stdout.trim().split("\n")) {As per coding guidelines: "Desktop packaged app: never use
npx,npm,pnpm, or any shell command that relies on the user's PATH."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/main/services/launchd-bootstrap.ts` around lines 725 - 743, The function findNexuProcessPids currently calls execFileAsync("pgrep", ...) which relies on the user's PATH; update it to call pgrep via an absolute, validated path (e.g. define a PGREP_PATH constant like "/usr/bin/pgrep"), use that constant in the execFileAsync call instead of "pgrep", and validate existence/readability of PGREP_PATH (fs.access or similar) before calling; reference the function name findNexuProcessPids, the execFileAsync invocation, and NEXU_PROCESS_PATTERNS when making the change so the replacement is localized and robust for packaged desktop apps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/desktop/update-install.test.ts`:
- Around line 110-120: The tests leak mock implementations because
vi.clearAllMocks()/vi.restoreAllMocks() don't remove custom implementations on
vi.fn() mocks; in the test setup add a reset for the specific mock by calling
mockAutoUpdater.quitAndInstall.mockReset() (or mockClear if you prefer) inside
the beforeEach block so the custom mockImplementation assigned to
mockAutoUpdater.quitAndInstall (used around lines where quitAndInstall is
stubbed) is cleared before each test, ensuring isolation between test cases and
preventing persisted behavior across runs.
---
Nitpick comments:
In `@apps/desktop/main/services/launchd-bootstrap.ts`:
- Around line 725-743: The function findNexuProcessPids currently calls
execFileAsync("pgrep", ...) which relies on the user's PATH; update it to call
pgrep via an absolute, validated path (e.g. define a PGREP_PATH constant like
"/usr/bin/pgrep"), use that constant in the execFileAsync call instead of
"pgrep", and validate existence/readability of PGREP_PATH (fs.access or similar)
before calling; reference the function name findNexuProcessPids, the
execFileAsync invocation, and NEXU_PROCESS_PATTERNS when making the change so
the replacement is localized and robust for packaged desktop apps.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 837831c0-5280-4068-951d-5208c6159c4d
📒 Files selected for processing (11)
apps/desktop/main/index.tsapps/desktop/main/services/index.tsapps/desktop/main/services/launchd-bootstrap.tsapps/desktop/main/services/launchd-manager.tsapps/desktop/main/services/quit-handler.tsapps/desktop/main/updater/update-manager.tstests/desktop/launchd-bootstrap.test.tstests/desktop/launchd-manager-pid-aware.test.tstests/desktop/launchd-startup-scenarios.test.tstests/desktop/lifecycle-teardown.test.tstests/desktop/update-install.test.ts
- ci.yml: add a `test` job that runs `pnpm test` on ubuntu-latest, covering all 247+ vitest unit tests that were previously not run in CI. - desktop-ci-dist.yml: add real launchd lifecycle e2e test that runs on macOS CI runners before the packaged app build. The test exercises: 1. Bootstrap: register plist → kickstart → verify running + port 2. Teardown: bootout → verify label unregistered → verify process dead 3. SIGKILL fallback: bootout → saved-PID SIGKILL → verify dead 4. Orphan detection: spawn fake orphan → detect via lsof → SIGKILL 5. Re-bootstrap: fresh cold start after full cleanup - scripts/launchd-lifecycle-e2e.sh: standalone e2e test script (15 checks) that validates the launchd process management primitives used by the desktop app's quit and update-install paths.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
.github/workflows/desktop-ci-dist.yml (1)
4-37:⚠️ Potential issue | 🟠 MajorInclude
apps/controller/**in the trigger paths.Lines 83-88 now build
@nexu/controllerand run the launchd e2e against it, but controller-only PRs still won't match eitherpaths:block. That means the new gate can be skipped by the code it is meant to protect.🧩 Suggested fix
pull_request: paths: + - "apps/controller/**" - "apps/desktop/**" - "apps/web/**" @@ push: branches: - main paths: + - "apps/controller/**" - "apps/desktop/**" - "apps/web/**"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/desktop-ci-dist.yml around lines 4 - 37, Add the missing apps/controller/** pattern to both paths: blocks so controller-only changes will trigger this workflow; update the pull_request.paths and push.paths arrays (the two paths: blocks in the shown workflow) to include "apps/controller/**" alongside the existing apps/desktop/** and apps/web/** entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@scripts/launchd-lifecycle-e2e.sh`:
- Around line 357-387: The test currently verifies the orphan by lsof and the
retained ORPHAN_PID, which bypasses the actual pgrep matcher used in production;
replace the lsof check with an invocation of the same pgrep matcher the
production code uses (run the pgrep command/pattern used by the
killOrphanNexuProcesses logic) against the spawned process and assert that the
pgrep output contains ORPHAN_PID (using CONTROLLER_PORT or the same pattern
string as the matcher), so the test fails if the matcher cannot find the orphan;
remove or replace the lsof | grep check with this pgrep-based assertion and then
proceed to kill the PID as before.
- Line 37: The script currently defines OPENCLAW_ENTRY and unconditionally fails
if that artifact is missing, but this job only exercises the controller path
(per the "only exercises the controller path" comment); update the prereq logic
so the missing openclaw artifact does not cause an exit for controller-only
runs: either remove the unconditional existence check for OPENCLAW_ENTRY, or
wrap it in a runtime-specific guard (e.g., only test for the file when launching
the runtime component), by changing the check that references OPENCLAW_ENTRY to
first verify that the script intends to start the openclaw runtime instead of
controller-only.
- Around line 96-107: wait_for_exit currently sleeps 0.5s but increments elapsed
by 1, halving the intended timeout; fix this by switching to half-step integer
math: introduce e.g. elapsed_half and timeout_half=$((timeout * 2)), keep sleep
0.5, increment elapsed_half by 1 each loop and compare elapsed_half to
timeout_half, leaving function name wait_for_exit and parameters (pid, timeout)
unchanged so behavior matches the documented seconds.
---
Outside diff comments:
In @.github/workflows/desktop-ci-dist.yml:
- Around line 4-37: Add the missing apps/controller/** pattern to both paths:
blocks so controller-only changes will trigger this workflow; update the
pull_request.paths and push.paths arrays (the two paths: blocks in the shown
workflow) to include "apps/controller/**" alongside the existing apps/desktop/**
and apps/web/** entries.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c9d053d4-b8a8-43ed-b440-448f2db91c06
📒 Files selected for processing (3)
.github/workflows/ci.yml.github/workflows/desktop-ci-dist.ymlscripts/launchd-lifecycle-e2e.sh
Fixes: - dev-env.sh: add Launch Services cache flush (lsregister) after patching LSUIElement=true on the dev Electron binary. Without this, macOS uses cached plist data and still shows Dock icons for child processes. Add verification logging on success/failure. - daemon-supervisor: force ELECTRON_RUN_AS_NODE=1 on all spawn() calls that use process.execPath (Electron binary) as a safety net, even if the manifest env omits it. Tests: - daemon-supervisor.test.ts (10 tests): constructor, startAutoStart, stopUnit SIGTERM/SIGKILL escalation, 5s deadline, stopAll parallel, dispose, skip non-managed, ELECTRON_RUN_AS_NODE safety net, stoppedByUser suppresses restart, dependent stop order. - quit-handler.test.ts (8 tests): quit-completely full sequence, __nexuForceQuit flag, app.exit(0), error resilience for onBeforeQuit and webServer.close, no-plistDir skip, run-in-background hide. - desktop-stop-smoke.sh: post-stop verification script — checks no residual processes, free ports, no launchd labels, no stale state. Integrated into desktop-check-dev.sh for CI.
dev-launchd.sh (pnpm start) was launching Electron directly without going through dev-env.sh, bypassing the LSUIElement=true plist patch and Launch Services cache flush. This is the direct cause of the Dock icon proliferation reported by users running pnpm start. Now pnpm start → dev-launchd.sh → dev-env.sh → electron, matching the same path that pnpm dev uses via dev.sh → dev-run.sh → dev-env.sh.
Static analysis tests that guard critical invariants across the launch, environment, and shutdown scripts. These catch regressions like the dev-launchd.sh bypass of dev-env.sh that caused Dock icon proliferation. 19 invariant checks covering: - Launch paths: all Electron launch commands go through dev-env.sh - LSUIElement: plist patch + LS cache flush present - ELECTRON_RUN_AS_NODE: set in plists, manifests, daemon-supervisor, openclaw-process, and catalog-manager - Shutdown: bootout, orphan kill, port wait, teardown via shared function, try/catch wrapping, verification gate ordering
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (5)
scripts/desktop-stop-smoke.sh (1)
107-112: Anchorruntime-ports.jsoncheck to script location, not caller CWD.Line 107 uses a relative path; invoking this script outside repo root can produce false results.
Proposed change
-RUNTIME_PORTS=".tmp/launchd/runtime-ports.json" +RUNTIME_PORTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.tmp/launchd/runtime-ports.json" if [ ! -f "$RUNTIME_PORTS" ]; then pass "no stale runtime-ports.json" else🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/desktop-stop-smoke.sh` around lines 107 - 112, The check for RUNTIME_PORTS uses a relative path and must be anchored to the script's directory; compute the script base directory (e.g., using dirname/realpath of "$0") and build RUNTIME_PORTS from that base instead of the current working directory so the if [ ! -f "$RUNTIME_PORTS" ] check always targets .tmp/launchd/runtime-ports.json inside the repository; update the RUNTIME_PORTS variable assignment in scripts/desktop-stop-smoke.sh and ensure subsequent references use that anchored path.tests/desktop/quit-handler.test.ts (2)
87-104: The “full sequence” test name promises order, but order is not asserted.Line 87 describes strict sequencing, yet Lines 96-104 only assert that calls happened. Add explicit ordering assertions to catch regressions in teardown flow.
✅ Example assertion pattern
it("quit-completely calls onBeforeQuit → webServer.close → teardown → exit", async () => { + const calls: string[] = []; const opts = createQuitOpts(); + opts.onBeforeQuit = vi.fn(async () => { + calls.push("onBeforeQuit"); + }); + opts.webServer.close = vi.fn(async () => { + calls.push("webServer.close"); + }); + mockTeardown.mockImplementation(async () => { + calls.push("teardown"); + }); + mockApp.exit.mockImplementation(() => { + calls.push("exit"); + }); @@ expect(mockApp.exit).toHaveBeenCalledWith(0); + expect(calls).toEqual([ + "onBeforeQuit", + "webServer.close", + "teardown", + "exit", + ]); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/quit-handler.test.ts` around lines 87 - 104, Test "quit-completely calls onBeforeQuit → webServer.close → teardown → exit" claims a strict call order but only checks presence; modify the test around quitWithDecision to assert call ordering between opts.onBeforeQuit, opts.webServer.close, mockTeardown, and mockApp.exit (e.g. compare their mock invocationCallOrder or inspect mock.calls indices) so the test explicitly verifies that onBeforeQuit ran before webServer.close, which ran before mockTeardown, which ran before mockApp.exit.
61-70: Replace unsafeas nevercasts with properQuitHandlerOptionstyping.The
createQuitOptsfunction usesas nevercasts to suppress type errors instead of properly typing its return value. SinceQuitHandlerOptionsis exported from the quit-handler module, the function should be explicitly typed to returnQuitHandlerOptions, and test calls should pass the result without casts.♻️ Proposed refactor
-function createQuitOpts(overrides?: Record<string, unknown>) { +function createQuitOpts(overrides?: Partial<QuitHandlerOptions>): QuitHandlerOptions { return { - launchd: {} as never, + launchd: {} as QuitHandlerOptions["launchd"], labels: { controller: "io.nexu.controller", openclaw: "io.nexu.openclaw" }, plistDir: "/tmp/test-plist", webServer: { close: vi.fn().mockResolvedValue(undefined) }, onBeforeQuit: vi.fn().mockResolvedValue(undefined), onForceQuit: vi.fn(), ...overrides, - }; + } as QuitHandlerOptions; }- await quitWithDecision("quit-completely", opts as never); + await quitWithDecision("quit-completely", opts);Also applies to lines 94–95, 121–122, 135–136, 152–153, 173–174, 189–190, 205–206, 222–223.
Run
pnpm typecheckandpnpm lintafter making these changes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/quit-handler.test.ts` around lines 61 - 70, The createQuitOpts helper currently uses unsafe `as never` casts; change its signature to return the exported QuitHandlerOptions type (e.g. function createQuitOpts(overrides?: Partial<QuitHandlerOptions>): QuitHandlerOptions) and replace the casted fields (launchd, webServer, onBeforeQuit, onForceQuit, labels, plistDir, etc.) with properly typed mock values that satisfy QuitHandlerOptions (use Partial for overrides and merge into a fully typed object), then remove any call-site casts that relied on `as never` (the other createQuitOpts usages referenced in the comment). After updating createQuitOpts and all test usages, run pnpm typecheck and pnpm lint to confirm types and linting pass.tests/desktop/daemon-supervisor.test.ts (1)
309-326: Cover the two suffix fallbacks as well.This only exercises
command === process.execPath. The production guard also relies on/Electronand/electronsuffix matches, so those new heuristics can regress without any test failure.Suggested test shape
-it("forces ELECTRON_RUN_AS_NODE=1 when command is process.execPath", async () => { +it.each([ + process.execPath, + "/Applications/Electron.app/Contents/MacOS/Electron", + "/usr/local/bin/electron", +])("forces ELECTRON_RUN_AS_NODE=1 when command is %s", async (command) => { const { RuntimeOrchestrator } = await import( "../../apps/desktop/main/runtime/daemon-supervisor" ); const orchestrator = new RuntimeOrchestrator([ makeManagedManifest("controller", { - command: process.execPath, + command, // Deliberately omit ELECTRON_RUN_AS_NODE from manifest env env: { PORT: "50800" }, }), ] as never[]); await orchestrator.startAutoStartManagedUnits(); const spawnEnv = mockSpawn.mock.calls[0][2].env; expect(spawnEnv.ELECTRON_RUN_AS_NODE).toBe("1"); -}); +});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/daemon-supervisor.test.ts` around lines 309 - 326, The test only asserts the exact match case (command === process.execPath) but misses the two suffix-fallback heuristics that also should set ELECTRON_RUN_AS_NODE; update the test in daemon-supervisor.test.ts (the "forces ELECTRON_RUN_AS_NODE=1 when command is process.execPath" case) to include two additional scenarios where RuntimeOrchestrator is constructed with manifests whose command strings end with "/Electron" and "/electron" respectively (using makeManagedManifest to build each manifest), call startAutoStartManagedUnits for each, and assert mockSpawn.mock.calls[].env.ELECTRON_RUN_AS_NODE === "1" for those calls so all three heuristics (exact process.execPath, "/Electron" suffix, "/electron" suffix) are covered.apps/desktop/main/runtime/daemon-supervisor.ts (1)
1007-1019: Extend the Electron fallback toutility-processlaunches (defensive measure).Currently, no managed units use
runner: "utility-process", but this predicate checks onlymanifest.command, which is not consulted byutilityProcess.fork(). For future safety, extend the Electron detection to also cover the utility-process runner.Suggested change
const isElectronBinary = + manifest.runner === "utility-process" || manifest.command === process.execPath || manifest.command?.endsWith("/Electron") || manifest.command?.endsWith("/electron");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/main/runtime/daemon-supervisor.ts` around lines 1007 - 1019, The Electron-detection predicate (isElectronBinary) only checks manifest.command but misses cases where the unit uses the utility-process runner; update isElectronBinary to also return true when the manifest indicates runner: "utility-process" (e.g., manifest.runner === "utility-process" or presence of manifest.utilityProcess) so that the ELECTRON_RUN_AS_NODE fallback is applied to utilityProcess.fork launches as well; change the isElectronBinary expression in daemon-supervisor.ts to include this runner check while keeping the existing command-based checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/scripts/dev-env.sh`:
- Around line 42-45: The success log "[dev-env] patched Electron
LSUIElement=true + flushed LS cache" can be misleading because the lsregister
invocation uses "|| true" and always returns success; change the logic around
the lsregister call (the invocation referencing lsregister and the variable
electron_app) to capture its exit status and only echo the "flushed LS cache"
message when lsregister succeeds, otherwise emit an explicit failure log (and
preserve stderr redirection as needed) so the dev-env script accurately reports
whether the LS cache flush succeeded or failed.
In `@scripts/desktop-check-dev.sh`:
- Around line 26-34: The fixed 2s sleep in desktop-check-dev.sh causes teardown
races; replace it with a bounded polling loop that repeatedly checks for
shutdown completion (e.g., no remaining target processes or ports in use) before
invoking bash scripts/desktop-stop-smoke.sh, using a configurable timeout and
short sleep interval and exiting with a nonzero exit_code if the timeout is
reached; keep references to the existing variables exit_code and smoke_code so
you update exit_code on failure as the current logic does.
In `@scripts/desktop-stop-smoke.sh`:
- Around line 48-55: The process-sweep loop currently iterates patterns
["controller/dist/index.js", "openclaw.mjs gateway", "openclaw-gateway"] and
therefore misses the web sidecar and the native OpenClaw binary; update the
for-loop's pattern list (the patterns used by the variable pattern in that loop)
to also include the web-sidecar pattern(s) used by apps/desktop/dev.sh (e.g.,
the web sidecar entry point such as "web/dist/index.js" or "web-sidecar") and
the native OpenClaw binary name (e.g., plain "openclaw" or the platform-specific
native binary name), so pgrep -f will detect those residual processes and the
script will correctly fail when they remain.
In `@tests/desktop/quit-handler.test.ts`:
- Around line 78-82: Replace vi.clearAllMocks() with vi.resetAllMocks() in the
beforeEach block to fully reset mocks and implementations between tests; update
the beforeEach that references vi.clearAllMocks(), mockApp.__nexuForceQuit and
mockTeardown to call vi.resetAllMocks() so custom implementations like
mockApp.exit.mockImplementation() do not leak into later tests.
---
Nitpick comments:
In `@apps/desktop/main/runtime/daemon-supervisor.ts`:
- Around line 1007-1019: The Electron-detection predicate (isElectronBinary)
only checks manifest.command but misses cases where the unit uses the
utility-process runner; update isElectronBinary to also return true when the
manifest indicates runner: "utility-process" (e.g., manifest.runner ===
"utility-process" or presence of manifest.utilityProcess) so that the
ELECTRON_RUN_AS_NODE fallback is applied to utilityProcess.fork launches as
well; change the isElectronBinary expression in daemon-supervisor.ts to include
this runner check while keeping the existing command-based checks.
In `@scripts/desktop-stop-smoke.sh`:
- Around line 107-112: The check for RUNTIME_PORTS uses a relative path and must
be anchored to the script's directory; compute the script base directory (e.g.,
using dirname/realpath of "$0") and build RUNTIME_PORTS from that base instead
of the current working directory so the if [ ! -f "$RUNTIME_PORTS" ] check
always targets .tmp/launchd/runtime-ports.json inside the repository; update the
RUNTIME_PORTS variable assignment in scripts/desktop-stop-smoke.sh and ensure
subsequent references use that anchored path.
In `@tests/desktop/daemon-supervisor.test.ts`:
- Around line 309-326: The test only asserts the exact match case (command ===
process.execPath) but misses the two suffix-fallback heuristics that also should
set ELECTRON_RUN_AS_NODE; update the test in daemon-supervisor.test.ts (the
"forces ELECTRON_RUN_AS_NODE=1 when command is process.execPath" case) to
include two additional scenarios where RuntimeOrchestrator is constructed with
manifests whose command strings end with "/Electron" and "/electron"
respectively (using makeManagedManifest to build each manifest), call
startAutoStartManagedUnits for each, and assert
mockSpawn.mock.calls[].env.ELECTRON_RUN_AS_NODE === "1" for those calls so all
three heuristics (exact process.execPath, "/Electron" suffix, "/electron"
suffix) are covered.
In `@tests/desktop/quit-handler.test.ts`:
- Around line 87-104: Test "quit-completely calls onBeforeQuit → webServer.close
→ teardown → exit" claims a strict call order but only checks presence; modify
the test around quitWithDecision to assert call ordering between
opts.onBeforeQuit, opts.webServer.close, mockTeardown, and mockApp.exit (e.g.
compare their mock invocationCallOrder or inspect mock.calls indices) so the
test explicitly verifies that onBeforeQuit ran before webServer.close, which ran
before mockTeardown, which ran before mockApp.exit.
- Around line 61-70: The createQuitOpts helper currently uses unsafe `as never`
casts; change its signature to return the exported QuitHandlerOptions type (e.g.
function createQuitOpts(overrides?: Partial<QuitHandlerOptions>):
QuitHandlerOptions) and replace the casted fields (launchd, webServer,
onBeforeQuit, onForceQuit, labels, plistDir, etc.) with properly typed mock
values that satisfy QuitHandlerOptions (use Partial for overrides and merge into
a fully typed object), then remove any call-site casts that relied on `as never`
(the other createQuitOpts usages referenced in the comment). After updating
createQuitOpts and all test usages, run pnpm typecheck and pnpm lint to confirm
types and linting pass.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 60942df0-8f14-426e-8182-7d1df969cf15
📒 Files selected for processing (7)
.github/workflows/desktop-ci-dev.ymlapps/desktop/main/runtime/daemon-supervisor.tsapps/desktop/scripts/dev-env.shscripts/desktop-check-dev.shscripts/desktop-stop-smoke.shtests/desktop/daemon-supervisor.test.tstests/desktop/quit-handler.test.ts
✅ Files skipped from review due to trivial changes (1)
- .github/workflows/desktop-ci-dev.yml
… 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.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (5)
tests/desktop/embedded-web-server.test.ts (2)
33-35: Temp directory is not cleaned up after tests.The
webRoottemp directory created viamkdtempSyncis never removed, leaving orphan files on disk. Consider cleaning up inafterAll.🧹 Proposed fix to clean up temp directory
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";afterAll(async () => { await server?.close(); + if (webRoot) { + rmSync(webRoot, { recursive: true, force: true }); + } });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/embedded-web-server.test.ts` around lines 33 - 35, The test creates a temporary webRoot directory via mkdtempSync but never cleans it up; update the afterAll block (where server?.close() is called) to also remove the temporary directory (webRoot) when tests finish — e.g., check that webRoot is defined and then remove it recursively/forcefully using the fs API (fs.rmSync or fs.rmdirSync with recursive) to avoid orphaned files; reference the webRoot variable and the existing afterAll teardown to place the cleanup.
17-17: Fixed port may cause test flakiness in CI.If port 51777 is already in use by another process or parallel test run,
startEmbeddedWebServerwill fail. Consider using port 0 to let the OS assign an available port, then read the actual port from the server instance.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/embedded-web-server.test.ts` at line 17, The test uses a fixed TEST_PORT (const TEST_PORT = 51777) which can cause CI flakiness; change the test to pass port 0 to startEmbeddedWebServer so the OS picks an available port, then read the actual bound port from the returned server instance (or server.address().port) and use that dynamic port in subsequent requests/assertions; update any references to TEST_PORT and the startEmbeddedWebServer call to use 0 and replace usages with the server's actual port.tests/desktop/quit-handler-full.test.ts (1)
75-88: TightencreateQuitOptstyping to eliminateas nevercasts.The loose
Record<string, unknown>return type increateQuitOptsforces 14as nevercasts across the test file. SinceQuitHandlerOptionsis exported fromquit-handler.ts, type the helper or fixture to match it directly—either by importingQuitHandlerOptionsor usingParameters<typeof installLaunchdQuitHandler>[0]—so type mismatches fail at compile time.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/quit-handler-full.test.ts` around lines 75 - 88, The createQuitOpts helper currently returns a loose Record and uses `as never` casts; change its signature to return the concrete type used by the handler (e.g. import and use `QuitHandlerOptions` or use `Parameters<typeof installLaunchdQuitHandler>[0]`) and type the `overrides` parameter as `Partial<QuitHandlerOptions>` (or the equivalent Parameters-based type); update the function return type accordingly and remove all `as never` casts inside the fixture so TypeScript will catch mismatches at compile time while preserving the mock shapes (e.g. keep vi.fn() values but typed to match the corresponding properties).tests/desktop/launchd-manager-ops-extended.test.ts (1)
83-377: Consolidate overlapping suites to reduce maintenance drift.This file largely duplicates scenarios already covered in
tests/desktop/launchd-manager-ops.test.ts(Line 348-573) for the same five methods. Consider keeping one detailed suite and trimming the other to non-overlapping smoke coverage.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/launchd-manager-ops-extended.test.ts` around lines 83 - 377, This test file duplicates full coverage of the same five LaunchdManager behaviors (uninstallService, stopServiceGracefully, restartService, rebootstrapFromPlist, hasPlistFile); please consolidate by trimming this file to non-overlapping smoke tests only (or remove redundant it blocks) and keep one authoritative, detailed suite elsewhere: identify the describe blocks named "uninstallService", "stopServiceGracefully", "restartService", "rebootstrapFromPlist", and "hasPlistFile" in this file, remove or reduce duplicate it(...) cases that mirror the other suite, and retain only minimal assertions that exercise happy-path behavior (using existing setupExecFile and fs mocks) or unique edge cases not covered elsewhere so maintenance won’t drift.tests/desktop/update-manager-full.test.ts (1)
513-600: Add coverage before the first periodic timer fires.Both timer specs only interact after
initialDelayMshas elapsed. A regression that double-schedules the startup timeout on repeatedstartPeriodicCheck()calls, or forgets to cancel that startup timeout instopPeriodicCheck(), would still pass here.🧪 Suggested cases
describe("startPeriodicCheck", () => { + it("is a no-op when called twice before initialDelayMs elapses", async () => { + vi.useFakeTimers(); + mockAutoUpdater.checkForUpdates.mockResolvedValue({ + updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" }, + }); + + const { mgr } = await createManager(undefined, { + initialDelayMs: 1000, + checkIntervalMs: 5000, + }); + + mgr.startPeriodicCheck(); + mgr.startPeriodicCheck(); + + await vi.advanceTimersByTimeAsync(1000); + expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + }); + it("calling startPeriodicCheck twice after timer is set is a no-op", async () => { vi.useFakeTimers(); mockAutoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" }, }); @@ describe("stopPeriodicCheck", () => { + it("cancels the startup timeout before the first check", async () => { + vi.useFakeTimers(); + mockAutoUpdater.checkForUpdates.mockResolvedValue({ + updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" }, + }); + + const { mgr } = await createManager(undefined, { + initialDelayMs: 1000, + checkIntervalMs: 5000, + }); + + mgr.startPeriodicCheck(); + mgr.stopPeriodicCheck(); + + await vi.advanceTimersByTimeAsync(1000); + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + it("clears the interval and prevents further checks", async () => { vi.useFakeTimers(); mockAutoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "0.2.0", releaseDate: "2026-03-15" }, });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/update-manager-full.test.ts` around lines 513 - 600, Add tests in tests/desktop/update-manager-full.test.ts that assert behavior before the initialDelayMs fires: (1) calling mgr.startPeriodicCheck() twice before the initial delay should not schedule duplicate startup timeouts (advance timers by less than initialDelayMs then by initialDelayMs and verify checkForUpdates called only once, then advance by checkIntervalMs to verify only a single interval), and (2) mgr.stopPeriodicCheck() called after startPeriodicCheck but before the initialDelayMs should cancel the pending startup timeout (start, call stopPeriodicCheck(), advance past initialDelayMs and ensure mockAutoUpdater.checkForUpdates was never called). Reference the startPeriodicCheck and stopPeriodicCheck methods on mgr and the initialDelayMs/checkIntervalMs options when adding these specs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/desktop/launchd-bootstrap-edge.test.ts`:
- Around line 83-92: Tests mutate process.env/platform/execPath but do not call
vi.resetModules(), so dynamic imports of isLaunchdBootstrapEnabled may return a
cached module that used original environment; add a beforeEach that calls
vi.resetModules() (and optionally reassigns any mocked process properties) so
each test re-imports the module fresh; update the test suite around
isLaunchdBootstrapEnabled to include beforeEach(() => vi.resetModules()) so
changes to process.env, process.platform, or process.execPath are picked up by
the module under test.
- Around line 186-227: The tests call resolveLaunchdPaths via dynamic import but
don't reset the module cache, so memoized or module-initialized values can leak
between cases; before each dynamic import of
"../../apps/desktop/main/services/launchd-bootstrap" call vi.resetModules()
(e.g., at the top of each it block or in a beforeEach) so resolveLaunchdPaths is
re-imported fresh; ensure you add vi.resetModules() immediately prior to the
import that returns resolveLaunchdPaths.
- Around line 243-274: The mock implementation in setupPgrepSequence should
match Node's execFile callback signature: change the
mockExecFile.mockImplementation callback type to (cmd: string, _args: string[],
callback: (error: Error | null, stdout: string, stderr: string) => void) and
invoke callback with separate stdout and stderr args (e.g., callback(new
ErrorWithCode, "", "") or callback(null, pids.join("\n"), "")); keep the early
return for pgrep and for non-pgrep commands call callback(null, "", "") so
lsof/other branches also use the same (error, stdout, stderr) signature.
In `@tests/desktop/launchd-manager-ops-extended.test.ts`:
- Around line 77-81: Save the original process.platform before you overwrite it
in the existing beforeEach, then add an afterEach that restores it;
specifically, in the "LaunchdManager — extended operations" describe block
capture const originalPlatform = process.platform (or store it on a variable
scoped to the describe) before calling Object.defineProperty(process,
"platform", { value: "darwin" }) in beforeEach, and add an afterEach calling
Object.defineProperty(process, "platform", { value: originalPlatform }) to
restore the platform and prevent test bleed.
In `@tests/desktop/update-manager-full.test.ts`:
- Around line 112-126: The helper function createManager is forcing a channel
value by always merging { channel: "stable", feedUrl: null, ...options } into
the UpdateManager constructor, preventing tests from exercising the
constructor's real defaults; change createManager so it does not inject a
default channel (and avoid forcing feedUrl unless required) and instead pass
only the options provided (i.e., construct new UpdateManager(win, orchestrator,
options ?? {}) ), so tests that need explicit channels set them themselves and
other tests exercise UpdateManager's own defaults.
---
Nitpick comments:
In `@tests/desktop/embedded-web-server.test.ts`:
- Around line 33-35: The test creates a temporary webRoot directory via
mkdtempSync but never cleans it up; update the afterAll block (where
server?.close() is called) to also remove the temporary directory (webRoot) when
tests finish — e.g., check that webRoot is defined and then remove it
recursively/forcefully using the fs API (fs.rmSync or fs.rmdirSync with
recursive) to avoid orphaned files; reference the webRoot variable and the
existing afterAll teardown to place the cleanup.
- Line 17: The test uses a fixed TEST_PORT (const TEST_PORT = 51777) which can
cause CI flakiness; change the test to pass port 0 to startEmbeddedWebServer so
the OS picks an available port, then read the actual bound port from the
returned server instance (or server.address().port) and use that dynamic port in
subsequent requests/assertions; update any references to TEST_PORT and the
startEmbeddedWebServer call to use 0 and replace usages with the server's actual
port.
In `@tests/desktop/launchd-manager-ops-extended.test.ts`:
- Around line 83-377: This test file duplicates full coverage of the same five
LaunchdManager behaviors (uninstallService, stopServiceGracefully,
restartService, rebootstrapFromPlist, hasPlistFile); please consolidate by
trimming this file to non-overlapping smoke tests only (or remove redundant it
blocks) and keep one authoritative, detailed suite elsewhere: identify the
describe blocks named "uninstallService", "stopServiceGracefully",
"restartService", "rebootstrapFromPlist", and "hasPlistFile" in this file,
remove or reduce duplicate it(...) cases that mirror the other suite, and retain
only minimal assertions that exercise happy-path behavior (using existing
setupExecFile and fs mocks) or unique edge cases not covered elsewhere so
maintenance won’t drift.
In `@tests/desktop/quit-handler-full.test.ts`:
- Around line 75-88: The createQuitOpts helper currently returns a loose Record
and uses `as never` casts; change its signature to return the concrete type used
by the handler (e.g. import and use `QuitHandlerOptions` or use
`Parameters<typeof installLaunchdQuitHandler>[0]`) and type the `overrides`
parameter as `Partial<QuitHandlerOptions>` (or the equivalent Parameters-based
type); update the function return type accordingly and remove all `as never`
casts inside the fixture so TypeScript will catch mismatches at compile time
while preserving the mock shapes (e.g. keep vi.fn() values but typed to match
the corresponding properties).
In `@tests/desktop/update-manager-full.test.ts`:
- Around line 513-600: Add tests in tests/desktop/update-manager-full.test.ts
that assert behavior before the initialDelayMs fires: (1) calling
mgr.startPeriodicCheck() twice before the initial delay should not schedule
duplicate startup timeouts (advance timers by less than initialDelayMs then by
initialDelayMs and verify checkForUpdates called only once, then advance by
checkIntervalMs to verify only a single interval), and (2)
mgr.stopPeriodicCheck() called after startPeriodicCheck but before the
initialDelayMs should cancel the pending startup timeout (start, call
stopPeriodicCheck(), advance past initialDelayMs and ensure
mockAutoUpdater.checkForUpdates was never called). Reference the
startPeriodicCheck and stopPeriodicCheck methods on mgr and the
initialDelayMs/checkIntervalMs options when adding these specs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2966befa-6949-4a89-b297-34fcec1b01b9
📒 Files selected for processing (9)
tests/desktop/daemon-supervisor.test.tstests/desktop/embedded-web-server.test.tstests/desktop/launchd-bootstrap-edge.test.tstests/desktop/launchd-bootstrap.test.tstests/desktop/launchd-manager-ops-extended.test.tstests/desktop/launchd-manager-ops.test.tstests/desktop/plist-env-parity.test.tstests/desktop/quit-handler-full.test.tstests/desktop/update-manager-full.test.ts
✅ Files skipped from review due to trivial changes (2)
- tests/desktop/plist-env-parity.test.ts
- tests/desktop/daemon-supervisor.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/desktop/launchd-bootstrap.test.ts
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.
Relates to #566, #510
What
Comprehensive desktop lifecycle robustness overhaul: startup, shutdown, update-install, process management, and data directory correctness.
Why
Users reported:
pnpm startspawns 10+ Dock icons (LSUIElement not patched)bootstrap.tsoverwrites NEXU_HOME, causing fresh config on everypnpm startpnpm stopleaves tsc --watch printing to terminalHow
Bugs fixed (5)
dev-launchd.shbypasseddev-env.sh→ Dock icon proliferation. Now routes throughdev-env.shfor LSUIElement patch + LS cache flush.bootstrap.tsunconditionally overwroteNEXU_HOME→ data directory split. Now respects externally-set NEXU_HOME.quitAndInstalldidn't bootout launchd services → macOS installer blocked. Now usesteardownLaunchdServices+ensureNexuProcessesDeadverification gate.waitForExitlost PID after bootout → SIGKILL fallback failed. NewbootoutAndWaitForExitcaptures PID before bootout.stopPeriodicCheckrace condition → periodic checks fired during teardown. Now saves initial setTimeout ID.Architecture improvements (3)
gracefulShutdown(reason)— single authoritative teardown for before-quit, SIGTERM, SIGINT. Idempotent + 8s hard timeout.dev-launchd.sh stopnow sends SIGTERM first (triggers graceful shutdown), SIGKILL only as fallback.removeAllListeners("before-quit")→removeListener— prevents future listener collisions.Test suite (net +261 tests: 199 → 460)
launchctl bootstrap/bootout/kickstart, real processes53 real launchd tests run on macOS CI runners (not mocked).
46 garbage/duplicate tests deleted (grep-source-code, mock-plumbing, duplicates).
CI improvements
ci.yml: addedpnpm testjob (was missing)desktop-ci-dev.yml+desktop-ci-dist.yml: addedpnpm test(runs real launchd tests on macOS), comprehensive path filters coveringapps/controller/**,tests/desktop/**,scripts/dev-launchd.sh,vitest.config.ts@lefarcenrequired for test changesAffected areas
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (458 passed, 2 skipped)pnpm generate-typesrun — N/Aanytypes introducedNotes for reviewers
Key files to review:
apps/desktop/main/index.ts—gracefulShutdown()function + signal handlersapps/desktop/main/services/launchd-bootstrap.ts—teardownLaunchdServices,ensureNexuProcessesDeadapps/desktop/main/updater/update-manager.ts— 3-phasequitAndInstalltests/desktop/launchd-integration.test.ts— 36 real launchd tests