Files
hermes-agent/apps/desktop/electron/singleton-lock.ts
hermes-seaeye[bot] 44fc5f629d fmt(js): npm run fix on merge (#129471)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-30 17:36:24 +00:00

133 lines
3.8 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
/**
* Parse the PID encoded in Electron's Linux SingletonLock symlink.
*
* Chromium's ProcessSingleton on Linux/X11 owns `<userData>/SingletonLock`, a
* symlink whose target is `<hostname>-<pid>` (#78101). The hostname prefix is
* the guard against removing a lock owned from a different machine on a
* shared home directory: a target that does not start with this host's
* name is not ours to judge, so it parses to null and stays in place.
*/
export function parseSingletonLockPid(linkTarget: string | null | undefined, hostname: string): number | null {
if (typeof linkTarget !== 'string' || linkTarget === '') {
return null
}
const prefix = `${hostname}-`
if (!linkTarget.startsWith(prefix)) {
return null
}
const tail = linkTarget.slice(prefix.length)
const pid = Number.parseInt(tail, 10)
// Reject `123junk` / `+4`-style targets: only a bare decimal PID counts.
if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== tail) {
return null
}
return pid
}
/**
* Field 3 of `/proc/<pid>/stat` is the process state — but field 2, the comm,
* is parenthesized and may contain spaces AND parentheses (`(hermes (v8))`),
* so splitting on whitespace is wrong. The state is the first token after the
* final `)`. Returns null for an unparseable line.
*/
export function parseProcStateField(statLine: string): string | null {
const close = statLine.lastIndexOf(')')
if (close === -1) {
return null
}
const state = statLine
.slice(close + 2)
.split(' ')[0]
.trim()
return state === '' ? null : state
}
/**
* Decide whether a SingletonLock owner is stale, from its `/proc` state.
*
* `kill(pid, 0)` succeeds for a zombie (the PID exists until its parent reaps
* it), which is exactly the stale-owner case in #78101: Chromium's liveness
* probe sees the defunct owner as alive, `requestSingleInstanceLock()`
* returns false, and every later launch silently exits. A null state (no
* `/proc` entry at all) is a dead owner and equally stale.
*/
export function isStaleSingletonLockOwner(procState: string | null | undefined): boolean {
if (procState === null || procState === undefined) {
return true
}
return procState.trim() === 'Z'
}
/** Read `/proc/<pid>/stat`'s state field, or null when the entry is gone. */
export function readLinuxProcState(pid: number): string | null {
try {
return parseProcStateField(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'))
} catch {
return null
}
}
/**
* Remove a provably-stale SingletonLock (Linux only) and return the dead
* owner's PID, or null when the lock must be left alone: another machine's
* lock (hostname mismatch), an unparseable target, or a live-looking owner.
*
* Live-looking means `/proc/<pid>/stat` shows a state other than Z — never
* `kill(pid, 0)`, which reports zombies as alive.
*/
export function removeStaleSingletonLock(
userDataDir: string,
deps: {
platform?: NodeJS.Platform
hostname?: string
readProcState?: (pid: number) => string | null
} = {}
): number | null {
if ((deps.platform ?? process.platform) !== 'linux') {
return null
}
const hostname = deps.hostname ?? os.hostname()
const readProcState = deps.readProcState ?? readLinuxProcState
let target: string
try {
target = fs.readlinkSync(path.join(userDataDir, 'SingletonLock'))
} catch {
// No lock (ENOENT), not a symlink (EINVAL), or unreadable: nothing to do.
return null
}
const pid = parseSingletonLockPid(target, hostname)
if (pid === null) {
return null
}
if (!isStaleSingletonLockOwner(readProcState(pid))) {
return null
}
try {
fs.unlinkSync(path.join(userDataDir, 'SingletonLock'))
} catch {
return null
}
return pid
}