diff --git a/src/domain/eventProcessTree.ts b/src/domain/eventProcessTree.ts new file mode 100644 index 0000000000..926de825fd --- /dev/null +++ b/src/domain/eventProcessTree.ts @@ -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; + +/** 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; + +/** 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; + +/** 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(); + const children = new Map(); + 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(); + 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 { + 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()); +} diff --git a/tests/eventProcessTree.test.ts b/tests/eventProcessTree.test.ts new file mode 100644 index 0000000000..3bb36e8f2f --- /dev/null +++ b/tests/eventProcessTree.test.ts @@ -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"); + }); +});