Skip to content

fix(host-service): reap orphaned host-service before spawn (single-writer) - #4997

Closed
Kitenite wants to merge 1 commit into
mainfrom
fix/host-service-single-writer
Closed

Kitenite wants to merge 1 commit into
mainfrom
fix/host-service-single-writer

Conversation

@Kitenite

@Kitenite Kitenite commented May 29, 2026 •

Copy link
Copy Markdown
Member

Problem

The coordinator only tracks host-services it spawned in this Electron process (its in-memory instances map). After a restart that map is empty, so a host-service left alive by a previous process is invisible — and spawn() never consults the manifest before starting a new one. It just binds a competitor on a fresh port, giving two processes connected to the same org host.db. That concurrent-writer contention is the root trigger behind the migration lock-up (see #4996, which hardens the migration side).

How an orphan arises:

  • Electron died before before-quit ran (hard crash / SIGKILL / OOM) — child never SIGTERMed, parent watchdog hadn't fired yet;
  • a CLI-spawned host-service — no HOST_PARENT_PID, so no watchdog at all;
  • a wedged child from a failed spawn that ignored SIGTERM.

(reset() already reaps via the manifest, but it's only reachable manually via tRPC — never on the normal start path.)

Fix

Reap based on the manifest (the only cross-process record) before spawning, in spawn():

  • Identity-validated kill (PID-reuse safe): only terminate a pid we can positively confirm is our host-service — its endpoint answers health.check with the manifest secret, or (best-effort, POSIX) its command line matches our host-service.js. A live-but-unrecognized pid is left alone and the stale manifest dropped, so a recycled pid can't cause friendly-fire.
  • Escalation: SIGTERM → await exit → SIGKILL → await.

Why safe to kill: host-service is stateless w.r.t. terminals — PTYs live in the detached pty-daemon, which a fresh host-service re-adopts. Reaping an orphan doesn't touch user sessions.

Structure

  • host-service-reap.ts — dependency-injected reap logic (electron-free, so it unit-tests under bun test without spawning real processes).
  • host-service-coordinator.ts — wires real deps + the health/argv identity probe; calls reap at the top of spawn().
  • host-service-utils.ts — adds healthCheckOnce (single-shot identity probe; pollHealthCheck now reuses it).
  • host-service-reap.test.ts — 6 cases: no-manifest, own-live-instance, dead-pid-cleared, unidentified-left-alone (PID reuse), SIGTERM-exit, SIGTERM→SIGKILL escalation.

Desktop typecheck + lint clean; reap tests 6/6.

Relationship to #4996

Independent and complementary. #4996 makes the DB migration survive contention (busy_timeout + fail-closed); this PR removes the source of the contention. Either can merge first.


Open in Stage

Summary by cubic

Prevents two host-service processes from writing to the same org DB by reaping any orphaned host-service before spawn. Uses manifest-driven identity checks to safely terminate previous instances and ensure a single writer, resolving migration contention.

  • Bug Fixes
    • Reap any previous host-service for the org at the start of spawn() using the manifest.
    • Identity-validated kill: health.check with the manifest secret, with a POSIX argv fallback; unrecognized live PIDs are left alone and the stale manifest is removed.
    • Escalation flow: SIGTERM with grace, then SIGKILL if needed; clears the manifest after successful reaping or when the PID is dead.
    • Added healthCheckOnce and updated pollHealthCheck to reuse it.
    • Introduced dependency-injected host-service-reap.ts with unit tests covering no-manifest, own-live, dead-pid, PID-reuse, and SIGTERM→SIGKILL paths.

Written for commit 19143e5. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Improvements

    • Enhanced host-service process lifecycle management with proactive cleanup of previous instances to prevent conflicts.
  • Tests

    • Added comprehensive test coverage for host-service process cleanup logic, including edge cases for process termination and manifest handling.

Review Change Stack

…iter)

A host-service left running by a previous process (Electron hard-crash before before-quit, a CLI-spawned one with no watchdog, or a wedged child from a failed spawn) keeps a connection to the org's host.db. The coordinator only tracks instances it spawned this process, so on the next launch it spawns a competitor on a fresh port — two writers on one host.db, the contention behind the migration lock-up.

Reap based on the manifest (the only cross-process record) before spawning: confirm identity first (health.check with the manifest secret, or a best-effort POSIX argv match for a wedged child) so PID reuse can't cause friendly-fire — an unrecognized live pid is left alone and the stale manifest dropped — then SIGTERM, await exit, escalate to SIGKILL.

- host-service-reap.ts: dependency-injected reap logic (electron-free, unit-testable).

- coordinator: wire real deps + identity probe, call at the top of spawn().

- host-service-utils: add healthCheckOnce (single-shot identity probe; pollHealthCheck now reuses it).

- host-service-reap.test.ts: 6 cases incl. PID-reuse-left-alone and SIGTERM->SIGKILL escalation.
@coderabbitai

coderabbitai Bot commented May 29, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR introduces a proactive host-service process reaping system to clean up stale processes before spawning a new one per organization. It extracts and reuses health-check logic, defines a structured reap module with signal-escalation and grace-period handling, and integrates reaping into the coordinator's spawn flow with dual identity-confirmation strategies (health endpoint + POSIX fallback).

Changes

Host-service reaping feature

Layer / File(s) Summary
Health check utilities
apps/desktop/src/main/lib/host-service-utils.ts
New healthCheckOnce performs a single authenticated health probe with per-request abort timeout; pollHealthCheck refactored to delegate per-iteration timeouts to healthCheckOnce, removing inline AbortController/fetch boilerplate.
Reap module: types and constants
apps/desktop/src/main/lib/host-service-reap.ts
Exports ReapReason union and ReapResult shape alongside ReapDeps dependency-injection interface and REAP_SIGTERM_GRACE_MS/REAP_SIGKILL_GRACE_MS constants; module docstring explains reaping purpose and PID-reuse safety.
Reap core implementation
apps/desktop/src/main/lib/host-service-reap.ts
reapPreviousHostService reads manifest, exits early for missing/own/dead PIDs, confirms identity before killing, sends SIGTERM with race handling, polls up to grace period, escalates to SIGKILL, removes manifest, and returns structured outcome; waitForPidExit helper polls isProcessAlive until deadline.
Reap test suite
apps/desktop/src/main/lib/host-service-reap.test.ts
Bun tests cover all outcomes: no manifest, own-live-instance, dead-pid-cleared, unidentified-left-alone, SIGTERM termination, SIGKILL escalation; includes manifest and ReapDeps mock helpers.
Coordinator integration
apps/desktop/src/main/lib/host-service-coordinator.ts
Imports HostServiceManifest type; wires reapPreviousHostService before port selection in spawn(); adds confirmOurHostService to verify PID identity via health check or fallback; adds processLooksLikeHostService POSIX-only helper using ps output.

Sequence Diagram

sequenceDiagram
  participant Coordinator as HostServiceCoordinator
  participant Reaper as reapPreviousHostService
  participant Manifest as HostServiceManifest (disk)
  participant HealthAPI as Health Endpoint
  participant Process as OS Process (PID)

  Coordinator->>Reaper: spawn() → reapPreviousHostService(orgId)
  Reaper->>Manifest: readManifest(orgId)
  alt No manifest
    Reaper-->>Coordinator: ReapResult{reason: 'no-manifest'}
  else Own live instance
    Reaper-->>Coordinator: ReapResult{reason: 'own-live-instance'}
  else Dead PID
    Reaper->>Manifest: removeManifest(orgId)
    Reaper-->>Coordinator: ReapResult{reason: 'dead-pid-cleared'}
  else Unconfirmed live PID
    Reaper->>HealthAPI: healthCheckOnce(endpoint, secret)
    HealthAPI-->>Reaper: false/error
    Reaper->>Manifest: removeManifest(orgId)
    Reaper-->>Coordinator: ReapResult{reason: 'unidentified-left-alone'}
  else Confirmed PID needs reap
    Reaper->>HealthAPI: healthCheckOnce(endpoint, secret)
    HealthAPI-->>Reaper: true
    Reaper->>Process: kill(pid, SIGTERM)
    Reaper->>Process: waitForPidExit(pid, REAP_SIGTERM_GRACE_MS)
    alt Process exited
      Reaper->>Manifest: removeManifest(orgId)
      Reaper-->>Coordinator: ReapResult{reason: 'terminated-sigterm'}
    else Process still alive
      Reaper->>Process: kill(pid, SIGKILL)
      Reaper->>Process: waitForPidExit(pid, REAP_SIGKILL_GRACE_MS)
      Reaper->>Manifest: removeManifest(orgId)
      Reaper-->>Coordinator: ReapResult{reason: 'terminated-sigkill'}
    end
  end
  Coordinator->>Coordinator: select/bind ports
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • superset-sh/superset#4648: Modifies host-service stale process cleanup flow with health-verified kill/remove behavior for stale adoption.
  • superset-sh/superset#4499: Modifies coordinator host-service reuse/adoption logic to rely on manifest health checking rather than immediate termination on version mismatches.
  • superset-sh/superset#4739: Changes how existing/previous host-service PIDs/manifests are handled around spawn by dropping adoption flow.

Poem

🐰 A stray process lingers in the morn,
So reap it clean before the new is born!
SIGTERM first, then SIGKILL's might—
Health-check confirms, PID set right. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: adding reap logic before spawn to prevent orphaned host-service processes.
Description check ✅ Passed The PR description fully follows the template with complete Problem, Fix, and Structure sections, includes Related Issues reference (#4996, #4997), Type of Change (Bug fix), and Testing details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/host-service-single-writer

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

❤️ Share

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

@ghost

ghost commented May 29, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 3 individual chapters for you:

Title
1 Extract health check probe to utility
2 Implement core host-service reaping logic
3 Integrate reaping into the coordinator
Open in Stage

Chapters generated by Stage for commit 19143e5 on May 29, 2026 6:23pm UTC.

@capy-ai

capy-ai Bot commented May 29, 2026

Copy link
Copy Markdown

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the "two writers on the same host.db" root cause by reaping any orphaned host-service before starting a new one. It introduces host-service-reap.ts with dependency-injected kill logic, wires it into spawn() in the coordinator, and extracts healthCheckOnce from pollHealthCheck for use as a single-shot identity probe.

  • host-service-reap.ts: Pure reap module covering the full decision tree — no manifest, own live pid, dead pid, unidentified-alive (PID-reuse safe), SIGTERM, SIGTERM→SIGKILL escalation — tested with six unit tests.
  • host-service-coordinator.ts: spawn() now calls reapPreviousHostService() first; identity is confirmed via the manifest secret with a POSIX ps fallback. The fallback uses synchronous execFileSync, which can hold the main-process event loop for up to 1,500 ms.
  • host-service-reap.test.ts: The SIGKILL-escalation test mocks sleep as a no-op but waitForPidExit still uses real Date.now() for its deadline, causing that test to busy-spin for the full 4-second SIGTERM grace period of real wall-clock time.

Confidence Score: 4/5

The reap logic is structurally sound and PID-reuse-safe; two non-blocking issues exist that do not affect production correctness.

The reap decision tree covers all important cases correctly, identity verification via the manifest secret is a strong guard against friendly-fire, and the SIGTERM to SIGKILL escalation is well-tested. The synchronous execFileSync is a fallback path that only activates when an orphan health endpoint is unresponsive and ps completes in milliseconds in practice. The SIGKILL-escalation test functionally verifies the right behavior but spends ~4 real seconds doing so due to the un-injected grace-period constant.

The execFileSync call in processLooksLikeHostService and the SIGKILL test timing in host-service-reap.test.ts are worth revisiting.

Important Files Changed

Filename Overview
apps/desktop/src/main/lib/host-service-reap.ts New reap module with dependency-injected logic; kill flow (SIGTERM → wait → SIGKILL → wait) is correct and PID-reuse-safe, but grace-period timeouts are hard-coded constants not injected through ReapDeps.
apps/desktop/src/main/lib/host-service-coordinator.ts Wires reap logic into spawn(); confirmOurHostService uses healthCheckOnce then falls back to processLooksLikeHostService, which calls execFileSync synchronously and can block the Electron main process event loop for up to 1,500 ms.
apps/desktop/src/main/lib/host-service-reap.test.ts Six test cases cover all branches; sleep is mocked as a no-op but waitForPidExit's deadline uses real Date.now(), so the SIGKILL-escalation test spin-waits for the full REAP_SIGTERM_GRACE_MS (4 s) of real wall-clock time.
apps/desktop/src/main/lib/host-service-utils.ts Correctly extracts healthCheckOnce from pollHealthCheck; no logic change to the existing poll loop, clean refactor.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Coordinator as HostServiceCoordinator
    participant Reap as reapPreviousHostService
    participant Manifest as host-service-manifest
    participant ChildProc as orphan process

    Caller->>Coordinator: start(orgId, config)
    Coordinator->>Coordinator: spawn(orgId, config)
    Coordinator->>Reap: reapPreviousHostService(orgId, deps)
    Reap->>Manifest: readManifest(orgId)
    alt no manifest
        Manifest-->>Reap: null
        Reap-->>Coordinator: reason: no-manifest
    else pid is own live instance
        Reap-->>Coordinator: reason: own-live-instance
    else pid is dead
        Reap->>Manifest: removeManifest(orgId)
        Reap-->>Coordinator: reason: dead-pid-cleared
    else pid is alive
        Reap->>Coordinator: confirmOurHostService(manifest)
        Coordinator->>ChildProc: healthCheckOnce(endpoint, authToken)
        alt health check passes
            ChildProc-->>Coordinator: 2xx OK
            Coordinator-->>Reap: true
        else health check fails
            Coordinator->>Coordinator: processLooksLikeHostService [ps, POSIX]
            Coordinator-->>Reap: true / false
        end
        alt not our host-service
            Reap->>Manifest: removeManifest(orgId)
            Reap-->>Coordinator: reason: unidentified-left-alone
        else confirmed ours
            Reap->>ChildProc: SIGTERM
            Reap->>Reap: waitForPidExit(4000ms)
            alt exits on SIGTERM
                Reap->>Manifest: removeManifest(orgId)
                Reap-->>Coordinator: reason: terminated-sigterm
            else survives SIGTERM
                Reap->>ChildProc: SIGKILL
                Reap->>Reap: waitForPidExit(2000ms)
                Reap->>Manifest: removeManifest(orgId)
                Reap-->>Coordinator: reason: terminated-sigkill
            end
        end
    end
    Coordinator->>Coordinator: findFreePort() then spawn new child
    Coordinator-->>Caller: Connection
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/main/lib/host-service-reap.test.ts:60-103
**SIGKILL-escalation test takes ~4 s of real wall-clock time**

`sleep` is mocked as a no-op, but `waitForPidExit` uses `Date.now()` for its deadline — it doesn't rely on `sleep` to advance time. In the SIGKILL test `isProcessAlive` returns `true` on every iteration until SIGKILL fires, so the loop busy-spins against real wall time for the full `REAP_SIGTERM_GRACE_MS = 4_000 ms` before the deadline expires. The "No real waiting in tests" comment in `makeDeps` is therefore inaccurate for this case.

The clean fix is to add injectable timeout overrides to `ReapDeps` (e.g. `termGraceMs` and `killGraceMs`) and default them to the exported constants, then pass `0` in the two escalation tests. Alternatively, make `waitForPidExit` itself injectable in `ReapDeps`.

### Issue 2 of 2
apps/desktop/src/main/lib/host-service-coordinator.ts:587-599
**Synchronous `execFileSync` may block the Electron main-process event loop for up to 1,500 ms**

`processLooksLikeHostService` is reached only after `healthCheckOnce` already timed out (1,500 ms). The subsequent `execFileSync("ps", …, { timeout: 1_500 })` is fully synchronous and holds the Node.js event loop for up to another 1,500 ms. In practice `ps` finishes in single-digit milliseconds, but in a pathological environment (e.g. high load, container with limited CPU shares) this can make the Electron UI transiently unresponsive.

Replacing `execFileSync` with promisified `execFile` and `await`-ing the result inside the already-`async` `confirmOurHostService` caller would eliminate the synchronous block without changing observable behavior.

Reviews (1): Last reviewed commit: "fix(host-service): reap orphaned host-se..." | Re-trigger Greptile

Comment on lines +60 to +103
killProcess,
});
const result = await reapPreviousHostService(ORG, deps);
expect(result.reason).toBe("unidentified-left-alone");
expect(killProcess).not.toHaveBeenCalled();
expect(deps.removeManifest).toHaveBeenCalledTimes(1);
});

test("alive + ours + exits on SIGTERM → SIGTERM only, no SIGKILL", async () => {
const signals: NodeJS.Signals[] = [];
// Alive until SIGTERM is observed, then gone.
let sigtermed = false;
const deps = makeDeps({
killProcess: mock((_pid: number, sig: NodeJS.Signals) => {
signals.push(sig);
if (sig === "SIGTERM") sigtermed = true;
}),
isProcessAlive: mock(() => !sigtermed),
});
const result = await reapPreviousHostService(ORG, deps);
expect(result.reaped).toBe(true);
expect(result.reason).toBe("terminated-sigterm");
expect(signals).toEqual(["SIGTERM"]);
expect(deps.removeManifest).toHaveBeenCalledTimes(1);
});

test("alive + ours + ignores SIGTERM → escalates to SIGKILL", async () => {
const signals: NodeJS.Signals[] = [];
let sigkilled = false;
const deps = makeDeps({
killProcess: mock((_pid: number, sig: NodeJS.Signals) => {
signals.push(sig);
if (sig === "SIGKILL") sigkilled = true;
}),
// Survives SIGTERM (always alive until SIGKILL lands).
isProcessAlive: mock(() => !sigkilled),
});
const result = await reapPreviousHostService(ORG, deps);
expect(result.reaped).toBe(true);
expect(result.reason).toBe("terminated-sigkill");
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
expect(deps.removeManifest).toHaveBeenCalledTimes(1);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 SIGKILL-escalation test takes ~4 s of real wall-clock time

sleep is mocked as a no-op, but waitForPidExit uses Date.now() for its deadline — it doesn't rely on sleep to advance time. In the SIGKILL test isProcessAlive returns true on every iteration until SIGKILL fires, so the loop busy-spins against real wall time for the full REAP_SIGTERM_GRACE_MS = 4_000 ms before the deadline expires. The "No real waiting in tests" comment in makeDeps is therefore inaccurate for this case.

The clean fix is to add injectable timeout overrides to ReapDeps (e.g. termGraceMs and killGraceMs) and default them to the exported constants, then pass 0 in the two escalation tests. Alternatively, make waitForPidExit itself injectable in ReapDeps.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/host-service-reap.test.ts
Line: 60-103

Comment:
**SIGKILL-escalation test takes ~4 s of real wall-clock time**

`sleep` is mocked as a no-op, but `waitForPidExit` uses `Date.now()` for its deadline — it doesn't rely on `sleep` to advance time. In the SIGKILL test `isProcessAlive` returns `true` on every iteration until SIGKILL fires, so the loop busy-spins against real wall time for the full `REAP_SIGTERM_GRACE_MS = 4_000 ms` before the deadline expires. The "No real waiting in tests" comment in `makeDeps` is therefore inaccurate for this case.

The clean fix is to add injectable timeout overrides to `ReapDeps` (e.g. `termGraceMs` and `killGraceMs`) and default them to the exported constants, then pass `0` in the two escalation tests. Alternatively, make `waitForPidExit` itself injectable in `ReapDeps`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +587 to +599
function processLooksLikeHostService(pid: number, scriptPath: string): boolean {
if (process.platform === "win32") return false;
try {
const command = childProcess
.execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
timeout: 1_500,
})
.trim();
return command.includes(path.basename(scriptPath));
} catch {
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Synchronous execFileSync may block the Electron main-process event loop for up to 1,500 ms

processLooksLikeHostService is reached only after healthCheckOnce already timed out (1,500 ms). The subsequent execFileSync("ps", …, { timeout: 1_500 }) is fully synchronous and holds the Node.js event loop for up to another 1,500 ms. In practice ps finishes in single-digit milliseconds, but in a pathological environment (e.g. high load, container with limited CPU shares) this can make the Electron UI transiently unresponsive.

Replacing execFileSync with promisified execFile and await-ing the result inside the already-async confirmOurHostService caller would eliminate the synchronous block without changing observable behavior.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/host-service-coordinator.ts
Line: 587-599

Comment:
**Synchronous `execFileSync` may block the Electron main-process event loop for up to 1,500 ms**

`processLooksLikeHostService` is reached only after `healthCheckOnce` already timed out (1,500 ms). The subsequent `execFileSync("ps", …, { timeout: 1_500 })` is fully synchronous and holds the Node.js event loop for up to another 1,500 ms. In practice `ps` finishes in single-digit milliseconds, but in a pathological environment (e.g. high load, container with limited CPU shares) this can make the Electron UI transiently unresponsive.

Replacing `execFileSync` with promisified `execFile` and `await`-ing the result inside the already-`async` `confirmOurHostService` caller would eliminate the synchronous block without changing observable behavior.

How can I resolve this? If you propose a fix, please make it concise.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/main/lib/host-service-coordinator.ts`:
- Around line 590-596: Replace the raw substring check with a token-aware match:
call childProcess.execFileSync(...) to read the process command/args for pid (as
currently done), then test whether any argv token's basename equals
path.basename(scriptPath) (or whether any token ends with path.sep +
path.basename(scriptPath)) instead of using command.includes(...). In other
words, split/parse the command into arguments (or use a regex that matches a
path tail or token boundary, e.g. (?:^|[\/\s])<basename>(?:$|\s)) and only
return true when a full token/path tail matches scriptPath's basename to avoid
matching substrings like "not-host-service.js". Use the existing symbols
childProcess.execFileSync, pid, scriptPath, and path.basename to locate and
implement the change.

In `@apps/desktop/src/main/lib/host-service-reap.ts`:
- Around line 130-139: The code removes the manifest and returns reaped: true
even if waitForPidExit(pid, REAP_SIGKILL_GRACE_MS, deps.isProcessAlive, sleep)
returns false; change the logic in the function containing that call so you only
call deps.removeManifest(organizationId) and return { reaped: true, ... } when
waitForPidExit returns true, and if it returns false keep the manifest and
return a failure result (e.g. { reaped: false, reason: "failed-to-terminate",
pid }) so HostServiceCoordinator.spawn() can bail out on "failed-to-terminate";
locate and update the block around waitForPidExit, deps.removeManifest, and the
returned object to implement this conditional behavior.
🪄 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: 2e98fd39-c0fa-427a-8611-de2839e94601

📥 Commits

Reviewing files that changed from the base of the PR and between 7f3e5b3 and 19143e5.

📒 Files selected for processing (4)
  • apps/desktop/src/main/lib/host-service-coordinator.ts
  • apps/desktop/src/main/lib/host-service-reap.test.ts
  • apps/desktop/src/main/lib/host-service-reap.ts
  • apps/desktop/src/main/lib/host-service-utils.ts

Comment on lines +590 to +596
const command = childProcess
.execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
timeout: 1_500,
})
.trim();
return command.includes(path.basename(scriptPath));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the argv fallback token-aware instead of using includes().

A recycled PID whose command line contains host-service.js as a substring — for example not-host-service.js or some unrelated arg value — will be treated as ours and killed. That breaks the PID-reuse safety guarantee this fallback is supposed to preserve. Match a basename token / path tail, not a raw substring.

Possible tightening
 		const command = childProcess
 			.execFileSync("ps", ["-p", String(pid), "-o", "command="], {
 				encoding: "utf8",
 				timeout: 1_500,
 			})
 			.trim();
-		return command.includes(path.basename(scriptPath));
+		const scriptName = path
+			.basename(scriptPath)
+			.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+		return new RegExp(`(?:^|[\\s/])${scriptName}(?:$|\\s)`).test(command);
 	} catch {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/lib/host-service-coordinator.ts` around lines 590 -
596, Replace the raw substring check with a token-aware match: call
childProcess.execFileSync(...) to read the process command/args for pid (as
currently done), then test whether any argv token's basename equals
path.basename(scriptPath) (or whether any token ends with path.sep +
path.basename(scriptPath)) instead of using command.includes(...). In other
words, split/parse the command into arguments (or use a regex that matches a
path tail or token boundary, e.g. (?:^|[\/\s])<basename>(?:$|\s)) and only
return true when a full token/path tail matches scriptPath's basename to avoid
matching substrings like "not-host-service.js". Use the existing symbols
childProcess.execFileSync, pid, scriptPath, and path.basename to locate and
implement the change.

Comment on lines +130 to +139
await waitForPidExit(
pid,
REAP_SIGKILL_GRACE_MS,
deps.isProcessAlive,
sleep,
);
}

deps.removeManifest(organizationId);
return { reaped: true, reason, pid };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't report success until the old PID is actually gone.

If waitForPidExit() is still false after the SIGKILL grace period, this code still removes the manifest and returns reaped: true. That lets spawn() continue while the previous writer may still hold host.db, which reintroduces the lockup this PR is meant to prevent. Keep the manifest and surface a failure result when the PID survives SIGKILL.

Suggested direction
 export type ReapReason =
 	| "no-manifest"
 	| "own-live-instance"
 	| "dead-pid-cleared"
 	| "unidentified-left-alone"
 	| "terminated-sigterm"
-	| "terminated-sigkill";
+	| "terminated-sigkill"
+	| "failed-to-terminate";
@@
-		await waitForPidExit(
+		const exitedAfterSigkill = await waitForPidExit(
 			pid,
 			REAP_SIGKILL_GRACE_MS,
 			deps.isProcessAlive,
 			sleep,
 		);
+		if (!exitedAfterSigkill) {
+			deps.log?.warn(
+				`[host-service:${organizationId}] pid=${pid} survived SIGKILL; refusing to spawn a second writer`,
+			);
+			return { reaped: false, reason: "failed-to-terminate", pid };
+		}
 	}
 
 	deps.removeManifest(organizationId);
 	return { reaped: true, reason, pid };

HostServiceCoordinator.spawn() should then bail out on "failed-to-terminate" instead of proceeding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/lib/host-service-reap.ts` around lines 130 - 139, The
code removes the manifest and returns reaped: true even if waitForPidExit(pid,
REAP_SIGKILL_GRACE_MS, deps.isProcessAlive, sleep) returns false; change the
logic in the function containing that call so you only call
deps.removeManifest(organizationId) and return { reaped: true, ... } when
waitForPidExit returns true, and if it returns false keep the manifest and
return a failure result (e.g. { reaped: false, reason: "failed-to-terminate",
pid }) so HostServiceCoordinator.spawn() can bail out on "failed-to-terminate";
locate and update the block around waitForPidExit, deps.removeManifest, and the
returned object to implement this conditional behavior.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/main/lib/host-service-coordinator.ts">

<violation number="1" location="apps/desktop/src/main/lib/host-service-coordinator.ts:591">
P2: `execFileSync` blocks the Electron main-process event loop for up to 1.5 s. Since the caller (`confirmOurHostService`) is already `async`, replace with the promisified `execFile` + `await` to avoid UI jank under pathological load.</violation>

<violation number="2" location="apps/desktop/src/main/lib/host-service-coordinator.ts:596">
P1: Process identity check too loose. Basename substring can match unrelated commands, then reap can kill wrong PID. Use full script path (or exact argv token) for confirmation.</violation>
</file>

<file name="apps/desktop/src/main/lib/host-service-reap.ts">

<violation number="1" location="apps/desktop/src/main/lib/host-service-reap.ts:110">
P1: SIGTERM catch marks reap as success for every error. Non-race kill failures can leave the old process alive and still drop the manifest. Check liveness in catch, only treat real exit as success, otherwise rethrow.</violation>

<violation number="2" location="apps/desktop/src/main/lib/host-service-reap.ts:130">
P0: SIGKILL path ignores exit result. Code always clears manifest and returns reaped=true even if pid still alive. Require confirmed exit before success path.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Coordinator as HostServiceCoordinator
    participant Reap as reapPreviousHostService
    participant Manifest as Manifest Store (Disk)
    participant OldHS as Previous HostService
    participant NewHS as New HostService
    participant Health as Health Check Client

    Note over Coordinator,Health: SPAWN FLOW WITH REAP

    Coordinator->>Coordinator: spawn(organizationId)

    Coordinator->>Reap: reapPreviousHostService(orgId, deps)

    Reap->>Manifest: readManifest(orgId)
    Manifest-->>Reap: HostServiceManifest {pid, endpoint, authToken}

    alt no manifest
        Reap-->>Coordinator: {reason: "no-manifest", reaped: false}
    else manifest exists
        Reap->>Coordinator: isOwnLivePid(pid)
        alt PID tracked and live in this process
            Coordinator-->>Reap: true
            Reap-->>Coordinator: {reason: "own-live-instance", reaped: false}
        else PID not ours
            Coordinator-->>Reap: false
            Reap->>Reap: isProcessAlive(pid)
            alt PID dead
                Reap->>Manifest: removeManifest(orgId)
                Reap-->>Coordinator: {reason: "dead-pid-cleared", reaped: false}
            else PID alive
                Reap->>Reap: confirmOurHostService(manifest)
                Reap->>Health: healthCheckOnce(endpoint, authToken)
                Health-->>Reap: true if endpoint answers with secret
                alt unrecognized (PID reuse)
                    Reap->>Reap: processLooksLikeHostService(pid, scriptPath)
                    Note over Reap: POSIX fallback via ps command
                    alt recognized
                        Reap->>OldHS: killProcess(pid, SIGTERM)
                        Reap->>Reap: waitForPidExit(pid, 4s)
                        alt exited
                            Manifest-->>Reap: removeManifest(orgId)
                            Reap-->>Coordinator: {reason: "terminated-sigterm", reaped: true}
                        else still alive
                            Reap->>OldHS: killProcess(pid, SIGKILL)
                            Reap->>Reap: waitForPidExit(pid, 2s)
                            Reap->>Manifest: removeManifest(orgId)
                            Reap-->>Coordinator: {reason: "terminated-sigkill", reaped: true}
                        end
                    else not recognized
                        Reap->>Manifest: removeManifest(orgId)
                        Reap-->>Coordinator: {reason: "unidentified-left-alone", reaped: false}
                    end
                end
            end
        end
    end

    Coordinator->>Coordinator: [after reap complete] findFreePort()
    Coordinator->>NewHS: spawn child process
    NewHS->>Health: start health.check endpoint
    Coordinator->>Health: pollHealthCheck(endpoint, secret)
    Health-->>Coordinator: ready
    Coordinator->>Coordinator: register instance in instances map
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

} catch {
// Raced to exit during the grace window.
}
await waitForPidExit(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0: SIGKILL path ignores exit result. Code always clears manifest and returns reaped=true even if pid still alive. Require confirmed exit before success path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/host-service-reap.ts, line 130:

<comment>SIGKILL path ignores exit result. Code always clears manifest and returns reaped=true even if pid still alive. Require confirmed exit before success path.</comment>

<file context>
@@ -0,0 +1,140 @@
+		} catch {
+			// Raced to exit during the grace window.
+		}
+		await waitForPidExit(
+			pid,
+			REAP_SIGKILL_GRACE_MS,
</file context>

timeout: 1_500,
})
.trim();
return command.includes(path.basename(scriptPath));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Process identity check too loose. Basename substring can match unrelated commands, then reap can kill wrong PID. Use full script path (or exact argv token) for confirmation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/host-service-coordinator.ts, line 596:

<comment>Process identity check too loose. Basename substring can match unrelated commands, then reap can kill wrong PID. Use full script path (or exact argv token) for confirmation.</comment>

<file context>
@@ -533,6 +578,27 @@ function pipeWithPrefix(
+				timeout: 1_500,
+			})
+			.trim();
+		return command.includes(path.basename(scriptPath));
+	} catch {
+		return false;
</file context>

let reason: ReapReason = "terminated-sigterm";
try {
deps.killProcess(pid, "SIGTERM");
} catch {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: SIGTERM catch marks reap as success for every error. Non-race kill failures can leave the old process alive and still drop the manifest. Check liveness in catch, only treat real exit as success, otherwise rethrow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/host-service-reap.ts, line 110:

<comment>SIGTERM catch marks reap as success for every error. Non-race kill failures can leave the old process alive and still drop the manifest. Check liveness in catch, only treat real exit as success, otherwise rethrow.</comment>

<file context>
@@ -0,0 +1,140 @@
+	let reason: ReapReason = "terminated-sigterm";
+	try {
+		deps.killProcess(pid, "SIGTERM");
+	} catch {
+		// Exited between the liveness check and the signal — nothing to do.
+		deps.removeManifest(organizationId);
</file context>

if (process.platform === "win32") return false;
try {
const command = childProcess
.execFileSync("ps", ["-p", String(pid), "-o", "command="], {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: execFileSync blocks the Electron main-process event loop for up to 1.5 s. Since the caller (confirmOurHostService) is already async, replace with the promisified execFile + await to avoid UI jank under pathological load.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/host-service-coordinator.ts, line 591:

<comment>`execFileSync` blocks the Electron main-process event loop for up to 1.5 s. Since the caller (`confirmOurHostService`) is already `async`, replace with the promisified `execFile` + `await` to avoid UI jank under pathological load.</comment>

<file context>
@@ -533,6 +578,27 @@ function pipeWithPrefix(
+	if (process.platform === "win32") return false;
+	try {
+		const command = childProcess
+			.execFileSync("ps", ["-p", String(pid), "-o", "command="], {
+				encoding: "utf8",
+				timeout: 1_500,
</file context>

@Kitenite Kitenite left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Automated review (high effort) of the reap / single-writer change. Solid and well-factored — the dependency injection makes the core logic genuinely unit-testable. One finding worth addressing before merge (the ps-fallback friendly-fire); the rest are minor.

Non-blocking notes:

  • The isOwnLivePid guard (coordinator ~357, reap.ts) is effectively unreachable on the spawn() path: this.instances.set runs after reap, so the org has no tracked instance at reap time. Harmless and fine as defense-in-depth, but currently dead.
  • pollHealthCheck's refactor preserves the prior per-attempt-2s / overall-deadline behavior exactly — no regression there.

// Fallback for a wedged child whose endpoint no longer responds: match
// the pid's command line to our host-service script. POSIX only — on
// win32 we fall back to health-only and leave unrecognized pids alone.
return processLooksLikeHostService(manifest.pid, this.scriptPath);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The health probe (line 373) is precise — it uses the manifest secret, so it confirms this specific host-service. This ps fallback is not: it matches any process whose command line contains host-service.js, and the org is passed via env (ORGANIZATION_ID, ~line 500), not argv — so ps -o command can't tell orgs apart.

Friendly-fire scenario: org A's manifest pid is dead; the OS reuses that pid for org B's host-service; the health probe to A's stale endpoint fails → this fallback matches host-service.js → we SIGTERM/SIGKILL org B's healthy host-service. That undercuts the PID-reuse safety the health probe is supposed to give.

Options: drop the argv fallback and rely on the health probe alone (a wedged-ours child that never answers health is rare, and reset() still covers it), or make the fallback org-specific (hard, since org isn't in argv). Minor, same line: execFileSync is synchronous, so this blocks the Electron main event loop for up to the 1.5s ps timeout whenever the health probe fails.

} catch {
// Exited between the liveness check and the signal — nothing to do.
deps.removeManifest(organizationId);
return { reaped: true, reason: "terminated-sigterm", pid };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Returning reaped: true / "terminated-sigterm" here is slightly misleading: killProcess threw, which (per the comment above) means the process had already exited on its own — we didn't terminate it. Harmless since the manifest is removed either way, but a distinct reason like "already-exited" would keep the result/telemetry honest.

expect(deps.removeManifest).toHaveBeenCalledTimes(1);
});

test("alive + ours + ignores SIGTERM → escalates to SIGKILL", async () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two branches stay uncovered: (a) the SIGTERM-throws path in reap.ts (killProcess is never mocked to throw) — a case asserting it still returns reaped:true + removes the manifest would pin it; and (b) the real confirmOurHostService identity logic is injected as a fake, so the ps/health fallback (and the friendly-fire risk flagged in the coordinator) has no test at all. Worth a focused test of the real identity probe.

Kitenite added a commit that referenced this pull request Jun 2, 2026
The fail-closed behavior was the swallowing try/catch all along. Delete it
instead of rewriting it: migrate() now throws naturally to serve.ts
main().catch -> exit(1). Drops the re-throw wrapper and sqlite.close() (the OS
reclaims the fd on exit). Keep the one genuine addition, busy_timeout = 8000,
and document that it applies for the connection lifetime.

Export MIGRATION_BUSY_TIMEOUT_MS and import it in the contention test, deleting
the hand-mirrored copy so the budget can't drift.

Plan reframed removal-first: drops the redundant retry loop (busy_timeout
already waits) and notes the structural fix (#4997) lets busy_timeout and the
widened health window be removed later.
@Kitenite Kitenite closed this Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant