CodeNomad/packages/electron-app/electron/main/client-state-process.ts
Pascal André 0bab9e3438
fix(restore): simplify persistence and harden cross-platform cleanup (#602)
## Summary

- Follow up #578 by consolidating desktop persistence, restore
reconciliation, lifecycle coordination, and regression coverage.
- Preserve active drafts and attachments, request-scoped workspace
ownership, deletion tombstones, renderer authority, and bounded shutdown
behavior.
- Fix the reported macOS cleanup failure with targeted BSD process
queries and random-token-guarded process-group cleanup, without an
unverified PID fallback.

## Platform hardening

- Ignore development renderer origins in packaged Electron builds.
- Preserve staged Tauri navigation authority and handle confirmed
Windows session-end shutdown on the UI thread.
- Bound workspace launch preflight, runtime startup, and health
readiness.
- Retain cleanup ownership after unexpected leaders exit and verify
portable POSIX descendants by immutable identity or inherited launch
token.
- Add real Darwin-only process-group integration tests for macOS CI.

## Scope

- 96 files changed.
- 6,295 additions and 12,167 deletions, a net reduction of 5,872 lines
from the merged implementation.
- Consolidated duplicated tests while retaining focused race,
durability, cleanup, and platform contracts.

## Validation

- pm run typecheck
- pm run typecheck --workspace @neuralnomads/codenomad
- Electron native suite: 60 passed
- Tauri suite: 49 passed
- Focused server lifecycle/identity suite: 31 passed, 2 Darwin-only
skipped on Windows
- Focused UI restore/codec/reconciliation suite: 36 passed
- Broader server suite: 59 passed, 3 platform skips
- Broader UI suite: 97 passed, 1 skip; 2 Node 25 solid-toast loader
failures reproduced on the merged baseline
- git diff --check
- Final limited gatekeeper: PASS for server/macOS, UI restore, and
Electron/Tauri
2026-07-17 22:17:15 +01:00

511 lines
16 KiB
TypeScript

import { closeSync, fsyncSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"
import { basename, join } from "node:path"
import {
type ExpectedProcessLookup,
getProcessStartIdentity,
isExpectedTauriProcess,
type ProcessStartIdentityLookup,
} from "./client-state-process-identity"
const RUNNING_MARKER_PREFIX = "client-state.running."
const RUNNING_MARKER_SUFFIX = ".json"
const PRIMARY_LOCK_ACQUIRE_ATTEMPTS = 5
const LOCK_RETRY_DELAY_MS = 10
export const REGISTRATION_LOCK_WAIT_MS = 1_000
export interface ProcessOwner {
pid: number
runToken: string
processStartIdentity?: string
}
export type RunningMarkerStatus = "current" | "other-live" | "stale"
export interface ClientStateElectionPaths {
primaryLockPath: string
registrationLockPath: string
}
interface ProcessOwnerLockAcquisition {
acquired: boolean
liveOwner?: {
owner: ProcessOwner
observed: string
}
}
export function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code
}
function isTransientFileContentionError(error: unknown): boolean {
return hasErrorCode(error, "EPERM") || hasErrorCode(error, "EACCES") || hasErrorCode(error, "EBUSY")
}
export function parseProcessOwner(value: string): ProcessOwner | undefined {
try {
return normalizeProcessOwner(JSON.parse(value))
} catch {
// Incomplete process files are handled conservatively using their filename owner.
}
return undefined
}
function normalizeProcessOwner(candidate: unknown): ProcessOwner | undefined {
if (!candidate || typeof candidate !== "object") {
return undefined
}
const owner = candidate as Partial<ProcessOwner>
if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && typeof owner.runToken === "string" && owner.runToken) {
return {
pid: Number(owner.pid),
runToken: owner.runToken,
...(typeof owner.processStartIdentity === "string" && owner.processStartIdentity
? { processStartIdentity: owner.processStartIdentity }
: {}),
}
}
return undefined
}
function parseAcknowledgedPrimary(value: string): ProcessOwner | undefined {
try {
const candidate = JSON.parse(value) as { primaryOwner?: unknown }
return normalizeProcessOwner(candidate.primaryOwner)
} catch {
return undefined
}
}
export function isSameProcessOwner(left: ProcessOwner, right: ProcessOwner): boolean {
return left.pid === right.pid && left.runToken === right.runToken
}
export function isPidAlive(pid: number): boolean {
if (pid === process.pid) {
return true
}
try {
process.kill(pid, 0)
return true
} catch (error) {
return !hasErrorCode(error, "ESRCH")
}
}
export function hasLiveTauriClient(
tauriDataPath: string,
pidAlive: (pid: number) => boolean = isPidAlive,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
expectedProcess: ExpectedProcessLookup = isExpectedTauriProcess,
upgradedParticipants: readonly ProcessOwner[] = [],
): boolean {
let entries: string[]
try {
entries = readdirSync(tauriDataPath)
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false
throw error
}
return entries.some((name) => {
const match = /^client-state\.running\.(\d+)\..+\.lock$/.exec(name)
if (!match) return false
const pid = Number(match[1])
if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid)) return false
const liveIdentity = processStartIdentity(pid)
if (liveIdentity && upgradedParticipants.some((owner) => owner.pid === pid && owner.processStartIdentity === liveIdentity)) return false
return expectedProcess(pid) !== false
})
}
export function classifyRunningMarker(
markerOwner: ProcessOwner,
currentOwner: ProcessOwner,
pidAlive: (pid: number) => boolean = isPidAlive,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): RunningMarkerStatus {
if (isSameProcessOwner(markerOwner, currentOwner)) {
return "current"
}
// Two live processes cannot share a PID. A different token therefore belongs to an old run.
if (markerOwner.pid === currentOwner.pid) {
return "stale"
}
if (!pidAlive(markerOwner.pid)) return "stale"
if (markerOwner.processStartIdentity) {
const liveIdentity = processStartIdentity(markerOwner.pid)
if (liveIdentity && liveIdentity !== markerOwner.processStartIdentity) return "stale"
}
return "other-live"
}
export function getRunningMarkerPath(userDataPath: string, owner: ProcessOwner): string {
return join(userDataPath, `${RUNNING_MARKER_PREFIX}${owner.pid}.${owner.runToken}${RUNNING_MARKER_SUFFIX}`)
}
function parseRunningMarkerFilename(filename: string): ProcessOwner | undefined {
if (!filename.startsWith(RUNNING_MARKER_PREFIX) || !filename.endsWith(RUNNING_MARKER_SUFFIX)) {
return undefined
}
const value = filename.slice(RUNNING_MARKER_PREFIX.length, -RUNNING_MARKER_SUFFIX.length)
const separator = value.indexOf(".")
if (separator < 1) {
return undefined
}
const pid = Number(value.slice(0, separator))
const runToken = value.slice(separator + 1)
if (!Number.isInteger(pid) || pid <= 0 || !runToken) {
return undefined
}
return { pid, runToken }
}
export function createRunningMarker(
userDataPath: string,
owner: ProcessOwner,
primaryOwner?: ProcessOwner,
): string {
const markerPath = getRunningMarkerPath(userDataPath, owner)
publishProcessFile(markerPath, JSON.stringify(primaryOwner ? { ...owner, primaryOwner } : owner))
return markerPath
}
function publishProcessFile(path: string, contents: string): void {
let descriptor: number | undefined
try {
descriptor = openSync(path, "wx", 0o600)
writeFileSync(descriptor, contents, "utf8")
try {
fsyncSync(descriptor)
} catch (error) {
if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error
}
closeSync(descriptor)
descriptor = undefined
} catch (error) {
if (descriptor !== undefined) {
try { closeSync(descriptor) } catch {}
try { unlinkSync(path) } catch {}
}
throw error
}
}
function removeFileIfUnchanged(path: string, observed: string): boolean {
try {
if (readFileSync(path, "utf8") !== observed) {
return false
}
unlinkSync(path)
return true
} catch (error) {
if (hasErrorCode(error, "ENOENT")) {
return false
}
throw error
}
}
function readFileIfExists(path: string): string | undefined {
try {
return readFileSync(path, "utf8")
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return undefined
throw error
}
}
function waitForLockRetry(delayMs = LOCK_RETRY_DELAY_MS) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, delayMs))
}
function removeContendedFile(path: string, observed: string): void {
try {
removeFileIfUnchanged(path, observed)
} catch (error) {
if (!isTransientFileContentionError(error)) throw error
waitForLockRetry()
}
}
function acquireProcessOwnerLockWithStatus(
path: string,
owner: ProcessOwner,
waitForLiveOwner: boolean,
pidAlive: (pid: number) => boolean = isPidAlive,
liveOwnerWaitMs = REGISTRATION_LOCK_WAIT_MS,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): ProcessOwnerLockAcquisition {
const serializedOwner = JSON.stringify(owner)
const waitDeadline = Date.now() + Math.max(0, liveOwnerWaitMs)
let liveOwner: ProcessOwnerLockAcquisition["liveOwner"]
for (let attempt = 0; ; attempt += 1) {
if (
(!waitForLiveOwner && attempt >= PRIMARY_LOCK_ACQUIRE_ATTEMPTS) ||
(waitForLiveOwner && attempt > 0 && Date.now() >= waitDeadline)
) {
return { acquired: false, liveOwner }
}
try {
publishProcessFile(path, serializedOwner)
return { acquired: true }
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) {
throw error
}
}
const observed = readFileIfExists(path)
if (observed === undefined) continue
const existingOwner = parseProcessOwner(observed)
if (existingOwner) {
const status = classifyRunningMarker(existingOwner, owner, pidAlive, processStartIdentity)
if (status === "other-live") {
liveOwner = { owner: existingOwner, observed }
if (!waitForLiveOwner) {
return { acquired: false, liveOwner }
}
const remainingWaitMs = waitDeadline - Date.now()
if (remainingWaitMs <= 0) {
return { acquired: false, liveOwner }
}
waitForLockRetry(Math.min(LOCK_RETRY_DELAY_MS, remainingWaitMs))
continue
}
} else if (waitForLiveOwner && attempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS - 1) {
// The owner may still be writing a newly-created lock file.
waitForLockRetry()
continue
}
removeContendedFile(path, observed)
}
return { acquired: false, liveOwner }
}
export function removeProcessOwnerLockIfOwned(path: string, owner: ProcessOwner): boolean {
const observed = readFileIfExists(path)
const current = observed === undefined ? undefined : parseProcessOwner(observed)
return Boolean(current && isSameProcessOwner(current, owner) && removeFileIfUnchanged(path, observed!))
}
function releaseProcessOwnerLock(
path: string,
owner: ProcessOwner,
onWarning: (message: string, error: unknown) => void,
warning: string,
): void {
try {
removeProcessOwnerLockIfOwned(path, owner)
} catch (error) {
onWarning(warning, error)
}
}
export function isProcessOwnerLockOwned(path: string, owner: ProcessOwner): boolean {
const value = readFileIfExists(path)
const current = value === undefined ? undefined : parseProcessOwner(value)
return Boolean(current && isSameProcessOwner(current, owner))
}
export function removeRunningMarkerIfOwned(markerPath: string, owner: ProcessOwner): boolean {
const filenameOwner = parseRunningMarkerFilename(basename(markerPath))
if (!filenameOwner || !isSameProcessOwner(filenameOwner, owner)) {
return false
}
const observed = readFileIfExists(markerPath)
const storedOwner = observed === undefined ? undefined : parseProcessOwner(observed)
return Boolean(storedOwner && isSameProcessOwner(storedOwner, owner) && removeFileIfUnchanged(markerPath, observed!))
}
export function cleanStaleRunningMarkers(
userDataPath: string,
currentOwner: ProcessOwner,
pidAlive: (pid: number) => boolean = isPidAlive,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): boolean {
let hasOtherLiveProcess = false
for (const filename of readdirSync(userDataPath)) {
const filenameOwner = parseRunningMarkerFilename(filename)
if (!filenameOwner) {
continue
}
const markerPath = join(userDataPath, filename)
const observed = readFileIfExists(markerPath)
if (observed === undefined) continue
const storedOwner = parseProcessOwner(observed)
if (storedOwner && !isSameProcessOwner(storedOwner, filenameOwner)) {
const storedStatus = classifyRunningMarker(storedOwner, currentOwner, pidAlive, processStartIdentity)
const filenameStatus = classifyRunningMarker(filenameOwner, currentOwner, pidAlive, processStartIdentity)
if (storedStatus === "other-live" || filenameStatus === "other-live") {
hasOtherLiveProcess = true
} else {
removeFileIfUnchanged(markerPath, observed)
}
continue
}
const markerOwner = storedOwner ?? filenameOwner
const status = classifyRunningMarker(markerOwner, currentOwner, pidAlive, processStartIdentity)
if (status === "other-live") {
const acknowledgedPrimary = parseAcknowledgedPrimary(observed)
if (!acknowledgedPrimary || !isSameProcessOwner(acknowledgedPrimary, currentOwner)) {
hasOtherLiveProcess = true
}
} else if (status === "stale") {
removeFileIfUnchanged(markerPath, observed)
}
}
return hasOtherLiveProcess
}
function hasMatchingLiveRunningMarker(
userDataPath: string,
owner: ProcessOwner,
pidAlive: (pid: number) => boolean,
processStartIdentity: ProcessStartIdentityLookup,
): boolean {
if (!pidAlive(owner.pid)) {
return false
}
const value = readFileIfExists(getRunningMarkerPath(userDataPath, owner))
const markerOwner = value === undefined ? undefined : parseProcessOwner(value)
if (!markerOwner || !isSameProcessOwner(markerOwner, owner)) return false
const liveIdentity = markerOwner.processStartIdentity && processStartIdentity(markerOwner.pid)
return !liveIdentity || liveIdentity === markerOwner.processStartIdentity
}
function acquireMarkerBackedProcessOwnerLock(
userDataPath: string,
path: string,
owner: ProcessOwner,
pidAlive: (pid: number) => boolean,
liveOwnerWaitMs: number,
processStartIdentity: ProcessStartIdentityLookup,
): ProcessOwnerLockAcquisition {
let lastAcquisition: ProcessOwnerLockAcquisition = { acquired: false }
for (let recoveryAttempt = 0; recoveryAttempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS; recoveryAttempt += 1) {
const acquisition = acquireProcessOwnerLockWithStatus(
path,
owner,
true,
pidAlive,
liveOwnerWaitMs,
processStartIdentity,
)
lastAcquisition = acquisition
if (acquisition.acquired || !acquisition.liveOwner) {
if (acquisition.acquired) return acquisition
continue
}
// An identity-backed owner only reaches this point after an exact identity match
// or an inconclusive lookup. Never steal its lock while its PID remains live.
if (acquisition.liveOwner.owner.processStartIdentity) {
return acquisition
}
if (hasMatchingLiveRunningMarker(
userDataPath,
acquisition.liveOwner.owner,
pidAlive,
processStartIdentity,
)) {
return acquisition
}
removeContendedFile(path, acquisition.liveOwner.observed)
}
return lastAcquisition
}
export function electClientStateProcess(
userDataPath: string,
owner: ProcessOwner,
paths: ClientStateElectionPaths,
onWarning: (message: string, error: unknown) => void = () => {},
pidAlive: (pid: number) => boolean = isPidAlive,
registrationLockWaitMs = REGISTRATION_LOCK_WAIT_MS,
onPrimaryLockAcquired: () => void = () => {},
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): boolean {
let registrationAcquired = false
let registeringOwner: ProcessOwner | undefined
try {
const registration = acquireMarkerBackedProcessOwnerLock(
userDataPath,
paths.registrationLockPath,
owner,
pidAlive,
registrationLockWaitMs,
processStartIdentity,
)
registrationAcquired = registration.acquired
registeringOwner = registration.liveOwner?.owner
} catch (error) {
onWarning("failed to acquire registration lock", error)
}
if (!registrationAcquired) {
try {
createRunningMarker(userDataPath, owner, registeringOwner)
} catch (error) {
onWarning("failed to create running marker", error)
}
return false
}
try {
let isPrimary = false
let acknowledgedPrimary: ProcessOwner | undefined
try {
const acquisition = acquireMarkerBackedProcessOwnerLock(
userDataPath,
paths.primaryLockPath,
owner,
pidAlive,
registrationLockWaitMs,
processStartIdentity,
)
isPrimary = acquisition.acquired
acknowledgedPrimary = acquisition.liveOwner?.owner
} catch (error) {
onWarning("failed to acquire primary lock", error)
}
if (isPrimary) {
try {
onPrimaryLockAcquired()
if (cleanStaleRunningMarkers(userDataPath, owner, pidAlive, processStartIdentity)) {
removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner)
isPrimary = false
}
} catch (error) {
onWarning("failed to inspect running markers", error)
releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock")
isPrimary = false
}
}
try {
createRunningMarker(userDataPath, owner, acknowledgedPrimary)
} catch (error) {
onWarning("failed to create running marker", error)
if (isPrimary) releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock")
return false
}
return isPrimary
} finally {
releaseProcessOwnerLock(paths.registrationLockPath, owner, onWarning, "failed to release registration lock")
}
}