From 6c4d880f2dd9ca7d42593be916f90ec19b8a09d7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:10:01 +0800 Subject: [PATCH] fix(process): parse native teardown relation sentinels --- src/process/LinuxProcessStat.test.ts | 29 +++++++++ src/process/LinuxProcessStat.ts | 7 ++- .../ProcessOwnership.procIdentity.test.ts | 15 +++++ src/process/ProcessOwnershipObservation.ts | 16 +++-- src/process/capture/LinuxProcessSampling.ts | 46 ++++++--------- .../ProcessSampling.procReadCoverage.test.ts | 59 +++++++++++++++---- .../process/processTruncationAdapters.test.ts | 4 +- 7 files changed, 127 insertions(+), 49 deletions(-) diff --git a/src/process/LinuxProcessStat.test.ts b/src/process/LinuxProcessStat.test.ts index d922714e1c..c195baf352 100644 --- a/src/process/LinuxProcessStat.test.ts +++ b/src/process/LinuxProcessStat.test.ts @@ -47,6 +47,35 @@ it("accepts zero parent, process-group, and session IDs as native facts", () => ).toMatchObject({ parentPid: 0, processGroupId: 0, sessionId: 0 }); }); +it("retains identity when Linux teardown reports unavailable group and session IDs", () => { + // Linux v6.8 fs/proc/array.c: do_task_stat initializes pgid/sid to -1 + // and only replaces them while lock_task_sighand succeeds. + expect( + parseLinuxProcessStat( + statRecord({ + parentPid: "0", + processGroupId: "-1", + sessionId: "-1", + state: "X", + flags: "4", + }), + ), + ).toEqual({ + pid: 42, + startTime: "987654", + lifecycle: "dead", + parentPid: 0, + processGroupId: -1, + sessionId: -1, + }); + expect( + parseLinuxProcessStat(statRecord({ processGroupId: "-2" })), + ).toBeUndefined(); + expect( + parseLinuxProcessStat(statRecord({ sessionId: "-2" })), + ).toBeUndefined(); +}); + it("distinguishes zombie lifecycle and rejects malformed or missing identity fields", () => { expect(parseLinuxProcessStat(statRecord({ state: "Z" }))?.lifecycle).toBe( "zombie", diff --git a/src/process/LinuxProcessStat.ts b/src/process/LinuxProcessStat.ts index d255b74936..02dd1aaed4 100644 --- a/src/process/LinuxProcessStat.ts +++ b/src/process/LinuxProcessStat.ts @@ -13,7 +13,8 @@ export interface LinuxProcessStat { /** * Parse the changing command field by its final `)`, then validate the native * identity and relation fields. Zero is valid for the native group/session - * facts; callers may impose narrower admission rules for their own purpose. + * facts; -1 for group/session means lock_task_sighand could not supply them + * during teardown. Preserve these sentinels separately from the start identity. */ export const parseLinuxProcessStat = ( value: string, @@ -42,9 +43,9 @@ export const parseLinuxProcessStat = ( parentPidText === undefined || !/^\d+$/u.test(parentPidText) || processGroupIdText === undefined || - !/^\d+$/u.test(processGroupIdText) || + !/^(?:-1|\d+)$/u.test(processGroupIdText) || sessionIdText === undefined || - !/^\d+$/u.test(sessionIdText) || + !/^(?:-1|\d+)$/u.test(sessionIdText) || startTime === undefined || !/^\d+$/u.test(startTime) ) diff --git a/src/process/ProcessOwnership.procIdentity.test.ts b/src/process/ProcessOwnership.procIdentity.test.ts index fd519081d6..1fe4c05369 100644 --- a/src/process/ProcessOwnership.procIdentity.test.ts +++ b/src/process/ProcessOwnership.procIdentity.test.ts @@ -351,6 +351,21 @@ it("keeps hidden or permission-denied live PIDs unavailable", async () => { } }); +it("preserves native identity with unavailable teardown relations", async () => { + const host = createSystemProcessOwnershipHost("linux", {}); + filesystem.readFile.mockResolvedValueOnce(procStat(987654, 0, -1, 42, 4)); + const identities = await host.processIdentities?.([entry]); + expect(identities?.get(42)).toMatchObject({ + state: "readable", + identity: "linux-starttime:987654", + lifecycle: { state: "observed", lifecycle: "exiting" }, + relation: { + state: "unavailable", + reason: "proc_stat_relation_unavailable", + }, + }); +}); + it("keeps permission, parser, and unexpected failures unavailable", async () => { const host = createSystemProcessOwnershipHost("linux", {}); const signalProbe = vi.spyOn(process, "kill"); diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 15023e45fc..057cacc855 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1055,11 +1055,17 @@ export const createSystemProcessOwnershipHost = ( state: "observed" as const, lifecycle: facts.lifecycle, }, - relation: { - state: "observed" as const, - parentPid: facts.parentPid, - processGroupId: facts.processGroupId, - }, + relation: + facts.processGroupId < 0 + ? { + state: "unavailable" as const, + reason: "proc_stat_relation_unavailable", + } + : { + state: "observed" as const, + parentPid: facts.parentPid, + processGroupId: facts.processGroupId, + }, }); if (!credentialPids?.has(pid)) diff --git a/src/process/capture/LinuxProcessSampling.ts b/src/process/capture/LinuxProcessSampling.ts index 1e3800455e..c847d2d729 100644 --- a/src/process/capture/LinuxProcessSampling.ts +++ b/src/process/capture/LinuxProcessSampling.ts @@ -24,28 +24,6 @@ export interface SampledProcessNode extends SampledProcessRow { readonly children: readonly number[] | undefined; } -const parseProcStat = ( - pid: number, - stat: string, -): SampledProcessRow | undefined => { - const parsed = parseLinuxProcessStat(stat); - if ( - parsed === undefined || - parsed.pid !== pid || - parsed.processGroupId <= 0 || - parsed.sessionId <= 0 - ) - return undefined; - return { - pid: parsed.pid, - parent_pid: parsed.parentPid, - process_group_id: parsed.processGroupId, - session_id: parsed.sessionId, - command: "", - startTime: parsed.startTime, - }; -}; - export const readLinuxProcessStat = async ( pid: number, signal: AbortSignal, @@ -56,13 +34,27 @@ export const readLinuxProcessStat = async ( ); if (read.value === undefined) return samplingObservation(undefined, read.diagnostics); - const row = parseProcStat(pid, read.value); + const parsed = parseLinuxProcessStat(read.value); + if (parsed === undefined || parsed.pid !== pid) + return samplingObservation( + undefined, + read.diagnostics, + samplingFailure(`${path}: malformed process identity`), + ); + // A valid stat record can outlive its group/session relation during teardown. + // It cannot supply an attributable sample, but is not a parser failure. + if (parsed.processGroupId <= 0 || parsed.sessionId <= 0) + return samplingObservation(undefined, read.diagnostics); return samplingObservation( - row, + { + pid: parsed.pid, + parent_pid: parsed.parentPid, + process_group_id: parsed.processGroupId, + session_id: parsed.sessionId, + command: "", + startTime: parsed.startTime, + }, read.diagnostics, - ...(row === undefined - ? [samplingFailure(`${path}: malformed process identity`)] - : []), ); }; diff --git a/src/process/capture/ProcessSampling.procReadCoverage.test.ts b/src/process/capture/ProcessSampling.procReadCoverage.test.ts index e3e425c38b..078f7fd265 100644 --- a/src/process/capture/ProcessSampling.procReadCoverage.test.ts +++ b/src/process/capture/ProcessSampling.procReadCoverage.test.ts @@ -272,7 +272,7 @@ const waitFor = async (condition: () => boolean): Promise => { } }; -it("rejects a mismatched stat PID and zero process-group/session IDs at sampling admission", async () => { +it("rejects a mismatched stat PID at sampling admission", async () => { setUpFixture(); fixture.statMutations.set(100, { read: 1, pid: 200 }); const wrongPid = await readLinuxProcessStat( @@ -280,20 +280,30 @@ it("rejects a mismatched stat PID and zero process-group/session IDs at sampling new AbortController().signal, ); expect(wrongPid.value).toBeUndefined(); - - setUpFixture(); - fixture.statMutations.set(100, { - read: 1, - processGroupId: 0, - sessionId: 0, + expect(wrongPid.diagnostics).toMatchObject({ + count: 1, + first: "/proc/100/stat: malformed process identity", }); - const zeroGroups = await readLinuxProcessStat( - 100, - new AbortController().signal, - ); - expect(zeroGroups.value).toBeUndefined(); }); +it.each([0, -1])( + "does not classify native unavailable relations (%s) as malformed identity", + async (relation) => { + setUpFixture(); + fixture.statMutations.set(100, { + read: 1, + processGroupId: relation, + sessionId: relation, + }); + const observed = await readLinuxProcessStat( + 100, + new AbortController().signal, + ); + expect(observed.value).toBeUndefined(); + expect(observed.diagnostics).toMatchObject({ count: 0, first: null }); + }, +); + const flushImmediateProcPass = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); @@ -578,6 +588,31 @@ it.each([ }, ); +it.each([0, -1])( + "keeps completed samples without false failures when root relations disappear (%s)", + async (relation) => { + setUpFixture(); + fixture.children.set("100:100", ""); + // Reads 1/2 establish PTY readiness; 3/4 bracket the retained root sample. + // The next pass sees a valid kernel record whose relations are unavailable. + fixture.statMutations.set(100, { + read: 5, + processGroupId: relation, + sessionId: relation, + }); + const { samples, stop } = start(); + await waitFor(() => (fixture.statReads.get(100) ?? 0) >= 5); + await flushImmediateProcPass(); + const result = await stop(); + expect(samples.map(({ pid }) => pid)).toEqual([100]); + expect(result.coverage).toMatchObject({ + sampling_failures: 0, + first_sampling_failure: null, + }); + expect(result.partial).toBe(false); + }, +); + it("marks a completed clean pass as neither partial nor interrupted", async () => { setUpFixture(); const { samples, stop } = start(); diff --git a/tests/boundary/process/processTruncationAdapters.test.ts b/tests/boundary/process/processTruncationAdapters.test.ts index 0a9cf7d9f6..3fab345416 100644 --- a/tests/boundary/process/processTruncationAdapters.test.ts +++ b/tests/boundary/process/processTruncationAdapters.test.ts @@ -273,13 +273,13 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( ], limits: { processes: 1 }, }); + // Sampling diagnostics are independent of retention exhaustion. The capture + // parser above validates their count/reason consistency on this live host. expect(result.truncation_details?.process).toMatchObject({ sample_limit: 1, sampling_partial: true, retained_samples: 1, sample_limit_reached: true, - sampling_failures: 0, - first_sampling_failure: null, coverage: "sampled", }); expect(compareProcessCaptures(result, result)).toMatchObject({