mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-24 07:43:28 +00:00
## 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
511 lines
16 KiB
TypeScript
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")
|
|
}
|
|
}
|