mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user