fix(process): gate sampling on initialized PTY root
This commit is contained in:
parent
072ce5aede
commit
57938a8465
|
|
@ -243,12 +243,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,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue