Merge pull request #453 from morluto/codex/event-process-tree-332

feat(process): replace procfs sampling with event-backed process-tree capture
This commit is contained in:
morluto
2026-07-28 14:21:25 +08:00
committed by GitHub
2 changed files with 658 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import { z } from "zod";
/** Event types for event-backed process-tree capture. */
export const processEventTypeSchema = z.enum([
"spawn",
"exit",
"re_exec",
"reparent",
"signal",
"name_change",
"thread_create",
"thread_exit",
]);
export type ProcessEventType = z.infer<typeof processEventTypeSchema>;
/** A single event-backed process-tree event. */
export const processTreeEventSchema = z.strictObject({
/** Monotonic event sequence number. */
sequence: z.number().int().nonnegative(),
/** Event timestamp in milliseconds. */
at_ms: z.number().int().nonnegative(),
/** Event type. */
type: processEventTypeSchema,
/** Process ID. */
pid: z.number().int().positive(),
/** Parent process ID. */
ppid: z.number().int().nullable(),
/** Process name if known. */
process_name: z.string().nullable(),
/** Executable path if known. */
executable: z.string().nullable(),
/** Command line arguments. */
arguments: z.array(z.string()).default([]),
/** Previous PID (for reparent events). */
previous_pid: z.number().int().nullable(),
/** Previous PPID (for reparent events). */
previous_ppid: z.number().int().nullable(),
/** Exit code (for exit events). */
exit_code: z.number().int().nullable(),
/** Signal number (for signal events). */
signal: z.number().int().nullable(),
/** Thread ID (for thread events). */
tid: z.number().int().nullable(),
});
export type ProcessTreeEvent = z.infer<typeof processTreeEventSchema>;
/** Process node in a reconstructed tree. */
export const processNodeSchema = z.strictObject({
pid: z.number().int().positive(),
ppid: z.number().int().nullable(),
process_name: z.string().nullable(),
executable: z.string().nullable(),
arguments: z.array(z.string()).default([]),
children: z.array(z.number().int()).default([]),
spawn_time_ms: z.number().int().nullable(),
exit_time_ms: z.number().int().nullable(),
exit_code: z.number().int().nullable(),
is_re_exec: z.boolean().default(false),
is_reparented: z.boolean().default(false),
previous_ppid: z.number().int().nullable(),
});
export type ProcessNode = z.infer<typeof processNodeSchema>;
/** Result of event-backed process-tree reconstruction. */
export const processTreeReconstructionSchema = z.strictObject({
/** Root PID of the process tree. */
root_pid: z.number().int().positive(),
/** All reconstructed process nodes. */
processes: z.array(processNodeSchema).min(0).max(10_000),
/** Events that were consumed. */
events_consumed: z.number().int().nonnegative(),
/** Events that could not be matched. */
events_unmatched: z.number().int().nonnegative(),
/** Short-lived descendants that were missed by sampling. */
short_lived_descendants: z.number().int().nonnegative(),
/** Whether any re-exec was detected. */
has_re_exec: z.boolean().default(false),
/** Whether any reparenting was detected. */
has_reparenting: z.boolean().default(false),
});
export type ProcessTreeReconstruction = z.infer<
typeof processTreeReconstructionSchema
>;
/** Reconstruct a process tree from event-backed capture. */
export function reconstructProcessTree(
events: readonly ProcessTreeEvent[],
rootPid: number,
): ProcessTreeReconstruction {
const nodes = new Map<number, ProcessNode>();
const children = new Map<number, number[]>();
let eventsConsumed = 0;
let eventsUnmatched = 0;
let hasReExec = false;
let hasReparenting = false;
let shortLivedDescendants = 0;
const sorted = [...events].sort((a, b) => a.at_ms - b.at_ms);
for (const event of sorted) {
eventsConsumed++;
let node = nodes.get(event.pid);
switch (event.type) {
case "spawn": {
if (!node) {
node = {
pid: event.pid,
ppid: event.ppid,
process_name: event.process_name,
executable: event.executable,
arguments: event.arguments,
children: [],
spawn_time_ms: event.at_ms,
exit_time_ms: null,
exit_code: null,
is_re_exec: false,
is_reparented: false,
previous_ppid: null,
};
nodes.set(event.pid, node);
}
if (event.ppid !== null) {
const parentChildren = children.get(event.ppid) ?? [];
parentChildren.push(event.pid);
children.set(event.ppid, parentChildren);
}
break;
}
case "exit": {
if (node) {
node.exit_time_ms = event.at_ms;
node.exit_code = event.exit_code;
if (
node.spawn_time_ms !== null &&
event.at_ms - node.spawn_time_ms < 100
)
shortLivedDescendants++;
} else {
eventsUnmatched++;
}
break;
}
case "re_exec": {
if (node) {
node.is_re_exec = true;
node.executable = event.executable ?? node.executable;
node.arguments = event.arguments;
hasReExec = true;
} else {
eventsUnmatched++;
}
break;
}
case "reparent": {
if (node) {
node.previous_ppid = node.ppid;
node.ppid = event.ppid;
node.is_reparented = true;
hasReparenting = true;
} else {
eventsUnmatched++;
}
break;
}
case "signal":
case "name_change":
case "thread_create":
case "thread_exit":
// These events are recorded but don't modify the tree structure
break;
}
}
// Attach children to nodes
for (const [pid, childPids] of children) {
const node = nodes.get(pid);
if (node) node.children = childPids;
}
return {
root_pid: rootPid,
processes: [...nodes.values()],
events_consumed: eventsConsumed,
events_unmatched: eventsUnmatched,
short_lived_descendants: shortLivedDescendants,
has_re_exec: hasReExec,
has_reparenting: hasReparenting,
};
}
/** Get all descendant PIDs of a given root PID. */
export function getDescendants(
processes: readonly ProcessNode[],
rootPid: number,
): number[] {
const byPid = new Map(processes.map((p) => [p.pid, p]));
const visited = new Set<number>();
const result: number[] = [];
const queue: number[] = [rootPid];
while (queue.length > 0) {
const pid = queue.shift()!;
if (visited.has(pid)) continue;
visited.add(pid);
const node = byPid.get(pid);
if (node) {
for (const child of node.children) {
if (!visited.has(child)) {
result.push(child);
queue.push(child);
}
}
}
}
return result;
}
/** Check if a process tree contains a specific PID. */
export function containsProcess(
processes: readonly ProcessNode[],
pid: number,
): boolean {
return processes.some((p) => p.pid === pid);
}
/** Get the maximum tree depth from the root. */
export function maxTreeDepth(
processes: readonly ProcessNode[],
rootPid: number,
): number {
const byPid = new Map(processes.map((p) => [p.pid, p]));
function depth(pid: number, visited: Set<number>): number {
if (visited.has(pid)) return 0;
visited.add(pid);
const node = byPid.get(pid);
if (!node || node.children.length === 0) return 0;
return 1 + Math.max(...node.children.map((c) => depth(c, visited)));
}
return depth(rootPid, new Set());
}
+418
View File
@@ -0,0 +1,418 @@
import { describe, expect, it } from "vitest";
import {
containsProcess,
getDescendants,
maxTreeDepth,
reconstructProcessTree,
type ProcessTreeEvent,
type ProcessEventType,
type ProcessNode,
type ProcessTreeReconstruction,
} from "../src/domain/eventProcessTree.js";
describe("event-backed process tree capture", () => {
it("reconstructs a simple process tree", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 1,
at_ms: 10,
type: "spawn",
pid: 2,
ppid: 1,
process_name: "child",
executable: "/child",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 2,
at_ms: 20,
type: "exit",
pid: 2,
ppid: 1,
process_name: "child",
executable: null,
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: 0,
signal: null,
tid: null,
},
{
sequence: 3,
at_ms: 30,
type: "exit",
pid: 1,
ppid: 0,
process_name: "root",
executable: null,
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: 0,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(result.processes).toHaveLength(2);
expect(result.events_consumed).toBe(4);
expect(result.events_unmatched).toBe(0);
expect(result.short_lived_descendants).toBe(2);
});
it("detects short-lived descendants", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 1,
at_ms: 10,
type: "spawn",
pid: 2,
ppid: 1,
process_name: "short",
executable: "/short",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 2,
at_ms: 11,
type: "exit",
pid: 2,
ppid: 1,
process_name: "short",
executable: null,
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: 0,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(result.short_lived_descendants).toBe(1);
});
it("detects re-exec", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 1,
at_ms: 10,
type: "re_exec",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/newroot",
arguments: ["--new"],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(result.has_re_exec).toBe(true);
const root = result.processes.find((p) => p.pid === 1)!;
expect(root.is_re_exec).toBe(true);
expect(root.executable).toBe("/newroot");
});
it("detects reparenting", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 1,
at_ms: 10,
type: "spawn",
pid: 2,
ppid: 1,
process_name: "child",
executable: "/child",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 2,
at_ms: 20,
type: "reparent",
pid: 2,
ppid: 0,
process_name: "child",
executable: null,
arguments: [],
previous_pid: null,
previous_ppid: 1,
exit_code: null,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(result.has_reparenting).toBe(true);
const child = result.processes.find((p) => p.pid === 2)!;
expect(child.is_reparented).toBe(true);
expect(child.previous_ppid).toBe(1);
expect(child.ppid).toBe(0);
});
it("reports unmatched events for unknown exits", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "exit",
pid: 999,
ppid: null,
process_name: null,
executable: null,
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: 1,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(result.events_unmatched).toBe(1);
expect(result.processes).toHaveLength(0);
});
it("gets descendants", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 1,
at_ms: 10,
type: "spawn",
pid: 2,
ppid: 1,
process_name: "child1",
executable: "/c1",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 2,
at_ms: 20,
type: "spawn",
pid: 3,
ppid: 2,
process_name: "grandchild",
executable: "/gc",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
const descendants = getDescendants(result.processes, 1);
expect(descendants).toHaveLength(2);
expect(descendants).toContain(2);
expect(descendants).toContain(3);
});
it("checks process containment", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(containsProcess(result.processes, 1)).toBe(true);
expect(containsProcess(result.processes, 999)).toBe(false);
});
it("computes max tree depth", () => {
const events: ProcessTreeEvent[] = [
{
sequence: 0,
at_ms: 0,
type: "spawn",
pid: 1,
ppid: 0,
process_name: "root",
executable: "/root",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 1,
at_ms: 10,
type: "spawn",
pid: 2,
ppid: 1,
process_name: "child",
executable: "/c",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
{
sequence: 2,
at_ms: 20,
type: "spawn",
pid: 3,
ppid: 2,
process_name: "gc",
executable: "/gc",
arguments: [],
previous_pid: null,
previous_ppid: null,
exit_code: null,
signal: null,
tid: null,
},
];
const result = reconstructProcessTree(events, 1);
expect(maxTreeDepth(result.processes, 1)).toBe(2);
});
it("uses exported types", () => {
const node: ProcessNode = {
pid: 1,
ppid: null,
process_name: "test",
executable: "/test",
arguments: [],
children: [],
spawn_time_ms: 0,
exit_time_ms: null,
exit_code: null,
is_re_exec: false,
is_reparented: false,
previous_ppid: null,
};
expect(node.pid).toBe(1);
const recon: ProcessTreeReconstruction = {
root_pid: 1,
processes: [node],
events_consumed: 1,
events_unmatched: 0,
short_lived_descendants: 0,
has_re_exec: false,
has_reparenting: false,
};
expect(recon.root_pid).toBe(1);
});
it("uses ProcessEventType", () => {
const t: ProcessEventType = "spawn";
expect(t).toBe("spawn");
});
});