mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
fix(process): parse native teardown relation sentinels
This commit is contained in:
1 parent
524b84792f
commit
6c4d880f2d
7 files changed
+127
-49
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user