fix(process): gate sampling on initialized PTY roots

This commit is contained in:
morluto 2026-07-14 22:22:51 +00:00
commit e831a41375
4 changed files with 151 additions and 19 deletions

View File

@ -245,12 +245,13 @@ const startCaptureRuntime = async (options: {
renderer,
started,
});
const stopSampler = startProcessSampler(
terminal.pid,
const stopSampler = startProcessSampler({
rootPid: terminal.pid,
runId: options.runId,
started,
scenario.limits.processes,
options.samples,
);
limit: scenario.limits.processes,
samples: options.samples,
});
return {
replay,
renderer,

View File

@ -121,6 +121,13 @@ const systemHost: ProcessOwnershipHost = {
},
};
/** Read the capture run token currently exposed by one live process. */
export const readProcessRunId = async (
pid: number,
host: ProcessOwnershipHost = systemHost,
): Promise<string | undefined> =>
(await host.environment(pid)).REA_PROCESS_RUN_ID;
/**
* Verify run-token ownership before signaling a POSIX process group.
*

View File

@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import { promisify } from "node:util";
import type { ProcessSample } from "../domain/processCapture.js";
import { readProcessRunId } from "./ProcessOwnership.js";
const execFileAsync = promisify(execFile);
const PROC_READ_CONCURRENCY = 64;
@ -27,6 +28,29 @@ interface SampleProcessContext {
readonly signal: AbortSignal;
}
type ProcessIdentitySnapshot = Pick<
ProcessRow,
"pid" | "parent_pid" | "process_group_id" | "session_id" | "startTime"
>;
/** Prove that forkpty completed session setup and installed the run token. */
export const isInitializedPtyRoot = (options: {
readonly rootPid: number;
readonly expectedRunId: string;
readonly before: ProcessIdentitySnapshot;
readonly observedRunId: string | undefined;
readonly after: ProcessIdentitySnapshot;
}): boolean =>
options.before.pid === options.rootPid &&
options.before.process_group_id === options.rootPid &&
options.before.session_id === options.rootPid &&
options.after.pid === options.rootPid &&
options.after.parent_pid === options.before.parent_pid &&
options.after.process_group_id === options.rootPid &&
options.after.session_id === options.rootPid &&
options.after.startTime === options.before.startTime &&
options.observedRunId === options.expectedRunId;
const isExpectedProcReadFailure = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
@ -305,6 +329,47 @@ const readPsRows = async (
.sort((left, right) => left.pid - right.pid);
};
const readRootIdentity = async (
rootPid: number,
signal: AbortSignal,
): Promise<ProcessRow | undefined> =>
process.platform === "linux"
? readProcessStat(rootPid, signal)
: (await readPsRows(signal)).find(({ pid }) => pid === rootPid);
const inspectInitializedPtyRoot = async (
rootPid: number,
runId: string,
signal: AbortSignal,
): Promise<{ readonly startTime: string | undefined } | undefined> => {
const before = await readRootIdentity(rootPid, signal);
if (
before === undefined ||
before.process_group_id !== rootPid ||
before.session_id !== rootPid
)
return undefined;
let observedRunId: string | undefined;
try {
observedRunId = await readProcessRunId(rootPid);
} catch {
return undefined;
}
const after = await readRootIdentity(rootPid, signal);
if (
after === undefined ||
!isInitializedPtyRoot({
rootPid,
expectedRunId: runId,
before,
observedRunId,
after,
})
)
return undefined;
return { startTime: after.startTime };
};
const samplePs = async (
context: SampleProcessContext,
): Promise<readonly ProcessRow[]> => {
@ -375,31 +440,47 @@ const sampleProcesses = async (
};
/** Start bounded process-tree sampling and expose an awaited stop. */
export const startProcessSampler = (
rootPid: number,
started: number,
limit: number,
samples: ProcessSample[],
): (() => Promise<{ readonly partial: boolean }>) => {
export const startProcessSampler = (options: {
readonly rootPid: number;
readonly runId: string;
readonly started: number;
readonly limit: number;
readonly samples: ProcessSample[];
}): (() => Promise<{ readonly partial: boolean }>) => {
const { rootPid, runId, started, limit, samples } = options;
const identities = new Map<number, string>();
const lastObservations = new Map<number, string>();
let pending: Promise<void> | undefined;
let stopped = false;
let abortCurrent: (() => void) | undefined;
let partial = false;
let rootInitialized = false;
const sample = (): void => {
if (stopped || pending !== undefined) return;
const controller = new AbortController();
abortCurrent = () => controller.abort();
pending = sampleProcesses({
rootPid,
elapsedMs: Date.now() - started,
limit: limit + 1,
identities,
signal: controller.signal,
})
pending = (async () => {
if (!rootInitialized) {
const identity = await inspectInitializedPtyRoot(
rootPid,
runId,
controller.signal,
);
if (identity === undefined) return [];
if (identity.startTime !== undefined)
identities.set(rootPid, identity.startTime);
rootInitialized = true;
}
return sampleProcesses({
rootPid,
elapsedMs: Date.now() - started,
limit: limit + 1,
identities,
signal: controller.signal,
});
})()
.then((values) => {
for (const value of values) {
const observation = JSON.stringify({

View File

@ -19,7 +19,10 @@ import {
} from "../src/application/ProcessCaptureLifecycle.js";
import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js";
import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js";
import { readLinuxChildren } from "../src/application/ProcessSampling.js";
import {
isInitializedPtyRoot,
readLinuxChildren,
} from "../src/application/ProcessSampling.js";
import { TerminalRenderer } from "../src/application/TerminalRenderer.js";
import {
authorizeProcessScenario,
@ -211,6 +214,43 @@ describe("process capture domain", () => {
).toEqual([201, 202, 203]);
});
it("admits PTY samples only after stable session and token setup", () => {
const initialized = {
pid: 100,
parent_pid: 10,
process_group_id: 100,
session_id: 100,
startTime: "200",
};
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: { ...initialized, process_group_id: 10, session_id: 10 },
observedRunId: undefined,
after: initialized,
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: { ...initialized, startTime: "201" },
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: initialized,
}),
).toBe(true);
});
it("keeps interaction and shim residual uncertainty in separate scopes", () => {
const baseCapture = emptyCapture();
const interaction = parseProcessCapture({
@ -1134,6 +1174,9 @@ describe("process capture adapter", () => {
expect(commands.some((command) => command.includes("tree-child"))).toBe(
true,
);
expect(commands.some((command) => command.includes("forks.js"))).toBe(
false,
);
expect(
commands.some((command) => command.includes("tree-grandchild")),
).toBe(true);