148 lines
5.4 KiB
JavaScript
148 lines
5.4 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { EventEmitter } from 'node:events';
|
|
import { execa } from 'execa';
|
|
import { config } from '../config.js';
|
|
import { logger } from './logger.js';
|
|
class ProcessManager {
|
|
processes = new Map();
|
|
start(options) {
|
|
if (this.runningCount() >= config.maxConcurrentProcesses) {
|
|
throw new Error(`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`);
|
|
}
|
|
const id = randomUUID();
|
|
const emitter = new EventEmitter();
|
|
const subprocess = execa(options.command, options.args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
reject: false,
|
|
stdin: 'ignore',
|
|
forceKillAfterDelay: 5000,
|
|
});
|
|
const managed = {
|
|
id,
|
|
label: options.label,
|
|
command: options.command,
|
|
args: options.args,
|
|
cwd: options.cwd,
|
|
pid: subprocess.pid,
|
|
status: 'running',
|
|
exitCode: null,
|
|
startedAt: new Date().toISOString(),
|
|
emitter,
|
|
logs: [],
|
|
subprocess,
|
|
};
|
|
const appendLog = (chunk) => {
|
|
const text = chunk.toString();
|
|
for (const line of text.split(/\r?\n/)) {
|
|
if (line.length === 0)
|
|
continue;
|
|
managed.logs.push(line);
|
|
if (managed.logs.length > config.maxBufferedLogLines) {
|
|
managed.logs.splice(0, managed.logs.length - config.maxBufferedLogLines);
|
|
}
|
|
emitter.emit('log', line);
|
|
}
|
|
};
|
|
subprocess.stdout?.on('data', appendLog);
|
|
subprocess.stderr?.on('data', appendLog);
|
|
subprocess
|
|
.then((result) => {
|
|
managed.status = managed.status === 'stopped' ? 'stopped' : 'exited';
|
|
managed.exitCode = result.exitCode ?? null;
|
|
managed.exitedAt = new Date().toISOString();
|
|
emitter.emit('exit');
|
|
})
|
|
.catch((error) => {
|
|
managed.status = 'error';
|
|
managed.exitedAt = new Date().toISOString();
|
|
appendLog(`[process error] ${error instanceof Error ? error.message : String(error)}`);
|
|
emitter.emit('exit');
|
|
logger.error(`managed process ${id} failed`, { error: error instanceof Error ? error.message : error });
|
|
});
|
|
this.processes.set(id, managed);
|
|
return this.toInfo(managed);
|
|
}
|
|
/**
|
|
* Resolves once a log line matches `pattern`, the process exits, or `timeoutMs` elapses.
|
|
* Used by start_dev_server so it can report the actual "ready" URL instead of guessing.
|
|
*/
|
|
waitForLog(id, pattern, timeoutMs) {
|
|
const managed = this.require(id);
|
|
const existing = managed.logs.find((line) => pattern.test(line));
|
|
if (existing)
|
|
return Promise.resolve({ matchedLine: existing, timedOut: false });
|
|
return new Promise((resolve) => {
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
managed.emitter.off('log', onLog);
|
|
managed.emitter.off('exit', onExit);
|
|
};
|
|
const onLog = (line) => {
|
|
if (pattern.test(line)) {
|
|
cleanup();
|
|
resolve({ matchedLine: line, timedOut: false });
|
|
}
|
|
};
|
|
const onExit = () => {
|
|
cleanup();
|
|
resolve({ matchedLine: null, timedOut: false });
|
|
};
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
resolve({ matchedLine: null, timedOut: true });
|
|
}, timeoutMs);
|
|
managed.emitter.on('log', onLog);
|
|
managed.emitter.on('exit', onExit);
|
|
});
|
|
}
|
|
stop(id) {
|
|
const managed = this.require(id);
|
|
if (managed.status === 'running') {
|
|
managed.status = 'stopped';
|
|
managed.subprocess.kill('SIGTERM');
|
|
}
|
|
return this.toInfo(managed);
|
|
}
|
|
getLogs(id, tail) {
|
|
const managed = this.require(id);
|
|
const logs = tail && tail > 0 ? managed.logs.slice(-tail) : managed.logs.slice();
|
|
return { info: this.toInfo(managed), logs };
|
|
}
|
|
list() {
|
|
return Array.from(this.processes.values()).map((p) => this.toInfo(p));
|
|
}
|
|
stopAll() {
|
|
for (const managed of this.processes.values()) {
|
|
if (managed.status === 'running') {
|
|
managed.subprocess.kill('SIGTERM');
|
|
}
|
|
}
|
|
}
|
|
runningCount() {
|
|
return Array.from(this.processes.values()).filter((p) => p.status === 'running').length;
|
|
}
|
|
require(id) {
|
|
const managed = this.processes.get(id);
|
|
if (!managed) {
|
|
throw new Error(`Unknown processId "${id}". It may have already exited and been forgotten, or never existed.`);
|
|
}
|
|
return managed;
|
|
}
|
|
toInfo(p) {
|
|
return {
|
|
id: p.id,
|
|
label: p.label,
|
|
command: p.command,
|
|
args: p.args,
|
|
cwd: p.cwd,
|
|
pid: p.pid,
|
|
status: p.status,
|
|
exitCode: p.exitCode,
|
|
startedAt: p.startedAt,
|
|
exitedAt: p.exitedAt,
|
|
};
|
|
}
|
|
}
|
|
export const processManager = new ProcessManager();
|
|
//# sourceMappingURL=processManager.js.map
|