mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(process): retain unresolved descendants during lineage observation (#5270)
* fix(process): retain unresolved descendants during lineage observation * fix(process): preserve lineage across ancestor generation changes * fix(process): avoid retaining replacements of exited candidates * test(cli): avoid unused cache writes in module trace acceptance * fix(process): retire replaced lineage candidates * fix(process): preserve uncertain owners and validate launcher scope
This commit is contained in:
7 files changed
+1645
-58
No files matched your search
@@ -342,6 +342,98 @@ it.each(["omitted", "throws"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a matching-token replacement through omission and retries cleanup only with fresh identity proof", async () => {
|
||||
const row: ProcessTableEntry = {
|
||||
pid: 913,
|
||||
parentPid: 1,
|
||||
processGroupId: 913,
|
||||
state: "S",
|
||||
command: "replacement-owner",
|
||||
};
|
||||
let listed = true;
|
||||
let replaced = false;
|
||||
let identityUnavailable = false;
|
||||
let live = true;
|
||||
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
|
||||
const signalGroup = vi.fn(() => {
|
||||
live = false;
|
||||
});
|
||||
const host: ProcessOwnershipHost = {
|
||||
platform: "linux",
|
||||
listProcesses: async () => (listed && live ? [row] : []),
|
||||
environment: async () => ({}),
|
||||
processIdentities: async (entries) =>
|
||||
new Map(
|
||||
entries.map((entry) => [
|
||||
entry.pid,
|
||||
!live
|
||||
? { state: "absent" as const, reason: "not-in-snapshot" as const }
|
||||
: identityUnavailable
|
||||
? { state: "unavailable" as const, reason: "native read denied" }
|
||||
: observedIdentity(entry, replaced ? "new-owner" : "old-owner"),
|
||||
]),
|
||||
),
|
||||
runTokens: async (entries) => {
|
||||
// The token belongs to the replacement, but the surrounding identity
|
||||
// reads straddle reuse and therefore cannot yet grant signal authority.
|
||||
replaced = true;
|
||||
return new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable" as const, runId: "run-token" },
|
||||
]),
|
||||
);
|
||||
},
|
||||
signalGroup,
|
||||
};
|
||||
const ownership = {
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
evidenceOwner,
|
||||
};
|
||||
|
||||
await expect(
|
||||
verifyNoTokenOwnedProcesses("run-token", host, undefined, ownership),
|
||||
).resolves.toMatchObject({ cleaned: false });
|
||||
listed = false;
|
||||
identityUnavailable = true;
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership, host),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
failures: [{ pid: row.pid, reason: "process-identity-unavailable" }],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
expect(live).toBe(true);
|
||||
expect(evidenceOwner.candidates).toMatchObject([
|
||||
{ pid: row.pid, identity: undefined, applicability: "identity-unresolved" },
|
||||
]);
|
||||
|
||||
identityUnavailable = false;
|
||||
await expect(
|
||||
verifyNoTokenOwnedProcesses("run-token", host, undefined, ownership),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "token-owned process remained after cleanup",
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
listed = true;
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership, host),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: true,
|
||||
signaled: true,
|
||||
});
|
||||
expect(signalGroup).toHaveBeenCalledExactlyOnceWith(
|
||||
row.processGroupId,
|
||||
"SIGKILL",
|
||||
);
|
||||
expect(live).toBe(false);
|
||||
expect(evidenceOwner.candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains a scoped unknown generation when a later table omits its PID", async () => {
|
||||
const row: ProcessTableEntry = {
|
||||
pid: 101,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,7 @@ export interface RetainedProcessOwnershipCandidate {
|
||||
| "capture-leader-pid"
|
||||
| "captured-process-group-id"
|
||||
| "native-identity-unresolved"
|
||||
| "unverified-table-relation"
|
||||
| "previously-related-generation";
|
||||
readonly token: ProcessRunTokenObservation;
|
||||
readonly relation:
|
||||
@@ -81,6 +82,15 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
readonly processGroupIds: ReadonlySet<number>;
|
||||
/** First identity/token/identity result before bounded token-only retries. */
|
||||
readonly scopeFacts?: readonly ProcessOwnershipFact[];
|
||||
/** Native exit settled before candidate selection; these PIDs have no facts. */
|
||||
readonly nativeExitedPids?: ReadonlySet<number>;
|
||||
/** False disables disproven capture anchors without discarding token-owned ancestry. */
|
||||
readonly allowCapturedScope?: boolean;
|
||||
/** Caller-selected lineage links preserve uncertain scope, never ownership proof. */
|
||||
readonly unverifiedLineageRelations?: ReadonlyMap<
|
||||
number,
|
||||
Pick<ProcessTableEntry, "parentPid" | "processGroupId">
|
||||
>;
|
||||
},
|
||||
): readonly RetainedProcessOwnershipCandidate[] {
|
||||
this.#snapshotCount += 1;
|
||||
@@ -126,33 +136,58 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
"run-token" | "previously-related-generation"
|
||||
>
|
||||
| undefined => {
|
||||
const identity = generations.get(pid);
|
||||
if (identity === undefined) return undefined;
|
||||
const relation =
|
||||
identity.relation?.state === "observed" ? identity.relation : undefined;
|
||||
let unverifiedRelation = false;
|
||||
const relationFor = (candidatePid: number) => {
|
||||
const identity = generations.get(candidatePid);
|
||||
if (identity?.relation?.state === "observed") return identity.relation;
|
||||
const fallback = options.unverifiedLineageRelations?.get(candidatePid);
|
||||
if (fallback === undefined) return undefined;
|
||||
const current = sameGeneration.get(candidatePid);
|
||||
// Only a same-generation native relation can disprove the earlier
|
||||
// scope. A replacement or unproven generation must not sever the
|
||||
// retained ancestry of a surviving descendant.
|
||||
if (current?.relation?.state === "observed") {
|
||||
if (identity === undefined) unverifiedRelation = true;
|
||||
return current.relation;
|
||||
}
|
||||
unverifiedRelation = true;
|
||||
return fallback;
|
||||
};
|
||||
const scoped = (
|
||||
evidence:
|
||||
| "captured-process-group-id"
|
||||
| "capture-leader-pid"
|
||||
| "current-token-owned-parent",
|
||||
) =>
|
||||
unverifiedRelation ? ("unverified-table-relation" as const) : evidence;
|
||||
const relation = relationFor(pid);
|
||||
if (relation === undefined) return undefined;
|
||||
if (options.processGroupIds.has(relation.processGroupId))
|
||||
return "captured-process-group-id";
|
||||
if (
|
||||
options.allowCapturedScope !== false &&
|
||||
options.processGroupIds.has(relation.processGroupId)
|
||||
)
|
||||
return scoped("captured-process-group-id");
|
||||
const visited = new Set<number>([pid]);
|
||||
let parentPid = relation.parentPid;
|
||||
while (!visited.has(parentPid)) {
|
||||
const parent = generations.get(parentPid);
|
||||
if (parent === undefined) {
|
||||
return parentPid === options.leaderPid
|
||||
? "capture-leader-pid"
|
||||
: undefined;
|
||||
}
|
||||
const previousParent = this.#candidates.get(parentPid);
|
||||
if (
|
||||
tokenOwners.has(parentPid) ||
|
||||
(previousParent?.applicability === "token-verified" &&
|
||||
parent.identity === previousParent.identity)
|
||||
parent !== undefined &&
|
||||
(tokenOwners.has(parentPid) ||
|
||||
(previousParent?.applicability === "token-verified" &&
|
||||
parent.identity === previousParent.identity))
|
||||
)
|
||||
return "current-token-owned-parent";
|
||||
if (parentPid === options.leaderPid) return "capture-leader-pid";
|
||||
return scoped("current-token-owned-parent");
|
||||
if (
|
||||
options.allowCapturedScope !== false &&
|
||||
parentPid === options.leaderPid
|
||||
)
|
||||
return scoped("capture-leader-pid");
|
||||
visited.add(parentPid);
|
||||
if (parent.relation?.state !== "observed") return undefined;
|
||||
parentPid = parent.relation.parentPid;
|
||||
const parentRelation = relationFor(parentPid);
|
||||
if (parentRelation === undefined) return undefined;
|
||||
parentPid = parentRelation.parentPid;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -215,19 +250,27 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
};
|
||||
|
||||
for (const [pid, previous] of this.#candidates) {
|
||||
// A bounded native exit also retires an unresolved prior generation,
|
||||
// even when the caller excludes this PID from subsequent token reads.
|
||||
if (options.nativeExitedPids?.has(pid)) {
|
||||
this.#candidates.delete(pid);
|
||||
retiredPids.add(pid);
|
||||
continue;
|
||||
}
|
||||
const fact = factsByPid.get(pid);
|
||||
if (fact === undefined) continue;
|
||||
// Retire only this generation's current ledger entry. Darwin's
|
||||
// `not-in-snapshot` and Linux's corroborated ENOENT/ESRCH facts are
|
||||
// bounded observations, not claims about later PID reuse or global
|
||||
// liveness.
|
||||
// liveness. A bracketed replacement also settles an identity-less
|
||||
// candidate: its earlier lifetime has ended, regardless of relation access.
|
||||
if (
|
||||
fact.state === "absent" ||
|
||||
fact.state === "replaced" ||
|
||||
(previous.identity !== undefined &&
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.identity !== previous.identity) ||
|
||||
(fact.state === "same-generation" &&
|
||||
processIdentityHasExited(fact.identityAfter))
|
||||
processIdentityHasExited(fact.identityAfter)
|
||||
) {
|
||||
this.#candidates.delete(pid);
|
||||
retiredPids.add(pid);
|
||||
@@ -286,16 +329,30 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
|
||||
for (const fact of facts) {
|
||||
const pid = fact.process.pid;
|
||||
const matched =
|
||||
fact.token.state === "readable" && fact.token.runId === options.runId;
|
||||
const unresolvedReplacement = fact.state === "replaced" && matched;
|
||||
// A replacement's token cannot be attributed to either generation.
|
||||
// A matching token preserves uncertainty for another native observation,
|
||||
// without carrying the retired generation's identity or signal authority.
|
||||
if (
|
||||
this.#candidates.has(pid) ||
|
||||
(retiredPids.has(pid) && fact.state !== "same-generation") ||
|
||||
(retiredPids.has(pid) &&
|
||||
fact.state !== "same-generation" &&
|
||||
!unresolvedReplacement) ||
|
||||
fact.state === "absent" ||
|
||||
(fact.state === "same-generation" &&
|
||||
processIdentityHasExited(fact.identityAfter))
|
||||
(fact.state === "replaced" && !matched) ||
|
||||
processIdentityHasExited(fact.identityAfter)
|
||||
)
|
||||
continue;
|
||||
const identity = fact.identityAfter;
|
||||
if (fact.state !== "same-generation" || identity.state !== "readable") {
|
||||
if (
|
||||
options.allowCapturedScope === false &&
|
||||
!matched &&
|
||||
related(pid) === undefined
|
||||
)
|
||||
continue;
|
||||
this.#candidates.set(pid, {
|
||||
pid,
|
||||
identity: undefined,
|
||||
@@ -311,8 +368,6 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
uncertainPids.add(pid);
|
||||
continue;
|
||||
}
|
||||
const matched =
|
||||
fact.token.state === "readable" && fact.token.runId === options.runId;
|
||||
const unresolvedScope = matched ? undefined : related(pid);
|
||||
if (!matched && unresolvedScope === undefined) continue;
|
||||
const relation =
|
||||
|
||||
@@ -21,7 +21,7 @@ const row: ProcessTableEntry = {
|
||||
|
||||
const identity = (
|
||||
value: string,
|
||||
lifecycle: "non-zombie" | "zombie" = "non-zombie",
|
||||
lifecycle: "non-zombie" | "zombie" | "dead" = "non-zombie",
|
||||
relation: ProcessNativeRelationObservation = {
|
||||
state: "observed",
|
||||
parentPid: row.parentPid,
|
||||
@@ -80,18 +80,184 @@ it("binds owned, missing, and foreign tokens only to the same identity generatio
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a matching token as partial evidence when identity changes", async () => {
|
||||
it("retains a token-matching replacement without authority until same-generation validation", async () => {
|
||||
const host = hostFor([
|
||||
new Map([[row.pid, identity("start:old")]]),
|
||||
new Map([[row.pid, identity("start:new")]]),
|
||||
]);
|
||||
const [fact] = await readProcessOwnershipFacts([row], host);
|
||||
expect(fact).toMatchObject({
|
||||
const facts = await readProcessOwnershipFacts([row], host);
|
||||
expect(facts[0]).toMatchObject({
|
||||
state: "replaced",
|
||||
token: { state: "readable", runId: "run" },
|
||||
});
|
||||
const owner = new ProcessOwnershipEvidenceOwner();
|
||||
const options = {
|
||||
runId: "run",
|
||||
listedRows: 1,
|
||||
listedPids: new Set([row.pid]),
|
||||
leaderPid: row.pid,
|
||||
processGroupIds: new Set([row.processGroupId]),
|
||||
};
|
||||
expect(owner.reconcile(facts, options)).toMatchObject([
|
||||
{
|
||||
pid: row.pid,
|
||||
identity: undefined,
|
||||
applicability: "identity-unresolved",
|
||||
scopeEvidence: "native-identity-unresolved",
|
||||
relation: undefined,
|
||||
},
|
||||
]);
|
||||
expect(owner.coverage).toMatchObject({
|
||||
retainedOwned: 0,
|
||||
retainedUnresolved: 1,
|
||||
uncertainPids: [row.pid],
|
||||
});
|
||||
expect(owner.rowsForObservation([])).toEqual([row]);
|
||||
const stable = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor([
|
||||
new Map([[row.pid, identity("start:new")]]),
|
||||
new Map([[row.pid, identity("start:new")]]),
|
||||
]),
|
||||
);
|
||||
expect(owner.reconcile(stable, options)).toMatchObject([
|
||||
{ pid: row.pid, identity: "start:new", applicability: "token-verified" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["token-verified", "identity-unresolved"] as const)(
|
||||
"retains a token-matching replacement after retiring a %s candidate",
|
||||
async (applicability) => {
|
||||
const owner = new ProcessOwnershipEvidenceOwner();
|
||||
const options = {
|
||||
runId: "run",
|
||||
listedRows: 1,
|
||||
listedPids: new Set([row.pid]),
|
||||
leaderPid: row.pid,
|
||||
processGroupIds: new Set([row.processGroupId]),
|
||||
};
|
||||
const previousIdentity: ProcessIdentityObservation =
|
||||
applicability === "token-verified"
|
||||
? identity("start:old")
|
||||
: { state: "unavailable", reason: "native identity denied" };
|
||||
const previous = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor([
|
||||
new Map([[row.pid, previousIdentity]]),
|
||||
new Map([[row.pid, previousIdentity]]),
|
||||
]),
|
||||
);
|
||||
expect(owner.reconcile(previous, options)).toMatchObject([
|
||||
{ pid: row.pid, applicability },
|
||||
]);
|
||||
|
||||
const replacement = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor([
|
||||
new Map([[row.pid, identity("start:old")]]),
|
||||
new Map([[row.pid, identity("start:new")]]),
|
||||
]),
|
||||
);
|
||||
expect(owner.reconcile(replacement, options)).toMatchObject([
|
||||
{
|
||||
pid: row.pid,
|
||||
identity: undefined,
|
||||
applicability: "identity-unresolved",
|
||||
relation: undefined,
|
||||
},
|
||||
]);
|
||||
expect(owner.rowsForObservation([])).toEqual([row]);
|
||||
expect(owner.coverage).toMatchObject({
|
||||
retainedOwned: 0,
|
||||
retainedUnresolved: 1,
|
||||
uncertainPids: [row.pid],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["token-verified", "identity-unresolved"] as const)(
|
||||
"retires a %s candidate when its replacement has a foreign token",
|
||||
async (applicability) => {
|
||||
const owner = new ProcessOwnershipEvidenceOwner();
|
||||
const options = {
|
||||
runId: "run",
|
||||
listedRows: 1,
|
||||
listedPids: new Set([row.pid]),
|
||||
leaderPid: row.pid,
|
||||
processGroupIds: new Set([row.processGroupId]),
|
||||
};
|
||||
const previousIdentity: ProcessIdentityObservation =
|
||||
applicability === "token-verified"
|
||||
? identity("start:old")
|
||||
: { state: "unavailable", reason: "native identity denied" };
|
||||
const previous = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor([
|
||||
new Map([[row.pid, previousIdentity]]),
|
||||
new Map([[row.pid, previousIdentity]]),
|
||||
]),
|
||||
);
|
||||
expect(owner.reconcile(previous, options)).toMatchObject([
|
||||
{ pid: row.pid, applicability },
|
||||
]);
|
||||
const replacement = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor(
|
||||
[
|
||||
new Map([[row.pid, identity("start:old")]]),
|
||||
new Map([[row.pid, identity("start:foreign")]]),
|
||||
],
|
||||
{ state: "readable", runId: "foreign" },
|
||||
),
|
||||
);
|
||||
|
||||
expect(owner.reconcile(replacement, options)).toEqual([]);
|
||||
expect(owner.rowsForObservation([])).toEqual([]);
|
||||
expect(
|
||||
new ProcessOwnershipEvidenceOwner().reconcile(replacement, options),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["absent", "zombie", "dead"] as const)(
|
||||
"settles a token-matching replacement when its final native state is %s",
|
||||
async (state) => {
|
||||
const owner = new ProcessOwnershipEvidenceOwner();
|
||||
const options = {
|
||||
runId: "run",
|
||||
listedRows: 1,
|
||||
listedPids: new Set([row.pid]),
|
||||
leaderPid: row.pid,
|
||||
processGroupIds: new Set([row.processGroupId]),
|
||||
};
|
||||
const previous = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor([
|
||||
new Map([[row.pid, identity("start:old")]]),
|
||||
new Map([[row.pid, identity("start:old")]]),
|
||||
]),
|
||||
);
|
||||
owner.reconcile(previous, options);
|
||||
const terminalIdentity: ProcessIdentityObservation =
|
||||
state === "absent"
|
||||
? { state: "absent", reason: "not-in-snapshot" }
|
||||
: identity("start:new", state);
|
||||
const replacement = await readProcessOwnershipFacts(
|
||||
[row],
|
||||
hostFor([
|
||||
new Map([[row.pid, identity("start:old")]]),
|
||||
new Map([[row.pid, terminalIdentity]]),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(owner.reconcile(replacement, options)).toEqual([]);
|
||||
expect(owner.rowsForObservation([])).toEqual([]);
|
||||
expect(
|
||||
new ProcessOwnershipEvidenceOwner().reconcile(replacement, options),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps omitted identities uncertain and native absence distinct", async () => {
|
||||
const omitted = hostFor([new Map(), new Map()]);
|
||||
const [uncertain] = await readProcessOwnershipFacts([row], omitted);
|
||||
|
||||
@@ -348,39 +348,45 @@ const observeNativeOwnedProcessLineage = async (
|
||||
): Promise<ProcessLineageObservation> => {
|
||||
const initialIdentities = await readNativeProcessIdentityMap(processes, host);
|
||||
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
|
||||
if (launcher === undefined)
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
const launcherIdentity = initialIdentities.get(launcher.pid);
|
||||
if (launcherIdentity?.state === "absent")
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
if (launcherIdentity?.state !== "readable")
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
`owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`,
|
||||
);
|
||||
if (processIdentityHasExited(launcherIdentity))
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity);
|
||||
if (nativeLauncher === undefined)
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
"owned launcher native parent/group relation was unavailable",
|
||||
);
|
||||
const identityFailure = launcherIdentityFailure(nativeLauncher, ownership);
|
||||
if (identityFailure !== null)
|
||||
return unavailableLineage(ownership, identityFailure);
|
||||
|
||||
const nativeProcesses = processes.flatMap((process) => {
|
||||
const launcherIdentity =
|
||||
launcher === undefined ? undefined : initialIdentities.get(launcher.pid);
|
||||
const nativeLauncher =
|
||||
launcher !== undefined &&
|
||||
launcherIdentity?.state === "readable" &&
|
||||
!processIdentityHasExited(launcherIdentity)
|
||||
? processWithNativeRelation(launcher, launcherIdentity)
|
||||
: undefined;
|
||||
const identityFailure =
|
||||
nativeLauncher === undefined
|
||||
? null
|
||||
: launcherIdentityFailure(nativeLauncher, ownership);
|
||||
// Missing native facts leave table ancestry as candidate scope only. Keep
|
||||
// unresolved and exited intermediates as discovery bridges to descendants;
|
||||
// only validated live native facts may authorize the published lineage.
|
||||
const candidateProcesses = processes.map((process) => {
|
||||
const identity = initialIdentities.get(process.pid);
|
||||
if (processIdentityHasExited(identity)) return [];
|
||||
const nativeProcess =
|
||||
identity?.state === "readable"
|
||||
? processWithNativeRelation(process, identity)
|
||||
: undefined;
|
||||
return nativeProcess === undefined ? [] : [nativeProcess];
|
||||
return nativeProcess ?? process;
|
||||
});
|
||||
const descendants = descendantsOf([ownership.leaderPid], nativeProcesses);
|
||||
const candidates = [nativeLauncher, ...descendants];
|
||||
const descendants = descendantsOf([ownership.leaderPid], candidateProcesses);
|
||||
const lineageRows = [
|
||||
...(launcher === undefined ? [] : [launcher]),
|
||||
...descendants,
|
||||
];
|
||||
// A retained candidate may have moved outside the current launcher lineage.
|
||||
const nativeExitedPids = new Set(
|
||||
processes
|
||||
.filter(({ pid }) => processIdentityHasExited(initialIdentities.get(pid)))
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
// A native terminal fact settles the old candidate. Keep its ancestry link,
|
||||
// but do not enroll a replacement that appears during later token reads.
|
||||
const candidates = lineageRows.filter(
|
||||
({ pid }) => !nativeExitedPids.has(pid),
|
||||
);
|
||||
const facts = await readProcessOwnershipFacts(candidates, host, undefined, {
|
||||
identityBefore: initialIdentities,
|
||||
});
|
||||
@@ -396,7 +402,38 @@ const observeNativeOwnedProcessLineage = async (
|
||||
ownership.processGroupId,
|
||||
...(ownership.sampledProcessGroupIds ?? []),
|
||||
]),
|
||||
// A readable mismatching launcher disproves the captured anchors. Keep
|
||||
// independent token evidence, existing candidates, and unknown/exit bridges.
|
||||
// Later same-generation movement cannot erase valid initial scope.
|
||||
allowCapturedScope: identityFailure === null,
|
||||
nativeExitedPids,
|
||||
unverifiedLineageRelations: new Map(
|
||||
lineageRows.map(({ pid, parentPid, processGroupId }) => [
|
||||
pid,
|
||||
{ parentPid, processGroupId },
|
||||
]),
|
||||
),
|
||||
});
|
||||
// A launcher failure prevents publication, but must not discard partial
|
||||
// ownership evidence already observed for this run's descendants.
|
||||
if (launcher === undefined)
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
if (launcherIdentity?.state === "absent")
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
if (launcherIdentity?.state !== "readable")
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
`owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`,
|
||||
);
|
||||
if (processIdentityHasExited(launcherIdentity))
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
if (nativeLauncher === undefined)
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
"owned launcher native parent/group relation was unavailable",
|
||||
);
|
||||
if (identityFailure !== null)
|
||||
return unavailableLineage(ownership, identityFailure);
|
||||
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
|
||||
const verifiedDescendants: ProcessTableEntry[] = [];
|
||||
let currentLauncher = nativeLauncher;
|
||||
|
||||
@@ -32,7 +32,11 @@ cliTest(
|
||||
};
|
||||
const cliResponse = await cli.run({
|
||||
arguments: ["trace-web-module-imports", manifestPath, "0", "--json"],
|
||||
environment,
|
||||
environment: {
|
||||
...environment,
|
||||
// This single CLI invocation cannot reuse a newly written cache.
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
},
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
expect(cliResponse.exitCode, cliResponse.stdout + cliResponse.stderr).toBe(
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Writable } from "node:stream";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
type PersistentOwnedProcessGroup,
|
||||
type ProcessOwnershipHost,
|
||||
} from "../../../src/process/ProcessOwnership.js";
|
||||
import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
observeOwnedProcessLineage,
|
||||
} from "../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { waitForDetachedProviderChild } from "../../fixtures/providerProcess.js";
|
||||
|
||||
const descendantSource = `
|
||||
process.stdin.resume();
|
||||
process.stdin.once('end', () => process.exit(0));
|
||||
process.stdout.write('ready\\n');
|
||||
`;
|
||||
|
||||
const launcherSource = `
|
||||
const { spawn } = require('node:child_process');
|
||||
const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendantSource)}], {
|
||||
detached: true,
|
||||
stdio: [3, 'pipe', process.stderr],
|
||||
});
|
||||
child.once('error', () => process.exit(1));
|
||||
child.stdout.once('data', () => {
|
||||
process.stdout.write('ready:' + child.pid + '\\n');
|
||||
});
|
||||
child.unref();
|
||||
process.stdin.once('data', () => process.exit(0));
|
||||
`;
|
||||
|
||||
it.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
|
||||
"retains a native descendant after identity denial, launcher exit and a later table omission",
|
||||
async () => {
|
||||
const native = createSystemProcessOwnershipHost(
|
||||
process.platform,
|
||||
process.env,
|
||||
);
|
||||
try {
|
||||
await native.prepare?.();
|
||||
const captureBaseline = await native.captureBaseline?.();
|
||||
const runId = randomUUID();
|
||||
const launcher = spawn(process.execPath, ["-e", launcherSource], {
|
||||
detached: true,
|
||||
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
||||
// FD3 is the descendant's lifetime barrier; launcher stdin only exits
|
||||
// the launcher. Its exit must not close the descendant's inherited pipe.
|
||||
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
||||
});
|
||||
const launcherExited = new Promise<void>((resolve, reject) => {
|
||||
launcher.once("exit", () => resolve());
|
||||
launcher.once("error", reject);
|
||||
});
|
||||
launcher.stderr?.resume();
|
||||
const barrier = launcher.stdio[3];
|
||||
let ownership: PersistentOwnedProcessGroup | undefined;
|
||||
try {
|
||||
if (launcher.pid === undefined || !(barrier instanceof Writable))
|
||||
throw new Error("Expected an owned launcher and descendant barrier");
|
||||
ownership = {
|
||||
runId,
|
||||
leaderPid: launcher.pid,
|
||||
processGroupId: launcher.pid,
|
||||
expectedParentPid: process.pid,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
||||
...(captureBaseline === undefined ? {} : { captureBaseline }),
|
||||
};
|
||||
const descendantPid = await waitForDetachedProviderChild(launcher);
|
||||
const descendant = (await native.listProcesses()).find(
|
||||
({ pid }) => pid === descendantPid,
|
||||
);
|
||||
expect(descendant).toMatchObject({
|
||||
parentPid: launcher.pid,
|
||||
processGroupId: descendantPid,
|
||||
});
|
||||
if (descendant === undefined || native.processIdentities === undefined)
|
||||
throw new Error("Expected the real native descendant identity owner");
|
||||
|
||||
const deniedIdentityHost: ProcessOwnershipHost = {
|
||||
...native,
|
||||
processIdentities: async (rows, signal, effectiveUidPids) => {
|
||||
const identities = new Map(
|
||||
await native.processIdentities?.(rows, signal, effectiveUidPids),
|
||||
);
|
||||
if (rows.some(({ pid }) => pid === descendantPid))
|
||||
identities.set(descendantPid, {
|
||||
state: "unavailable",
|
||||
reason: "controlled descendant identity denial",
|
||||
});
|
||||
return identities;
|
||||
},
|
||||
};
|
||||
const lineage = await observeOwnedProcessLineage(
|
||||
ownership,
|
||||
deniedIdentityHost,
|
||||
);
|
||||
const retainedPids = ownership.evidenceOwner.candidates.map(
|
||||
({ pid }) => pid,
|
||||
);
|
||||
|
||||
launcher.stdin?.end("exit\n");
|
||||
await launcherExited;
|
||||
expect(launcher.exitCode).toBe(0);
|
||||
expect(
|
||||
(await native.processIdentities([descendant])).get(descendantPid),
|
||||
).toMatchObject({
|
||||
state: "readable",
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
});
|
||||
|
||||
const omittedHost: ProcessOwnershipHost = {
|
||||
...native,
|
||||
listProcesses: async (signal) =>
|
||||
(await native.listProcesses(signal)).filter(
|
||||
({ pid }) => pid !== descendantPid,
|
||||
),
|
||||
};
|
||||
const omitted = await cleanupOwnedProcessGroup(ownership, omittedHost);
|
||||
// The incomplete table cannot authorize signaling. It also cannot erase
|
||||
// a retained native candidate and certify cleanup while it is alive.
|
||||
expect(omitted).toMatchObject({ cleaned: false });
|
||||
expect(lineage.status).toBe("unavailable");
|
||||
expect(retainedPids).toContain(descendantPid);
|
||||
expect(
|
||||
(await native.processIdentities([descendant])).get(descendantPid),
|
||||
).toMatchObject({
|
||||
state: "readable",
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
});
|
||||
|
||||
// Restore the complete native table and retry the same retained owner.
|
||||
// The run token, native generation and current group authorize cleanup.
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership, native),
|
||||
).resolves.toMatchObject({ cleaned: true, signaled: true });
|
||||
expect(ownership.evidenceOwner.candidates).toEqual([]);
|
||||
} finally {
|
||||
if (barrier instanceof Writable) barrier.end();
|
||||
if (launcher.exitCode === null && launcher.signalCode === null)
|
||||
launcher.stdin?.end("exit\n");
|
||||
await launcherExited;
|
||||
if (ownership !== undefined)
|
||||
expect(
|
||||
await cleanupOwnedProcessGroup(ownership, native),
|
||||
).toMatchObject({
|
||||
cleaned: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await native.close?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user