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:
곽현 authored and GitHub committed 2026-10-23 02:25:04 +08:00
1 parent 8ecbb74b00
commit b2048d46d1
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
+81 -26
View File
@@ -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 =
+170 -4
View File
@@ -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);
+64 -27
View File
@@ -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;
+5 -1
View File
@@ -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?.();
}
},
);