fix: scope process leak snapshots

This commit is contained in:
Shakker 2026-05-07 10:36:42 +01:00
parent d626f7b5e5
commit 6468a077b7
No known key found for this signature in database
2 changed files with 84 additions and 6 deletions

View File

@ -233,6 +233,7 @@ export function captureProcessSnapshot(options = {}) {
const gatewayPid = envName ? resolveGatewayPid(envName) : null;
const allProcesses = listProcesses();
const gatewayTreePids = gatewayPid === null ? new Set() : collectProcessTreePids(allProcesses, gatewayPid);
const scopeTokens = snapshotScopeTokens(options);
const included = [];
for (const process of allProcesses) {
@ -243,7 +244,11 @@ export function captureProcessSnapshot(options = {}) {
if (gatewayTreePids.has(process.pid)) {
roles.add("gateway-tree");
}
for (const role of matchingRegistryProcessRoles(process, roleMatchers)) {
for (const role of matchingSnapshotRegistryRoles(process, roleMatchers, {
allowGlobalProcessRoleMatches: options.allowGlobalProcessRoleMatches === true,
existingRoles: roles,
scopeTokens
})) {
roles.add(role);
}
if (roles.size === 0) {
@ -297,6 +302,20 @@ export function classifyRegistryRolesForProcess(process, options = {}) {
return matchingRegistryRoles(process, options.rootCommand, roleMatchers, existingRoles);
}
export function classifySnapshotRolesForProcess(process, options = {}) {
const roleMatchers = compileRoleMatchers(options.processRoles ?? []);
const existingRoles = new Set(options.existingRoles ?? []);
const roles = new Set(existingRoles);
for (const role of matchingSnapshotRegistryRoles(process, roleMatchers, {
allowGlobalProcessRoleMatches: options.allowGlobalProcessRoleMatches === true,
existingRoles,
scopeTokens: snapshotScopeTokens(options)
})) {
roles.add(role);
}
return [...roles].sort();
}
function compileRoleMatchers(roles) {
return roles.map((role) => ({
id: role.id,
@ -345,6 +364,30 @@ function matchingRegistryProcessRoles(process, roleMatchers) {
return roles;
}
function matchingSnapshotRegistryRoles(process, roleMatchers, options = {}) {
const existingRoles = options.existingRoles ?? new Set();
if (existingRoles.size === 0 && options.allowGlobalProcessRoleMatches !== true && !processMatchesSnapshotScope(process, options.scopeTokens ?? [])) {
return [];
}
return matchingRegistryProcessRoles(process, roleMatchers);
}
function processMatchesSnapshotScope(process, scopeTokens) {
const command = String(process?.command ?? "");
return scopeTokens.some((token) => token.length > 0 && command.includes(token));
}
function snapshotScopeTokens(options = {}) {
const tokens = new Set();
if (typeof options.envName === "string" && options.envName.trim().length > 0) {
tokens.add(options.envName.trim());
}
for (const token of String(options.rootCommand ?? "").match(/\bkova-[A-Za-z0-9_.-]+\b/g) ?? []) {
tokens.add(token);
}
return [...tokens].filter((token) => token.length >= 4);
}
function summarizeRoleCounts(processes) {
const counts = new Map();
for (const process of processes) {

View File

@ -41,7 +41,7 @@ import {
parseProviderRequestLog,
parseTimelineProviderRequestLog
} from "./collectors/provider.mjs";
import { captureProcessSnapshot, classifyRegistryRolesForProcess, diffProcessSnapshots } from "./collectors/resources.mjs";
import { captureProcessSnapshot, classifyRegistryRolesForProcess, classifySnapshotRolesForProcess, diffProcessSnapshots } from "./collectors/resources.mjs";
import { renderMarkdownReport, renderPasteSummary, renderReportSummary } from "./reporting/report.mjs";
import { compareReports, renderCompareSummary } from "./reporting/compare.mjs";
import {
@ -5202,19 +5202,22 @@ async function resourceRolePollutionCheck() {
}
async function processSnapshotCheck(tmp) {
const processRoles = await loadProcessRoles();
const child = runCommand("node -e 'setTimeout(() => {}, 1200)'", {
timeoutMs: 5000,
resourceSample: null
});
await sleep(250);
const before = captureProcessSnapshot({
processRoles: await loadProcessRoles(),
rootCommand: "ocm @kova -- agent --local --message hi"
processRoles,
envName: "kova-self-check",
rootCommand: "ocm @kova-self-check -- agent --local --session-id kova-agent-self-check --message hi"
});
const result = await child;
const after = captureProcessSnapshot({
processRoles: await loadProcessRoles(),
rootCommand: "ocm @kova -- agent --local --message hi"
processRoles,
envName: "kova-self-check",
rootCommand: "ocm @kova-self-check -- agent --local --session-id kova-agent-self-check --message hi"
});
const leaks = diffProcessSnapshots(before, after, {
roles: ["agent-cli", "agent-process", "mcp-runtime", "plugin-cli", "mock-provider", "browser-sidecar"]
@ -5223,10 +5226,42 @@ async function processSnapshotCheck(tmp) {
await writeFile(artifactPath, `${JSON.stringify(leaks, null, 2)}\n`, "utf8");
try {
const unrelatedBrowserRoles = classifySnapshotRolesForProcess({
command: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --type=renderer"
}, {
processRoles,
envName: "kova-self-check",
rootCommand: "ocm @kova-self-check -- agent --local --session-id kova-agent-self-check --message hi"
});
const scopedBrowserRoles = classifySnapshotRolesForProcess({
command: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/tmp/kova-self-check/browser"
}, {
processRoles,
envName: "kova-self-check",
rootCommand: "ocm @kova-self-check -- agent --local --session-id kova-agent-self-check --message hi"
});
const gatewayBrowserRoles = classifySnapshotRolesForProcess({
command: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --type=renderer"
}, {
processRoles,
existingRoles: ["gateway-tree"],
envName: "kova-self-check"
});
const scopedAgentRoles = classifySnapshotRolesForProcess({
command: "openclaw-agent --session-id kova-agent-self-check"
}, {
processRoles,
envName: "kova-self-check",
rootCommand: "ocm @kova-self-check -- agent --local --session-id kova-agent-self-check --message hi"
});
assertEqual(result.status, 0, "snapshot command status");
assertEqual(before.schemaVersion, "kova.processSnapshot.v1", "snapshot schema");
assertEqual(leaks.schemaVersion, "kova.processLeakSummary.v1", "leak summary schema");
assertEqual(typeof leaks.leakCount, "number", "leak count type");
assertEqual(unrelatedBrowserRoles.includes("browser-sidecar"), false, "unrelated browser process excluded from snapshot role");
assertEqual(scopedBrowserRoles.includes("browser-sidecar"), true, "scoped browser process retained");
assertEqual(gatewayBrowserRoles.includes("browser-sidecar"), true, "gateway child browser process retained");
assertEqual(scopedAgentRoles.includes("agent-cli"), true, "scoped agent process retained");
return {
id: "process-snapshot-leak-contract",
status: "PASS",