From 57938a8465add2d6e89dae13e66059bad7f72a45 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:55:38 +0000 Subject: [PATCH] fix(process): gate sampling on initialized PTY root --- src/application/ProcessHarness.ts | 11 +-- src/application/ProcessOwnership.ts | 7 ++ src/application/ProcessSampling.ts | 107 ++++++++++++++++++++++++---- tests/processCapture.test.ts | 45 +++++++++++- 4 files changed, 151 insertions(+), 19 deletions(-) diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 7cc48b33e..d706704f8 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -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, diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts index 903dffcda..723197dd5 100644 --- a/src/application/ProcessOwnership.ts +++ b/src/application/ProcessOwnership.ts @@ -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 => + (await host.environment(pid)).REA_PROCESS_RUN_ID; + /** * Verify run-token ownership before signaling a POSIX process group. * diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts index 29bca043a..9c2aecf4b 100644 --- a/src/application/ProcessSampling.ts +++ b/src/application/ProcessSampling.ts @@ -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 => + 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 => { @@ -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(); const lastObservations = new Map(); let pending: Promise | 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({ diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index d1b5b3047..6309d9c24 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -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);