mirror of
https://github.com/Orkas-AI/Orkas.git
synced 2026-11-07 23:02:03 -10:00
206 lines
6.4 KiB
JavaScript
206 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const { execFileSync } = require('node:child_process');
|
|
const path = require('node:path');
|
|
|
|
const GRACEFUL_TIMEOUT_MS = 4_000;
|
|
const POLL_INTERVAL_MS = 100;
|
|
|
|
function pathApiFor(platform) {
|
|
return platform === 'win32' ? path.win32 : path.posix;
|
|
}
|
|
|
|
function normalizePath(value, platform) {
|
|
const pathApi = pathApiFor(platform);
|
|
const normalized = pathApi.normalize(String(value || '').trim());
|
|
const withoutTrailingSeparators = normalized.replace(/[\\/]+$/, '');
|
|
return platform === 'win32'
|
|
? withoutTrailingSeparators.toLowerCase()
|
|
: withoutTrailingSeparators;
|
|
}
|
|
|
|
function tokenizeCommandLine(commandLine) {
|
|
const tokens = [];
|
|
const pattern = /"([^"]*)"|'([^']*)'|([^\s]+)/g;
|
|
let match;
|
|
while ((match = pattern.exec(String(commandLine || ''))) !== null) {
|
|
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function isBundledElectronExecutable(executablePath, appRoot, platform) {
|
|
const pathApi = pathApiFor(platform);
|
|
const normalizedExecutable = normalizePath(executablePath, platform);
|
|
const electronDist = normalizePath(
|
|
pathApi.join(appRoot, 'node_modules', 'electron', 'dist'),
|
|
platform,
|
|
);
|
|
const relative = pathApi.relative(electronDist, normalizedExecutable);
|
|
if (!relative || relative.startsWith('..') || pathApi.isAbsolute(relative)) return false;
|
|
|
|
if (platform === 'win32') return relative.toLowerCase() === 'electron.exe';
|
|
if (platform === 'linux') return relative === 'electron';
|
|
if (platform === 'darwin') {
|
|
return /^[^/]+\.app\/Contents\/MacOS\/Electron$/.test(relative);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isAppRootArgument(argument, appRoot, platform) {
|
|
const value = String(argument || '').replace(/^--app(?:-path)?=/, '');
|
|
if (value === '.') return true;
|
|
if (!value || value.startsWith('-')) return false;
|
|
return normalizePath(value, platform) === normalizePath(appRoot, platform);
|
|
}
|
|
|
|
function isSourceMainProcess(processInfo, appRoot, platform = process.platform) {
|
|
const pid = Number(processInfo?.pid);
|
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
|
|
const commandLine = String(processInfo?.commandLine || '');
|
|
if (!commandLine || /(?:^|\s)--type=/.test(commandLine)) return false;
|
|
|
|
const tokens = tokenizeCommandLine(commandLine);
|
|
const executablePath = processInfo?.executablePath || tokens[0];
|
|
if (!isBundledElectronExecutable(executablePath, appRoot, platform)) return false;
|
|
|
|
return tokens.slice(1).some((argument) => (
|
|
isAppRootArgument(argument, appRoot, platform)
|
|
));
|
|
}
|
|
|
|
function parsePosixProcessList(output) {
|
|
return String(output || '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/))
|
|
.filter(Boolean)
|
|
.map((match) => ({
|
|
pid: Number(match[1]),
|
|
parentPid: Number(match[2]),
|
|
commandLine: match[3],
|
|
}));
|
|
}
|
|
|
|
function listProcesses(platform = process.platform) {
|
|
if (platform === 'win32') {
|
|
const script = [
|
|
"$ErrorActionPreference = 'Stop';",
|
|
'Get-CimInstance Win32_Process',
|
|
'| Select-Object ProcessId, ParentProcessId, ExecutablePath, CommandLine',
|
|
'| ConvertTo-Json -Compress',
|
|
].join(' ');
|
|
const output = execFileSync('powershell.exe', [
|
|
'-NoLogo',
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-Command',
|
|
script,
|
|
], { encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
const decoded = JSON.parse(output || '[]');
|
|
return (Array.isArray(decoded) ? decoded : [decoded]).map((entry) => ({
|
|
pid: Number(entry.ProcessId),
|
|
parentPid: Number(entry.ParentProcessId),
|
|
executablePath: entry.ExecutablePath || '',
|
|
commandLine: entry.CommandLine || '',
|
|
}));
|
|
}
|
|
|
|
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,command='], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
return parsePosixProcessList(output);
|
|
}
|
|
|
|
function sleep(milliseconds) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
}
|
|
|
|
function stillMatches(pid, appRoot, platform) {
|
|
return listProcesses(platform).some((entry) => (
|
|
entry.pid === pid && isSourceMainProcess(entry, appRoot, platform)
|
|
));
|
|
}
|
|
|
|
function stopWindowsProcessTree(pid, appRoot) {
|
|
try {
|
|
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
stdio: 'ignore',
|
|
});
|
|
} catch (error) {
|
|
// The app can finish naturally between the final identity check and
|
|
// taskkill. Treat that race as success; surface every other failure.
|
|
if (!stillMatches(pid, appRoot, 'win32')) return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function stopPosixProcess(pid, appRoot, platform) {
|
|
try {
|
|
process.kill(pid, 'SIGTERM');
|
|
} catch (error) {
|
|
if (error?.code === 'ESRCH') return;
|
|
throw error;
|
|
}
|
|
|
|
const deadline = Date.now() + GRACEFUL_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
if (!stillMatches(pid, appRoot, platform)) return;
|
|
sleep(POLL_INTERVAL_MS);
|
|
}
|
|
|
|
// Revalidate the PID immediately before escalation so a recycled PID can
|
|
// never cause an unrelated process to be terminated.
|
|
if (stillMatches(pid, appRoot, platform)) process.kill(pid, 'SIGKILL');
|
|
}
|
|
|
|
function stopSourceInstances(appRoot, platform = process.platform) {
|
|
const matches = listProcesses(platform).filter((entry) => (
|
|
entry.pid !== process.pid
|
|
&& entry.pid !== process.ppid
|
|
&& isSourceMainProcess(entry, appRoot, platform)
|
|
));
|
|
if (matches.length === 0) return [];
|
|
|
|
const pids = matches.map((entry) => entry.pid);
|
|
console.log(`[Orkas] Closing previous source instance${pids.length > 1 ? 's' : ''} (PID ${pids.join(', ')})...`);
|
|
for (const { pid } of matches) {
|
|
if (platform === 'win32') {
|
|
if (stillMatches(pid, appRoot, platform)) stopWindowsProcessTree(pid, appRoot);
|
|
} else {
|
|
stopPosixProcess(pid, appRoot, platform);
|
|
}
|
|
}
|
|
return pids;
|
|
}
|
|
|
|
function readAppRoot(argv) {
|
|
const rootIndex = argv.indexOf('--root');
|
|
if (rootIndex < 0 || !argv[rootIndex + 1]) {
|
|
throw new Error('Usage: stop-source-instance.cjs --root <Orkas source directory>');
|
|
}
|
|
return path.resolve(argv[rootIndex + 1]);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
stopSourceInstances(readAppRoot(process.argv.slice(2)));
|
|
} catch (error) {
|
|
console.error(`[Orkas] Failed to close the previous source instance: ${error?.message || error}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
isSourceMainProcess,
|
|
parsePosixProcessList,
|
|
stopSourceInstances,
|
|
tokenizeCommandLine,
|
|
};
|