174 lines
6.3 KiB
JavaScript
174 lines
6.3 KiB
JavaScript
// Identify and inspect the Orca terminal daemon on Windows.
|
|
//
|
|
// The daemon is forked with ELECTRON_RUN_AS_NODE=1, so on Windows its process
|
|
// image is Orca.exe (the Electron binary running as plain Node) — it CANNOT be
|
|
// matched by executable name. The only reliable discriminators are the
|
|
// command-line markers the fork always passes: the daemon entry script
|
|
// (daemon-entry.js) and its --socket / --token arguments. See
|
|
// src/main/daemon/daemon-init.ts (createOutOfProcessLauncher).
|
|
//
|
|
// The daemon also writes a PID file at <userData>/daemon/daemon-v<N>.pid whose
|
|
// JSON carries { pid, startedAtMs, entryPath, appVersion } — the appVersion
|
|
// field lets the harness prove whether a post-update daemon was re-forked by
|
|
// the new build (cold-restore) or is the same process (survival).
|
|
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { assertWin32 } from './platform-guard.mjs'
|
|
import { runCommandSync } from './powershell-runner.mjs'
|
|
|
|
const DAEMON_ENTRY_MARKER = 'daemon-entry.js'
|
|
|
|
/** Default packaged userData root on Windows: %APPDATA%\Orca. */
|
|
export function defaultUserDataDir() {
|
|
const appData =
|
|
process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming')
|
|
return path.join(appData, 'Orca')
|
|
}
|
|
|
|
/**
|
|
* Find live daemon processes by scanning Win32_Process command lines for the
|
|
* daemon-entry.js marker. Returns [{ pid, ppid, name, commandLine }].
|
|
*
|
|
* A developer box (and a busy CI runner) can host many unrelated daemons — one
|
|
* per worktree/profile, plus lingering hosts from reverted builds. Pass
|
|
* `scope` (a substring of the harness's own userData/token/socket path) to
|
|
* match ONLY the daemon this harness's app instance owns. Omit it for a
|
|
* machine-wide listing.
|
|
*/
|
|
export function findDaemonProcesses(scope = '') {
|
|
assertWin32('daemon-processes')
|
|
// Match by command-line marker only, never by exe name: with
|
|
// ELECTRON_RUN_AS_NODE the daemon's image is Orca.exe today but a relocated
|
|
// Phase 1 host may run from a differently-named copied binary. @() around the
|
|
// filtered result guards the PS 5.1 single-item unwrap — one match must still
|
|
// serialize as an array or the JS side sees an object and .length explodes.
|
|
// This exact class caused a production incident.
|
|
const command = [
|
|
`$procs = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |`,
|
|
` Where-Object { $_.CommandLine -and $_.CommandLine -match 'daemon-entry\\.js' })`,
|
|
`$out = @($procs | ForEach-Object {`,
|
|
` [pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name; commandLine = $_.CommandLine } })`,
|
|
`ConvertTo-Json -InputObject @{ processes = $out } -Depth 4 -Compress`
|
|
].join('\n')
|
|
|
|
const parsed = runJsonCommand(command)
|
|
const scopeNeedle = scope.toLowerCase()
|
|
return normalizeArray(parsed.processes).filter(
|
|
(p) =>
|
|
typeof p.commandLine === 'string' &&
|
|
p.commandLine.includes(DAEMON_ENTRY_MARKER) &&
|
|
(scopeNeedle === '' || p.commandLine.toLowerCase().includes(scopeNeedle))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Read all daemon PID files under <userData>/daemon (daemon-v*.pid). Globbing
|
|
* the protocol-versioned name keeps this correct across PROTOCOL_VERSION bumps.
|
|
* Returns [{ file, pid, startedAtMs, entryPath, appVersion }].
|
|
*/
|
|
export function readDaemonPidFiles(userDataDir = defaultUserDataDir()) {
|
|
const daemonDir = path.join(userDataDir, 'daemon')
|
|
if (!existsSync(daemonDir)) {
|
|
return []
|
|
}
|
|
const records = []
|
|
for (const entry of readdirSync(daemonDir)) {
|
|
if (!entry.startsWith('daemon-v') || !entry.endsWith('.pid')) {
|
|
continue
|
|
}
|
|
const filePath = path.join(daemonDir, entry)
|
|
// Read once: a PID file can vanish between readdir and here, and re-reading
|
|
// it in the catch path would crash discovery on a single stale file.
|
|
let raw = ''
|
|
try {
|
|
raw = readFileSync(filePath, 'utf8').trim()
|
|
const parsed = JSON.parse(raw)
|
|
records.push({ file: filePath, ...parsed })
|
|
} catch {
|
|
// Legacy/partial pid files may hold a bare integer.
|
|
const pid = Number(raw)
|
|
if (Number.isInteger(pid)) {
|
|
records.push({ file: filePath, pid })
|
|
}
|
|
}
|
|
}
|
|
return records
|
|
}
|
|
|
|
/** True if a PID currently maps to a live process. */
|
|
export function isPidAlive(pid) {
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
return false
|
|
}
|
|
const { stdout } = runCommandSync(
|
|
`if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'alive' } else { 'dead' }`
|
|
)
|
|
return stdout.trim() === 'alive'
|
|
}
|
|
|
|
function runJsonCommand(command) {
|
|
const { stdout, stderr, code, error } = runCommandSync(command)
|
|
if (error) {
|
|
throw new Error(`PowerShell spawn failed: ${error.message}`)
|
|
}
|
|
const trimmed = stdout.trim()
|
|
if (!trimmed) {
|
|
// No matches: ConvertTo-Json of an empty array can emit nothing.
|
|
return { processes: [] }
|
|
}
|
|
try {
|
|
return JSON.parse(trimmed)
|
|
} catch (parseError) {
|
|
throw new Error(
|
|
`daemon-processes query returned non-JSON (exit ${code}): ${parseError.message}\n` +
|
|
`stdout:\n${trimmed}\nstderr:\n${stderr}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function normalizeArray(raw) {
|
|
if (!raw) {
|
|
return []
|
|
}
|
|
return Array.isArray(raw) ? raw : [raw]
|
|
}
|
|
|
|
function parseUserDataArg(argv) {
|
|
const idx = argv.indexOf('--user-data')
|
|
return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir()
|
|
}
|
|
|
|
function runStandalone(argv) {
|
|
assertWin32('daemon-processes standalone')
|
|
const userDataDir = parseUserDataArg(argv)
|
|
console.log(`[daemon-processes] userData: ${userDataDir}`)
|
|
|
|
const pidFiles = readDaemonPidFiles(userDataDir)
|
|
console.log(`[daemon-processes] PID files (${pidFiles.length}):`)
|
|
console.log(JSON.stringify(pidFiles, null, 2))
|
|
|
|
const scopeIdx = argv.indexOf('--scope')
|
|
const scope = scopeIdx >= 0 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : ''
|
|
const processes = findDaemonProcesses(scope)
|
|
console.log(
|
|
`[daemon-processes] live daemon processes${scope ? ` scoped to "${scope}"` : ''} (${processes.length}):`
|
|
)
|
|
console.log(JSON.stringify(processes, null, 2))
|
|
|
|
for (const rec of pidFiles) {
|
|
if (typeof rec.pid === 'number') {
|
|
console.log(`[daemon-processes] pid ${rec.pid} alive: ${isPidAlive(rec.pid)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
|
try {
|
|
runStandalone(process.argv.slice(2))
|
|
} catch (err) {
|
|
console.error(err.message)
|
|
process.exitCode = 1
|
|
}
|
|
}
|