374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
const { EventEmitter } = require('events');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const net = require('net');
|
|
const path = require('path');
|
|
const { spawn, spawnSync } = require('child_process');
|
|
|
|
const LOOPBACK_HOST = '127.0.0.1';
|
|
const SIDECAR_NONCE_HEADER = 'x-aps-sidecar-nonce';
|
|
const CHILD_ENV_ALLOWLIST = new Set([
|
|
'ALLUSERSPROFILE',
|
|
'APPDATA',
|
|
'COMMONPROGRAMFILES',
|
|
'COMMONPROGRAMFILES(X86)',
|
|
'COMSPEC',
|
|
'HOME',
|
|
'HOMEDRIVE',
|
|
'HOMEPATH',
|
|
'LANG',
|
|
'LOCALAPPDATA',
|
|
'NUMBER_OF_PROCESSORS',
|
|
'PATH',
|
|
'PATHEXT',
|
|
'PROCESSOR_ARCHITECTURE',
|
|
'PROCESSOR_IDENTIFIER',
|
|
'PROCESSOR_LEVEL',
|
|
'PROCESSOR_REVISION',
|
|
'PROGRAMDATA',
|
|
'PROGRAMFILES',
|
|
'PROGRAMFILES(X86)',
|
|
'SYSTEMDRIVE',
|
|
'SYSTEMROOT',
|
|
'TEMP',
|
|
'TMP',
|
|
'TZ',
|
|
'USERPROFILE',
|
|
'WINDIR',
|
|
]);
|
|
|
|
function reserveLoopbackPort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once('error', reject);
|
|
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
server.close((error) => {
|
|
if (error) reject(error);
|
|
else if (!port) reject(new Error('Failed to reserve a loopback port'));
|
|
else resolve(port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function bundledSidecarPath(resourcesPath, platform = process.platform) {
|
|
const name = platform === 'win32' ? 'aps-sidecar.exe' : 'aps-sidecar';
|
|
const executable = path.join(resourcesPath, 'sidecar', name);
|
|
if (!fs.existsSync(executable)) {
|
|
throw new Error(`Bundled APS sidecar is missing: ${executable}`);
|
|
}
|
|
return executable;
|
|
}
|
|
|
|
function probeHealth(url, options = {}) {
|
|
const timeoutMs = options.timeoutMs ?? 1_500;
|
|
const expectedNonce = options.expectedNonce ?? null;
|
|
return new Promise((resolve, reject) => {
|
|
const headers = expectedNonce ? { [SIDECAR_NONCE_HEADER]: expectedNonce } : {};
|
|
const request = http.get(url, { timeout: timeoutMs, headers }, (response) => {
|
|
response.resume();
|
|
if (response.statusCode !== 200) {
|
|
reject(new Error(`Health probe returned HTTP ${response.statusCode}`));
|
|
} else if (expectedNonce && response.headers['x-aps-sidecar-nonce'] !== expectedNonce) {
|
|
reject(new Error('Health probe returned an unexpected sidecar identity'));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
request.once('timeout', () => request.destroy(new Error('Health probe timed out')));
|
|
request.once('error', reject);
|
|
});
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function waitForHealth(url, options = {}) {
|
|
const timeoutMs = options.timeoutMs ?? 45_000;
|
|
const intervalMs = options.intervalMs ?? 250;
|
|
const probe = options.probe ?? probeHealth;
|
|
const expectedNonce = options.expectedNonce ?? null;
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await probe(url, { expectedNonce });
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await delay(intervalMs);
|
|
}
|
|
}
|
|
|
|
const detail = lastError instanceof Error ? `: ${lastError.message}` : '';
|
|
throw new Error(`APS sidecar did not become healthy within ${timeoutMs}ms${detail}`);
|
|
}
|
|
|
|
const BLOCKED_PYTHON_ENV = new Set(['PYTHONHOME', 'PYTHONPATH', 'PYTHONUSERBASE']);
|
|
const CONDA_DISTRIBUTION_SEGMENT = /^(?:anaconda|miniconda|miniforge|mambaforge)(?:\d.*)?$/i;
|
|
|
|
function normalizedPathEntry(value, platform) {
|
|
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
const unquoted = String(value).trim().replace(/^"(.*)"$/, '$1');
|
|
const normalized = pathApi.normalize(unquoted).replace(/[\\/]+$/, '');
|
|
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
function condaPrefixesFromEnv(env, platform) {
|
|
const prefixes = [];
|
|
for (const [name, value] of Object.entries(env)) {
|
|
if (name.toUpperCase().startsWith('CONDA_PREFIX') && value) {
|
|
prefixes.push(normalizedPathEntry(value, platform));
|
|
}
|
|
}
|
|
return prefixes;
|
|
}
|
|
|
|
function isPathWithin(candidate, prefix, platform) {
|
|
if (!candidate || !prefix) return false;
|
|
if (candidate === prefix) return true;
|
|
const separator = platform === 'win32' ? '\\' : '/';
|
|
return candidate.startsWith(`${prefix}${separator}`);
|
|
}
|
|
|
|
function isCondaPathEntry(entry, prefixes, platform) {
|
|
const comparable = normalizedPathEntry(entry, platform);
|
|
if (prefixes.some((prefix) => isPathWithin(comparable, prefix, platform))) return true;
|
|
const segments = comparable.split(/[\\/]+/).filter(Boolean);
|
|
return segments.some(
|
|
(segment) => CONDA_DISTRIBUTION_SEGMENT.test(segment) || segment.toLowerCase() === 'condabin',
|
|
);
|
|
}
|
|
|
|
function sanitizePathValue(value, sourceEnv, platform) {
|
|
const delimiter = platform === 'win32' ? ';' : ':';
|
|
const prefixes = condaPrefixesFromEnv(sourceEnv, platform);
|
|
return String(value)
|
|
.split(delimiter)
|
|
.map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
|
|
.filter((entry) => entry && !isCondaPathEntry(entry, prefixes, platform))
|
|
.join(delimiter);
|
|
}
|
|
|
|
function buildChildEnv(baseEnv, overrides, platform = process.platform) {
|
|
const env = {};
|
|
for (const [name, value] of Object.entries(baseEnv)) {
|
|
const upperName = name.toUpperCase();
|
|
if (CHILD_ENV_ALLOWLIST.has(upperName) || upperName.startsWith('LC_')) {
|
|
env[name] = value;
|
|
}
|
|
}
|
|
const result = { ...env, ...overrides };
|
|
const sourceEnv = { ...baseEnv, ...overrides };
|
|
for (const name of Object.keys(result)) {
|
|
const upperName = name.toUpperCase();
|
|
if (upperName.startsWith('CONDA_') || BLOCKED_PYTHON_ENV.has(upperName)) {
|
|
delete result[name];
|
|
} else if (upperName === 'PATH') {
|
|
result[name] = sanitizePathValue(result[name], sourceEnv, platform);
|
|
}
|
|
}
|
|
result.PYTHONNOUSERSITE = '1';
|
|
result.PYTHONUTF8 = '1';
|
|
return result;
|
|
}
|
|
|
|
function terminateProcessTree(child, platform = process.platform, runner = spawnSync) {
|
|
if (platform === 'win32' && Number.isInteger(child?.pid) && child.pid > 0) {
|
|
const completed = runner(
|
|
'taskkill.exe',
|
|
['/pid', String(child.pid), '/T', '/F'],
|
|
{ stdio: 'ignore', windowsHide: true },
|
|
);
|
|
if (!completed.error && completed.status === 0) return;
|
|
}
|
|
try { child?.kill(); } catch { /* best-effort shutdown */ }
|
|
}
|
|
|
|
class SidecarManager extends EventEmitter {
|
|
constructor(options) {
|
|
super();
|
|
this.resourcesPath = options.resourcesPath;
|
|
this.apsHome = options.apsHome;
|
|
this.platform = options.platform ?? process.platform;
|
|
this.spawnProcess = options.spawnProcess ?? spawn;
|
|
this.baseEnv = options.baseEnv ?? process.env;
|
|
this.terminateChild = options.terminateChild ?? ((child) => terminateProcessTree(child, this.platform));
|
|
this.getPort = options.getPort ?? reserveLoopbackPort;
|
|
this.waitForHealthy = options.waitForHealthy ?? waitForHealth;
|
|
this.startupTimeoutMs = options.startupTimeoutMs ?? 45_000;
|
|
this.restartLimit = options.restartLimit ?? 3;
|
|
this.restartDelayMs = options.restartDelayMs ?? 1_000;
|
|
this.stabilityResetMs = options.stabilityResetMs ?? 60_000;
|
|
this.createNonce = options.createNonce ?? (() => crypto.randomBytes(32).toString('hex'));
|
|
this.createAuditToken = options.createAuditToken ?? (() => crypto.randomBytes(32).toString('hex'));
|
|
this.appVersion = options.appVersion ?? null;
|
|
this.nonce = null;
|
|
this.auditToken = null;
|
|
this.child = null;
|
|
this.port = null;
|
|
this.origin = null;
|
|
this.generation = 0;
|
|
this.restartCount = 0;
|
|
this.restartTimer = null;
|
|
this.stabilityTimer = null;
|
|
this.stopping = false;
|
|
}
|
|
|
|
async start() {
|
|
if (this.child) return this.origin;
|
|
this.stopping = false;
|
|
await this._launch();
|
|
return this.origin;
|
|
}
|
|
|
|
_writeLog(streamName, chunk) {
|
|
const logDir = path.join(this.apsHome, 'logs');
|
|
fs.mkdirSync(logDir, { recursive: true });
|
|
const logPath = path.join(logDir, 'sidecar.log');
|
|
if (fs.existsSync(logPath) && fs.statSync(logPath).size >= 10 * 1024 * 1024) {
|
|
const previousPath = `${logPath}.1`;
|
|
fs.rmSync(previousPath, { force: true });
|
|
fs.renameSync(logPath, previousPath);
|
|
}
|
|
const text = String(chunk).slice(0, 64 * 1024);
|
|
const line = `[${new Date().toISOString()}] [${streamName}] ${text}`;
|
|
fs.appendFileSync(logPath, line, 'utf8');
|
|
}
|
|
|
|
async _launch() {
|
|
const executable = bundledSidecarPath(this.resourcesPath, this.platform);
|
|
const uiDir = path.join(this.resourcesPath, 'web', 'dist');
|
|
if (!fs.existsSync(path.join(uiDir, 'index.html'))) {
|
|
throw new Error(`Bundled APS web UI is missing: ${uiDir}`);
|
|
}
|
|
|
|
const port = await this.getPort();
|
|
const nonce = this.createNonce();
|
|
const auditToken = this.createAuditToken();
|
|
this.port = port;
|
|
this.nonce = nonce;
|
|
this.auditToken = auditToken;
|
|
this.origin = `http://${LOOPBACK_HOST}:${port}`;
|
|
const generation = ++this.generation;
|
|
let startupSettled = false;
|
|
let rejectEarlyExit;
|
|
const earlyExit = new Promise((_, reject) => { rejectEarlyExit = reject; });
|
|
const child = this.spawnProcess(executable, [], {
|
|
cwd: path.dirname(executable),
|
|
env: buildChildEnv(this.baseEnv, {
|
|
APS_MODE: 'desktop',
|
|
APS_HOME: this.apsHome,
|
|
APS_API_HOST: LOOPBACK_HOST,
|
|
APS_API_PORT: String(port),
|
|
APS_UI_DIR: uiDir,
|
|
APS_SIDECAR_NONCE: nonce,
|
|
...(this.appVersion ? { APS_APP_VERSION: this.appVersion } : {}),
|
|
APS_PARENT_PID: String(process.pid),
|
|
}),
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
this.child = child;
|
|
|
|
child.stdout?.on('data', (chunk) => this._writeLog('stdout', chunk));
|
|
child.stderr?.on('data', (chunk) => this._writeLog('stderr', chunk));
|
|
child.once('error', (error) => {
|
|
this._writeLog('launcher', `${error.stack ?? error}\n`);
|
|
if (!startupSettled) rejectEarlyExit(error);
|
|
});
|
|
child.once('exit', (code, signal) => {
|
|
if (generation !== this.generation) return;
|
|
this.child = null;
|
|
const error = new Error(`APS sidecar exited code=${code} signal=${signal}`);
|
|
this._writeLog('launcher', `${error.message}\n`);
|
|
if (!startupSettled) rejectEarlyExit(error);
|
|
else if (!this.stopping) this._scheduleRestart(error);
|
|
});
|
|
|
|
try {
|
|
await Promise.race([
|
|
this.waitForHealthy(`${this.origin}/api/health`, {
|
|
timeoutMs: this.startupTimeoutMs,
|
|
expectedNonce: nonce,
|
|
}),
|
|
earlyExit,
|
|
]);
|
|
startupSettled = true;
|
|
this._armStabilityReset();
|
|
this.emit('ready', { origin: this.origin, pid: child.pid });
|
|
} catch (error) {
|
|
startupSettled = true;
|
|
if (this.child === child) {
|
|
this.terminateChild(child);
|
|
this.child = null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
_armStabilityReset() {
|
|
clearTimeout(this.stabilityTimer);
|
|
this.stabilityTimer = setTimeout(() => {
|
|
this.restartCount = 0;
|
|
}, this.stabilityResetMs);
|
|
this.stabilityTimer.unref?.();
|
|
}
|
|
|
|
_scheduleRestart(cause) {
|
|
clearTimeout(this.stabilityTimer);
|
|
const previousOrigin = this.origin;
|
|
this.port = null;
|
|
this.origin = null;
|
|
this.nonce = null;
|
|
this.auditToken = null;
|
|
if (this.restartCount >= this.restartLimit) {
|
|
this.emit('fatal', cause);
|
|
return;
|
|
}
|
|
this.restartCount += 1;
|
|
const attempt = this.restartCount;
|
|
this.emit('restarting', { attempt, cause, previousOrigin });
|
|
this.restartTimer = setTimeout(async () => {
|
|
this.restartTimer = null;
|
|
if (this.stopping) return;
|
|
try {
|
|
await this._launch();
|
|
this.emit('restarted', { attempt, origin: this.origin });
|
|
} catch (error) {
|
|
this._writeLog('launcher', `restart ${attempt} failed: ${error.stack ?? error}\n`);
|
|
this._scheduleRestart(error);
|
|
}
|
|
}, this.restartDelayMs * attempt);
|
|
}
|
|
|
|
stop() {
|
|
this.stopping = true;
|
|
clearTimeout(this.restartTimer);
|
|
clearTimeout(this.stabilityTimer);
|
|
this.restartTimer = null;
|
|
this.stabilityTimer = null;
|
|
this.generation += 1;
|
|
const child = this.child;
|
|
this.child = null;
|
|
if (child) this.terminateChild(child);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
LOOPBACK_HOST,
|
|
SidecarManager,
|
|
buildChildEnv,
|
|
bundledSidecarPath,
|
|
probeHealth,
|
|
reserveLoopbackPort,
|
|
terminateProcessTree,
|
|
waitForHealth,
|
|
};
|