feat: add inspection task lifecycle controls
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
* 主进程不直接 spawn Python,而是 fork Node runner —— 与现有
|
||||
* smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。
|
||||
*/
|
||||
import { fork, ChildProcess } from 'node:child_process';
|
||||
import { fork, ChildProcess, execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -67,7 +67,7 @@ class ConcurrencyPool {
|
||||
child.kill('SIGKILL');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return killOrphanAlgorithmProcesses({ resultId });
|
||||
}
|
||||
|
||||
/** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */
|
||||
@@ -85,13 +85,17 @@ class ConcurrencyPool {
|
||||
}
|
||||
this.queue = keep;
|
||||
// 运行中 kill
|
||||
for (const [resultId, child] of this.activeChildren) {
|
||||
for (const [resultId, child] of Array.from(this.activeChildren.entries())) {
|
||||
const jobTaskId = (child as ChildProcess & { _taskId?: number })._taskId;
|
||||
if (jobTaskId === taskId) {
|
||||
child.kill('SIGKILL');
|
||||
cancelled.push(resultId);
|
||||
}
|
||||
}
|
||||
const orphaned = killOrphanAlgorithmProcesses({ taskId });
|
||||
if (orphaned.length > 0) {
|
||||
cancelled.push(...orphaned);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
@@ -155,6 +159,113 @@ function resolveAlgorithmRunnerEntry(): string {
|
||||
|
||||
const pool = new ConcurrencyPool(DEFAULT_CONCURRENCY);
|
||||
|
||||
interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
function listProcesses(): ProcessRow[] {
|
||||
try {
|
||||
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
command: match[3],
|
||||
};
|
||||
})
|
||||
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
|
||||
} catch (err) {
|
||||
console.warn('[AlgorithmJob] 无法列出进程,跳过孤儿进程清理:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
|
||||
const all = new Set(rootPids);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const proc of processes) {
|
||||
if (!all.has(proc.pid) && all.has(proc.ppid)) {
|
||||
all.add(proc.pid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function killPids(pids: number[]): number[] {
|
||||
const killed: number[] = [];
|
||||
for (const pid of pids) {
|
||||
if (pid <= 1 || pid === process.pid) continue;
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
killed.push(pid);
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
function killOrphanAlgorithmProcesses(input: { resultId: number }): boolean;
|
||||
function killOrphanAlgorithmProcesses(input: { taskId: number }): number[];
|
||||
function killOrphanAlgorithmProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
|
||||
const processes = listProcesses();
|
||||
const roots = new Set<number>();
|
||||
const resultIds = new Set<number>();
|
||||
|
||||
for (const proc of processes) {
|
||||
const cmd = proc.command;
|
||||
if (input.resultId != null) {
|
||||
const resultId = String(input.resultId);
|
||||
if (
|
||||
new RegExp(`algorithmJobRunner\\.(?:ts|js)\\s+${resultId}\\s+`).test(cmd) ||
|
||||
cmd.includes(`/analysis/${resultId}/input.json`)
|
||||
) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(input.resultId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.taskId != null) {
|
||||
const runnerMatch = cmd.match(/algorithmJobRunner\.(?:ts|js)\s+(\d+)\s+(\d+)\s+/);
|
||||
if (runnerMatch && Number(runnerMatch[2]) === input.taskId) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(Number(runnerMatch[1]));
|
||||
continue;
|
||||
}
|
||||
const inputJsonMatch = cmd.match(new RegExp(`/data/tasks/${input.taskId}/analysis/(\\d+)/input\\.json`));
|
||||
if (inputJsonMatch) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(Number(inputJsonMatch[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
|
||||
const killed = killPids(targets);
|
||||
if (killed.length > 0) {
|
||||
console.warn(`[AlgorithmJob] 已清理孤儿算法进程 pid=${killed.join(', ')}`);
|
||||
}
|
||||
|
||||
if (input.resultId != null) return killed.length > 0;
|
||||
return Array.from(resultIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次插件运行加入并发池。返回的 Promise 在子进程退出时 resolve(无论成功/失败)。
|
||||
* 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。
|
||||
|
||||
Reference in New Issue
Block a user