fix(process): parse native teardown relation sentinels

This commit is contained in:
morluto committed 2026-10-23 16:10:04 +08:00
1 parent 524b84792f
commit 6c4d880f2d
7 files changed
+127 -49

No files matched your search

+29
View File
@@ -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",
+4 -3
View File
@@ -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)
)
@@ -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");
+11 -5
View File
@@ -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))
+19 -27
View File
@@ -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`)]
: []),
);
};
@@ -272,7 +272,7 @@ const waitFor = async (condition: () => boolean): Promise<void> => {
}
};
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<void> => {
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();
@@ -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({