fix(process): token-check identityless exit-refresh replacements (#5675)

* fix(process): token-check identityless exit-refresh replacements

A launcher-exit refresh could drop a reparented grandchild that was absent
from the prior snapshot and report cleanup complete, or publish a verified
empty lineage, without reading its run token.

Fixes #5206

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): re-read identityless refresh rows when relations change

Lineage refresh classified every pre-existing process as a newcomer, and it
skipped a row after caching its first parent and group. Seed the exclusion
set from the initial snapshot and read a row again when that relation changes.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style: format files oxfmt rejects on main

Repo-wide format:check fails on these two files at current main. Align them
so the process-ownership fix can pass the required static check.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): keep owned groups found while confirming exit

An unreadable exit-refresh row used to hide the confirmation table, and
revalidation dropped groups discovered after the plan was built. Both now
stay in the same cleanup, and confirmation reads are bounded.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): reclassify lineage rows when tree membership changes

Initial outsiders and cached refresh rows are read again when a parent,
group, or ancestor move changes launcher-tree membership. Unchanged
initial rows stay unread.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): re-read cached identityless lineage tokens

Parent and group do not identify a process generation. A row whose token
was read is read again on the next refresh, so a reused PID cannot keep
a verified descendant.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): revalidate an owned group that reappears

A group discovered after it was already processed is queued once more.
Another appearance stays unresolved instead of reporting cleanup complete.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): recheck a helper that changes groups during confirmation

The confirmation table can move a token-matched member into a new group.
That row is read again, and cleanup signals the group it belongs to now.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): publish one lineage row when a descendant returns

A refreshed descendant replaces the initial row for the same PID, including
when its group moves away and then returns to the original relation.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): re-read foreign PIDs on the exit confirmation table

A readable foreign token does not identify a process that later reuses
that PID. The confirmation table reads those newcomers again.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): recheck a pre-exit PID whose relation changes

An unchanged pre-existing row stays unread. A prior PID whose parent or
group changed is token-checked, so a reused owned process is not skipped.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): recheck an initial descendant after its token matches

A successful read in the first lineage pass marks that PID token-checked.
A later refresh reads it again, so a reused PID cannot stay verified.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): recheck the launcher on each lineage refresh

A later refresh reads the launcher PID again. A missing or unowned
launcher is unavailable instead of rooting a verified lineage.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): publish lineage from the latest refresh table

A descendant missing from the latest refresh is omitted. A refreshed
launcher whose group or command no longer matches stays unavailable.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(process): cover a refreshed launcher parent mismatch

A same-token launcher whose parent no longer matches the committed
expected parent is unavailable, same as a group or command change.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(process): recheck a token-read helper that returns to its group

A confirmation table dropped a previously matched PID once its parent and
group matched the pre-exit row, so cleanup could signal only the group it
had joined and report success while that helper survived.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
morluto
2026-10-21 11:43:17 +08:00
committed by GitHub
parent 00266efe13
commit df8b4d8a5a
6 changed files with 1669 additions and 27 deletions
+27 -1
View File
@@ -461,7 +461,33 @@ selected roots; it is not a per-file allowance. File contents are read in
Ownership inspections bind run-token reads to native start identities and retain
related generations through reparenting, group changes, omitted rows, and cleanup
retries. The captured run keeps one evidence owner through settlement and cleanup.
retries. A host without native identity reads still token-checks process-table
rows that appear only in a launcher-exit refresh before reporting that group
clean or publishing an empty lineage. A matching token authorizes a signal only
for a group whose leader carries the same token. A matching token whose leader
is absent, or an unreadable token for a still-live new row, stays unresolved. A
readable foreign token is not signaled. An unreadable row that has exited by
the confirmation read does not hide a new row in that confirmation table, and a
group proven while revalidating another owned group is signaled in the same
cleanup. A matched row that changes parent or group on the confirmation read
is token-checked again. A PID whose token was already read is read again
on the next confirmation even when its parent and group return to the
pre-exit values. A PID whose first token was foreign is read again
on that table, because the PID alone does not identify the process. A PID
from the pre-exit table is read again when its parent or group changes, and
an unchanged pre-existing row that was never token-read stays unread. Lineage
publishes one row for each PID. Confirmation reads are bounded; a table that keeps producing unreadable
replacements stays unresolved. That refresh does not retain a ledger
for a later attempt that never observes the launcher. Lineage reads a row again
when its parent, group, or launcher-tree membership changes, and it reads a
token again on the next refresh even when those fields are unchanged. The
launcher is read on every lineage refresh; a missing, unowned, or
relation-changed launcher PID is not published as a verified lineage. A
descendant absent from the latest refresh is omitted. An
ancestor move cannot leave a foreign descendant inside a verified result. A
group that reappears after cleanup already processed it is revalidated once;
another appearance stays unresolved. The captured run keeps
one evidence owner through settlement and cleanup.
An absent or changed token leaves a previously related generation unresolved;
current token proof is still required before signaling. Swept cleanup verifies the
remaining family after its group signals. Coverage is bounded by the native
+17 -1
View File
@@ -272,7 +272,23 @@ termination. Simulated Windows controls do not establish native Windows behavior
Native process-ownership controls distinguish start identity, zombie lifecycle,
current parent/group relation, and token-read evidence. Retain generation-bound
candidates through smaller lineage observations, table omissions, reparenting,
group changes, and failed cleanup retries. A readable token with unavailable
group changes, and failed cleanup retries. Identityless exit-refresh controls
cover a replacement row that is not linked to the prior descendant set: a
matching token on its own group is signaled, a foreign or absent token is not,
an unreadable live token stays unresolved, a replacement that appears only in
the confirmation table is still token-checked, a group discovered during
revalidation is signaled before cleanup succeeds, a member that changes groups
during confirmation is signaled in its new group, a PID first seen with a
foreign token is read again on the confirmation table, a PID whose token was
already read is read again even when its parent and group return to the
pre-exit values, a pre-existing PID is read again when its parent or group
changes, an unchanged pre-existing row that was never token-read stays unread,
an unbounded confirmation
chain stays unresolved, lineage reads a row again when its launcher-tree
membership changes, a cached lineage token is read again on the next refresh, an initial descendant whose token already matched is read again on a later refresh, the launcher is read again on that refresh and a missing, unowned, or relation-changed launcher PID stays unavailable, a descendant absent from the latest refresh is omitted,
a group that reappears after it was processed is revalidated once, a further
reappearance stays unresolved, lineage publishes one row per PID, and lineage does not publish a
verified empty descendant list while that row remains unexamined. A readable token with unavailable
identity stays unresolved. Native UID facts, rather than an earlier process-table
row, determine whether a token denial concerns another user. Darwin tests compile
the helper and exercise snapshot growth, absence, lifecycle, and UID observations;
+592 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import type { ProcessOwnershipHost } from "./ProcessOwnership.js";
import type {
ProcessOwnershipHost,
ProcessTableEntry,
} from "./ProcessOwnership.js";
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
const ownership = {
runId: "run-token",
@@ -106,7 +109,595 @@ describe("owned process-group cleanup discovery", () => {
},
});
});
});
describe("identityless lineage refresh keeps replacement rows", () => {
it("does not publish an empty lineage when exit refresh reveals a token-owned replacement", async () => {
const launcher = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "root",
};
const child = {
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "child",
};
const replacement = {
pid: 102,
parentPid: 1,
processGroupId: 102,
state: "S",
command: "reparented-grandchild",
};
const environment = vi.fn((pid: number) => {
if (pid === 101)
return Promise.reject(
new Error("process exited during environment read"),
);
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
});
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([launcher, child])
.mockResolvedValue([launcher, replacement]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "unavailable",
reason:
"process lineage refresh contained owned PID(s) outside the observed descendants: 102",
});
expect(environment).toHaveBeenCalledWith(102);
});
it("publishes a descendant that appears in the exit refresh and still belongs to the launcher", async () => {
const launcher = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "root",
};
const child = {
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "child",
};
const grandchild = {
pid: 102,
parentPid: 100,
processGroupId: 102,
state: "S",
command: "grandchild",
};
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([launcher, child])
.mockResolvedValue([launcher, grandchild]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment: (pid) =>
pid === 101
? Promise.reject(
new Error("process exited during environment read"),
)
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
},
});
});
});
describe("identityless lineage refresh ignores the prior process table", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command: `pid-${String(pid)}`,
});
it("does not token-read a pre-existing row when a descendant exits", async () => {
const environment = vi.fn((pid: number) =>
pid === 101
? Promise.reject(new Error("process exited during environment read"))
: pid === 50
? Promise.reject(new Error("unreadable pre-existing process"))
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), row(101, 100, 100), row(50, 1)])
.mockResolvedValue([row(100, 1, 100), row(50, 1)]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(50);
});
it("re-reads a refresh row after it moves under the launcher", async () => {
let replacementReads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101 || pid === 103)
return Promise.reject(
new Error("process exited during environment read"),
);
if (pid !== 102)
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
replacementReads += 1;
return Promise.resolve(
replacementReads === 1 ? {} : { REA_PROCESS_RUN_ID: "run-token" },
);
});
const outside = row(102, 1, 102);
const joined = row(102, 100, 102);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(103, 100)])
.mockResolvedValueOnce([row(100, 1, 100), row(103, 100), outside])
.mockResolvedValue([row(100, 1, 100), joined]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
},
});
expect(replacementReads).toBe(2);
});
});
describe("identityless lineage refresh follows tree membership", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command: `pid-${String(pid)}`,
});
it("token-checks an initial outsider after it joins the launcher", async () => {
const environment = vi.fn((pid: number) =>
pid === 101
? Promise.reject(new Error("process exited during environment read"))
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(102, 1)])
.mockResolvedValue([row(100, 1, 100), row(102, 100)]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
},
});
expect(environment).toHaveBeenCalledWith(102);
});
it("rejects a cached foreign row after an ancestor joins the launcher", async () => {
let outsiderReads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101 || pid === 103)
return Promise.reject(
new Error("process exited during environment read"),
);
if (pid !== 102)
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
outsiderReads += 1;
return Promise.resolve({});
});
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
row(100, 1, 100),
row(101, 100),
row(103, 100),
row(50, 1),
])
.mockResolvedValueOnce([
row(100, 1, 100),
row(103, 100),
row(50, 1),
row(102, 50),
])
.mockResolvedValue([row(100, 1, 100), row(50, 100), row(102, 50)]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
});
expect(outsiderReads).toBe(2);
expect(environment.mock.calls.filter(([pid]) => pid === 50)).toHaveLength(
1,
);
});
it("re-reads a cached descendant when its relation is unchanged", async () => {
let replacementReads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101 || pid === 103)
return Promise.reject(
new Error("process exited during environment read"),
);
if (pid !== 102)
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
replacementReads += 1;
return Promise.resolve(
replacementReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
);
});
const descendant = row(102, 100, 102);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(103, 100)])
.mockResolvedValueOnce([row(100, 1, 100), row(103, 100), descendant])
.mockResolvedValue([row(100, 1, 100), descendant]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
});
expect(replacementReads).toBe(2);
});
});
describe("identityless lineage refresh drops exited descendants", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command: `pid-${String(pid)}`,
});
it("omits a validated descendant that is gone from the latest refresh", async () => {
const environment = vi.fn((pid: number) =>
pid === 101 || pid === 103
? Promise.reject(new Error("process exited during environment read"))
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
row(100, 1, 100),
row(102, 100),
row(101, 100),
row(103, 100),
])
.mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(103, 100)])
.mockResolvedValue([row(100, 1, 100)]);
const result = await observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
});
expect(result.status).toBe("verified");
if (result.status !== "verified") return;
expect(result.lineage.descendants).toEqual([]);
});
});
describe("identityless lineage refresh rechecks the launcher relation", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
command = `pid-${String(pid)}`,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command,
});
it("rejects a same-token launcher whose process group changed", async () => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
row(100, 1, 100, "root"),
row(102, 100),
row(101, 100),
])
.mockResolvedValue([row(100, 1, 999, "root"), row(102, 100)]);
await expect(
observeOwnedProcessLineage(
{ ...ownership, expectedCommand: "root" },
{
listProcesses,
environment: (pid) =>
pid === 101
? Promise.reject(
new Error("process exited during environment read"),
)
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup: vi.fn(),
},
),
).resolves.toMatchObject({
status: "unavailable",
reason: "owned launcher process-group identity did not match",
});
});
it("rejects a same-token launcher whose command changed", async () => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
row(100, 1, 100, "root"),
row(102, 100),
row(101, 100),
])
.mockResolvedValue([row(100, 1, 100, "replacement"), row(102, 100)]);
await expect(
observeOwnedProcessLineage(
{ ...ownership, expectedCommand: "root" },
{
listProcesses,
environment: (pid) =>
pid === 101
? Promise.reject(
new Error("process exited during environment read"),
)
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup: vi.fn(),
},
),
).resolves.toMatchObject({
status: "unavailable",
reason:
"owned launcher command identity did not match (observed=replacement; expected=root)",
});
});
it("rejects a same-token launcher whose parent changed", async () => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
row(100, 1, 100, "root"),
row(102, 100),
row(101, 100),
])
.mockResolvedValue([row(100, 7, 100, "root"), row(102, 100)]);
await expect(
observeOwnedProcessLineage(
{ ...ownership, expectedParentPid: 1, expectedCommand: "root" },
{
listProcesses,
environment: (pid) =>
pid === 101
? Promise.reject(
new Error("process exited during environment read"),
)
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup: vi.fn(),
},
),
).resolves.toMatchObject({
status: "unavailable",
reason: "owned launcher parent identity did not match",
});
});
});
describe("identityless lineage refresh rechecks the launcher", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command: `pid-${String(pid)}`,
});
it("does not publish lineage when the launcher PID is gone on a later refresh", async () => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(101, 100)])
.mockResolvedValue([row(102, 100)]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment: (pid) =>
pid === 101
? Promise.reject(
new Error("process exited during environment read"),
)
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "owned launcher exited during lineage validation",
});
});
it("rejects a refresh whose launcher PID is no longer owned", async () => {
let launcherReads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101)
return Promise.reject(
new Error("process exited during environment read"),
);
if (pid !== 100)
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
launcherReads += 1;
return Promise.resolve(
launcherReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
);
});
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(101, 100)])
.mockResolvedValue([row(100, 1, 100), row(102, 100)]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
});
expect(launcherReads).toBe(2);
});
});
describe("identityless lineage refresh rechecks an initial descendant", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command: `pid-${String(pid)}`,
});
it("rejects a reused PID after an earlier descendant token matched", async () => {
let reads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101)
return Promise.reject(
new Error("process exited during environment read"),
);
if (pid !== 102)
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
reads += 1;
return Promise.resolve(
reads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
);
});
const descendant = row(102, 100, 102);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([row(100, 1, 100), descendant, row(101, 100)])
.mockResolvedValue([row(100, 1, 100), descendant]);
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
});
expect(reads).toBe(2);
});
});
describe("identityless lineage refresh publishes one row per PID", () => {
const row = (
pid: number,
parentPid: number,
processGroupId = pid,
): ProcessTableEntry => ({
pid,
parentPid,
processGroupId,
state: "S",
command: `pid-${String(pid)}`,
});
it("keeps a single row when a descendant returns to its original group", async () => {
const environment = vi.fn((pid: number) =>
pid === 101 || pid === 104
? Promise.reject(new Error("process exited during environment read"))
: Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
row(100, 1, 100),
row(101, 100),
row(102, 100, 100),
row(104, 100),
])
.mockResolvedValueOnce([
row(100, 1, 100),
row(102, 100, 102),
row(104, 100),
])
.mockResolvedValue([row(100, 1, 100), row(102, 100, 100)]);
const result = await observeOwnedProcessLineage(ownership, {
listProcesses,
environment,
signalGroup: vi.fn(),
});
expect(result.status).toBe("verified");
if (result.status !== "verified") return;
expect(result.lineage.descendants).toEqual([
{ pid: 102, parentPid: 100, processGroupId: 100 },
]);
});
});
describe("owned process-group cleanup discovery", () => {
it("does not publish lineage when the launcher exits during validation", async () => {
const launcher = {
pid: 100,
+260 -14
View File
@@ -348,7 +348,23 @@ export const cleanupOwnedProcessGroup = async (
);
if ("cleaned" in plan) return plan;
let signaled = false;
for (const processGroupId of plan.signalOrder) {
const pendingGroups = [...plan.signalOrder];
const pending = new Set(pendingGroups);
const passes = new Map<number, number>();
while (pendingGroups.length > 0) {
const processGroupId = pendingGroups.shift();
if (processGroupId === undefined) break;
pending.delete(processGroupId);
const seen = passes.get(processGroupId) ?? 0;
if (seen >= OWNED_GROUP_REVALIDATION_LIMIT)
return mergeUnverifiedObservations(
{
cleaned: false,
reason: `owned process group ${String(processGroupId)} reappeared after revalidation`,
},
plan.unverified,
);
passes.set(processGroupId, seen + 1);
const revalidation = await revalidateOwnedProcessGroup(
processGroupId,
ownership,
@@ -358,6 +374,19 @@ export const cleanupOwnedProcessGroup = async (
);
if ("cleaned" in revalidation)
return mergeUnverifiedObservations(revalidation, plan.unverified);
for (const groupId of revalidation.escapedProcessGroupIds) {
if (groupId === processGroupId || pending.has(groupId)) continue;
if ((passes.get(groupId) ?? 0) >= OWNED_GROUP_REVALIDATION_LIMIT)
return mergeUnverifiedObservations(
{
cleaned: false,
reason: `owned process group ${String(groupId)} reappeared after revalidation`,
},
plan.unverified,
);
pending.add(groupId);
pendingGroups.push(groupId);
}
if (revalidation.empty) continue;
try {
host.signalGroup(processGroupId, "SIGKILL");
@@ -497,6 +526,12 @@ interface OwnedCleanupPlan {
readonly unverified: readonly OwnershipSweepUnverifiedProcess[];
}
/** Process table retained after launcher-exit settlement, plus groups proven by token. */
interface SettledProcessTable {
readonly processes: readonly ProcessTableEntry[];
readonly escapedProcessGroupIds: readonly number[];
}
interface ProcessRelationSnapshot {
readonly currentRelations: readonly ProcessTableEntry[];
readonly identities: ReadonlyMap<number, ProcessIdentityObservation>;
@@ -561,6 +596,169 @@ const readProcessRelationSnapshot = async (
return { currentRelations, identities, failures };
};
const settledProcessTable = (
processes: readonly ProcessTableEntry[],
): SettledProcessTable => ({
processes,
escapedProcessGroupIds: [],
});
/** Confirmation tables taken after an unreadable exit-refresh row disappears. */
const EXIT_REFRESH_CONFIRMATION_LIMIT = 8;
/** Parent and group recorded for a PID in the pre-exit process table. */
interface InitialProcessRelation {
readonly parentPid: number;
readonly processGroupId: number;
}
/**
* Newcomers are token-checked. A PID from the pre-exit table is checked only
* when its parent or group changed, which is the identityless sign that the
* PID may have been reused. An unchanged pre-existing row stays unread.
*/
const isExitRefreshCandidate = (
process: ProcessTableEntry,
ownership: OwnedProcessGroup,
initialRelations: ReadonlyMap<number, InitialProcessRelation>,
): boolean => {
if (process.processGroupId === ownership.processGroupId) return false;
const initial = initialRelations.get(process.pid);
return (
initial === undefined ||
initial.parentPid !== process.parentPid ||
initial.processGroupId !== process.processGroupId
);
};
/**
* Times one group may be revalidated in a single cleanup. The second pass
* covers a group that reappears after it was already processed. Another
* appearance stays unresolved instead of reporting success.
*/
const OWNED_GROUP_REVALIDATION_LIMIT = 2;
/**
* Token-check rows that exist only in the post-exit table. A prior PID, group,
* or ancestry link is handled by the caller; these rows have none of those.
* Foreign tokens are not owned. A matching token authorizes only a group whose
* leader also carries that token. An unreadable live row stays unresolved.
*/
const classifyUnlinkedExitRows = async (
ownership: OwnedProcessGroup,
refreshed: readonly ProcessTableEntry[],
unlinked: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
initialRelations: ReadonlyMap<number, InitialProcessRelation>,
): Promise<
| { readonly kind: "clear"; readonly processes: readonly ProcessTableEntry[] }
| { readonly kind: "uncertain"; readonly reason: string }
| {
readonly kind: "escaped";
readonly processes: readonly ProcessTableEntry[];
readonly groupIds: readonly number[];
}
> => {
let current = refreshed;
let pending = unlinked;
let confirmations = 0;
const matched: ProcessTableEntry[] = [];
const inspected = new Set<number>();
// A readable token does not expire when the PID's parent and group return
// to the pre-exit values. Those PIDs are read again on the next table.
const tokenRead = new Set<number>();
while (pending.length > 0) {
const due = pending.filter((process) => !inspected.has(process.pid));
if (due.length === 0) break;
for (const process of due) inspected.add(process.pid);
const unreadable: { readonly pid: number; readonly reason: string }[] = [];
for await (const { process, observation } of readProcessRunTokens(
host,
due,
)) {
if (observation.state === "readable") {
tokenRead.add(process.pid);
if (observation.runId === ownership.runId) matched.push(process);
continue;
}
unreadable.push({ pid: process.pid, reason: observation.reason });
}
if (unreadable.length === 0) break;
let liveRows: readonly ProcessTableEntry[] | undefined;
try {
liveRows = liveProcesses(await host.listProcesses());
} catch (cause: unknown) {
void cause;
liveRows = undefined;
}
const live =
liveRows === undefined
? undefined
: new Set(liveRows.map(({ pid }) => pid));
const stillLive = unreadable.filter(
(candidate) => live === undefined || live.has(candidate.pid),
);
if (stillLive.length > 0) {
const only = stillLive.length === 1 ? stillLive[0] : undefined;
const pids = stillLive
.map(({ pid }) => pid)
.sort((left, right) => left - right);
return {
kind: "uncertain",
reason:
only === undefined
? `process ownership could not be revalidated for PID(s): ${pids.join(", ")}`
: `process ownership could not be revalidated for PID ${String(only.pid)}: ${only.reason}`,
};
}
if (liveRows === undefined) break;
confirmations += 1;
if (confirmations > EXIT_REFRESH_CONFIRMATION_LIMIT)
return {
kind: "uncertain",
reason:
"process ownership could not be revalidated: exit refresh confirmation limit exceeded",
};
// The confirmation read is its own table. A PID classified on the previous
// table, including a readable foreign token, does not identify the process
// now using that PID. Read newcomers again, read a pre-exit PID again
// when its parent or group changed, and read a PID again when its token
// was already read even if that parent and group have returned.
current = liveRows;
inspected.clear();
matched.length = 0;
pending = current.filter(
(process) =>
tokenRead.has(process.pid) ||
isExitRefreshCandidate(process, ownership, initialRelations),
);
}
const livePids = new Set(current.map(({ pid }) => pid));
const stillMatched = matched.filter((process) => livePids.has(process.pid));
if (stillMatched.length === 0) return { kind: "clear", processes: current };
const matchedPids = new Set(stillMatched.map(({ pid }) => pid));
const groupIds = new Set<number>();
for (const process of stillMatched) {
const leader = current.find(
(candidate) =>
candidate.pid === process.processGroupId &&
candidate.processGroupId === process.processGroupId,
);
if (leader === undefined || !matchedPids.has(leader.pid))
return {
kind: "uncertain",
reason:
"descendant process-group leader identity could not be verified",
};
groupIds.add(process.processGroupId);
}
return {
kind: "escaped",
processes: current,
groupIds: [...groupIds].sort((left, right) => left - right),
};
};
// A process-table read can race exit and retain the old state with an altered
// command. Native terminal facts can settle that mismatch; exit-in-progress
// must be confirmed first. Surviving members still require normal token checks.
@@ -570,11 +768,11 @@ const settleLauncherExit = async (
host: ProcessOwnershipHost,
exitSettlement: ProcessExitSettlementBudget,
): Promise<
| readonly ProcessTableEntry[]
| SettledProcessTable
| Extract<ProcessCleanupResult, { readonly cleaned: false }>
> => {
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
if (launcher === undefined) return processes;
if (launcher === undefined) return settledProcessTable(processes);
let identityCheckedLauncher = launcher;
if (host.processIdentities !== undefined) {
const initialIdentities = await readNativeProcessIdentityMap(
@@ -605,7 +803,9 @@ const settleLauncherExit = async (
reason: "owned launcher exit could not be confirmed",
};
if (processIdentityHasExited(identity))
return processes.filter(({ pid }) => pid !== launcher.pid);
return settledProcessTable(
processes.filter(({ pid }) => pid !== launcher.pid),
);
if (
identity?.state !== "readable" ||
identity.relation?.state !== "observed"
@@ -621,7 +821,7 @@ const settleLauncherExit = async (
};
}
const reason = launcherIdentityFailure(identityCheckedLauncher, ownership);
if (reason === null) return processes;
if (reason === null) return settledProcessTable(processes);
// A native identity read already says that the launcher still exists and
// supplies its current parent/group relation. Refreshing the process table
// cannot repair a command or launch-relation mismatch for that generation.
@@ -654,7 +854,36 @@ const settleLauncherExit = async (
)
)
return { cleaned: false, reason };
return refreshed.processes;
const initialRelations = new Map(
processes.map((process) => [
process.pid,
{
parentPid: process.parentPid,
processGroupId: process.processGroupId,
},
]),
);
const unlinked = refreshed.processes.filter(
(process) =>
isExitRefreshCandidate(process, ownership, initialRelations) &&
!descendantPids.has(process.pid) &&
!descendantGroupIds.has(process.processGroupId) &&
!refreshedDescendantPids.has(process.pid),
);
const classified = await classifyUnlinkedExitRows(
ownership,
refreshed.processes,
unlinked,
host,
initialRelations,
);
if (classified.kind === "uncertain")
return { cleaned: false, reason: classified.reason };
return {
processes: classified.processes,
escapedProcessGroupIds:
classified.kind === "escaped" ? classified.groupIds : [],
};
}
return { cleaned: false, reason };
};
@@ -665,13 +894,15 @@ const createOwnedCleanupPlan = async (
host: ProcessOwnershipHost,
exitSettlement: ProcessExitSettlementBudget,
): Promise<OwnedCleanupPlan | ProcessCleanupResult> => {
const processes = await settleLauncherExit(
const settled = await settleLauncherExit(
ownership,
initialProcesses,
host,
exitSettlement,
);
if ("cleaned" in processes) return processes;
if ("cleaned" in settled) return settled;
const processes = settled.processes;
const escapedProcessGroupIds = new Set(settled.escapedProcessGroupIds);
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
processes,
@@ -714,6 +945,7 @@ const createOwnedCleanupPlan = async (
launcher === undefined &&
rootMembers.length === 0 &&
tokenOwned.owned.length === 0 &&
escapedProcessGroupIds.size === 0 &&
!relationSnapshot.currentRelations.some(({ processGroupId }) =>
sampledProcessGroupIds.has(processGroupId),
)
@@ -748,6 +980,8 @@ const createOwnedCleanupPlan = async (
processGroupIds.add(process.processGroupId);
for (const processGroupId of sampledProcessGroupIds)
processGroupIds.add(processGroupId);
for (const processGroupId of escapedProcessGroupIds)
processGroupIds.add(processGroupId);
const signalableGroups = new Set<number>();
const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures];
const unverifiableSampledGroups = new Set<number>();
@@ -757,9 +991,12 @@ const createOwnedCleanupPlan = async (
({ pid, processGroupId: observedGroupId }) =>
pid === processGroupId && observedGroupId === processGroupId,
);
const provenGroup =
tokenOwnedGroupIds.has(processGroupId) ||
escapedProcessGroupIds.has(processGroupId);
if (
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
!tokenOwnedGroupIds.has(processGroupId) &&
!provenGroup &&
!sampledProcessGroupIds.has(processGroupId)
)
return {
@@ -769,7 +1006,7 @@ const createOwnedCleanupPlan = async (
};
if (
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
!tokenOwnedGroupIds.has(processGroupId)
!provenGroup
)
unverifiableSampledGroups.add(processGroupId);
}
@@ -864,16 +1101,25 @@ const revalidateOwnedProcessGroup = async (
host: ProcessOwnershipHost,
expectedIdentities: ReadonlyMap<number, string | null>,
exitSettlement: ProcessExitSettlementBudget,
): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => {
): Promise<
| {
readonly empty: boolean;
readonly escapedProcessGroupIds: readonly number[];
}
| ProcessCleanupResult
> => {
let members: readonly ProcessTableEntry[];
let escapedProcessGroupIds: readonly number[] = [];
try {
const processes = await settleLauncherExit(
const settled = await settleLauncherExit(
ownership,
liveProcesses(await host.listProcesses()),
host,
exitSettlement,
);
if ("cleaned" in processes) return processes;
if ("cleaned" in settled) return settled;
escapedProcessGroupIds = settled.escapedProcessGroupIds;
const processes = settled.processes;
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
processes,
@@ -910,7 +1156,7 @@ const revalidateOwnedProcessGroup = async (
reason:
"descendant process-group leader identity could not be revalidated",
};
return { empty: members.length === 0 };
return { empty: members.length === 0, escapedProcessGroupIds };
};
const cleanupValidationFailure = (
@@ -310,6 +310,577 @@ describe("owned process-group cleanup validation: refreshed descendant lineage",
);
});
describe("identityless exit refresh signals a token-owned replacement", () => {
it("token-checks a reparented grandchild that appears only after the launcher exits", async () => {
const child = { ...launcher, pid: 101, parentPid: 100 };
const escaped = {
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "reparented-grandchild",
};
const signalGroup = vi.fn();
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 102 ? ownership.runId : "run-token",
}),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([{ ...launcher, command: "[MainThread]" }, child])
.mockResolvedValue([escaped]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(environment).toHaveBeenCalledWith(102);
expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
});
it("does not token-read rows that were already outside the exited snapshot", async () => {
const unrelated = {
...launcher,
pid: 50,
parentPid: 1,
processGroupId: 50,
command: "unrelated",
};
const escaped = {
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "reparented-grandchild",
};
const signalGroup = vi.fn();
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 102 ? ownership.runId : "other-run",
}),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
unrelated,
])
.mockResolvedValue([unrelated, escaped]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(50);
expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
});
});
describe("identityless exit refresh leaves unrelated replacements unresolved", () => {
it.each([
["foreign", "other-run"],
["absent", undefined],
] as const)(
"does not signal a %s replacement row after the launcher exits",
async (_label, runId) => {
const signalGroup = vi.fn();
const environment = vi.fn(() =>
Promise.resolve(
runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId },
),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockResolvedValue([
{
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "foreign-replacement",
},
]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: false });
expect(environment).toHaveBeenCalledWith(102);
expect(signalGroup).not.toHaveBeenCalled();
},
);
it("keeps cleanup unresolved when a replacement row's token cannot be read", async () => {
const signalGroup = vi.fn();
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockResolvedValue([
{
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "unreadable-replacement",
},
]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: () =>
Promise.reject(new Error("environment unavailable")),
signalGroup,
},
),
).resolves.toMatchObject({
cleaned: false,
reason:
"process ownership could not be revalidated for PID 102: environment unavailable",
});
expect(signalGroup).not.toHaveBeenCalled();
});
it("does not signal a token-owned replacement whose group leader is absent", async () => {
const signalGroup = vi.fn();
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockResolvedValue([
{
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 999,
command: "member-without-leader",
},
]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: () =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
signalGroup,
},
),
).resolves.toMatchObject({
cleaned: false,
reason: "descendant process-group leader identity could not be verified",
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("identityless exit refresh keeps later confirmation rows", () => {
it("token-checks a replacement born in the liveness table after an unreadable row exits", async () => {
const unreadable = {
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "unreadable-replacement",
};
const escaped = {
...launcher,
pid: 103,
parentPid: 1,
processGroupId: 103,
command: "owned-replacement",
};
const signalGroup = vi.fn();
const environment = vi.fn((pid: number) =>
pid === 102
? Promise.reject(new Error("environment unavailable"))
: Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockResolvedValueOnce([unreadable])
.mockResolvedValue([escaped]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(environment).toHaveBeenCalledWith(103);
expect(signalGroup.mock.calls).toEqual([[103, "SIGKILL"]]);
});
it("signals an escaped group discovered while revalidating the planned group", async () => {
const escaped = {
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "reparented-grandchild",
};
const signalGroup = vi.fn();
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([launcher])
.mockResolvedValueOnce([{ ...launcher, command: "[MainThread]" }])
.mockResolvedValue([escaped]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: () =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
signalGroup,
},
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
});
it("stays unresolved when exit-refresh confirmations do not settle", async () => {
const signalGroup = vi.fn();
let nextPid = 200;
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockImplementation(() => {
const pid = nextPid;
nextPid += 1;
return Promise.resolve([
{
...launcher,
pid,
parentPid: 1,
processGroupId: pid,
command: "churn",
},
]);
});
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: () =>
Promise.reject(new Error("environment unavailable")),
signalGroup,
},
),
).resolves.toMatchObject({
cleaned: false,
reason:
"process ownership could not be revalidated: exit refresh confirmation limit exceeded",
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("identityless exit refresh follows a helper that changes groups", () => {
it("signals the group a token-owned member joins during confirmation", async () => {
const leader = {
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "owned-leader",
};
const member = {
...launcher,
pid: 103,
parentPid: 102,
processGroupId: 102,
command: "owned-member",
};
const moved = { ...member, parentPid: 1, processGroupId: 103 };
const unreadable = {
...launcher,
pid: 104,
parentPid: 1,
processGroupId: 104,
command: "unreadable",
};
const signalGroup = vi.fn();
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockResolvedValueOnce([leader, member, unreadable])
.mockResolvedValue([leader, moved]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: (pid) =>
pid === 104
? Promise.reject(new Error("environment unavailable"))
: Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
signalGroup,
},
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([
[102, "SIGKILL"],
[103, "SIGKILL"],
]);
});
});
describe("identityless exit refresh rechecks a prior PID whose relation changes", () => {
it("token-checks a pre-existing PID after its parent changes", async () => {
const unrelated = {
...launcher,
pid: 50,
parentPid: 999,
processGroupId: 50,
command: "unrelated",
};
const stable = {
...launcher,
pid: 60,
parentPid: 1,
processGroupId: 60,
command: "stable-outsider",
};
const signalGroup = vi.fn();
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 50 ? ownership.runId : "other-run",
}),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
unrelated,
stable,
])
.mockResolvedValue([{ ...unrelated, parentPid: 1 }, stable]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(environment).toHaveBeenCalledWith(50);
expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(60);
expect(signalGroup.mock.calls).toEqual([[50, "SIGKILL"]]);
});
});
describe("identityless exit refresh keeps a token-read helper", () => {
it("rechecks a matched helper after it returns to its original group", async () => {
const helper = {
...launcher,
pid: 50,
parentPid: 1,
processGroupId: 50,
command: "helper",
};
const leader = {
...launcher,
pid: 60,
parentPid: 1,
processGroupId: 60,
command: "owned-leader",
};
const stable = {
...launcher,
pid: 70,
parentPid: 1,
processGroupId: 70,
command: "stable-outsider",
};
const unreadable = {
...launcher,
pid: 104,
parentPid: 1,
processGroupId: 104,
command: "unreadable",
};
const signalGroup = vi.fn();
const environment = vi.fn((pid: number) =>
pid === 104
? Promise.reject(new Error("environment unavailable"))
: Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
helper,
stable,
])
.mockResolvedValueOnce([
leader,
{ ...helper, parentPid: 60, processGroupId: 60 },
unreadable,
stable,
])
.mockResolvedValue([leader, helper, stable]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(70);
expect(signalGroup.mock.calls).toEqual([
[50, "SIGKILL"],
[60, "SIGKILL"],
]);
});
});
describe("identityless exit refresh re-reads a reused foreign PID", () => {
it("token-checks a PID again after a foreign row exits into an owned replacement", async () => {
const reused = {
...launcher,
pid: 102,
parentPid: 1,
processGroupId: 102,
command: "reused-pid",
};
const unreadable = {
...launcher,
pid: 104,
parentPid: 1,
processGroupId: 104,
command: "unreadable",
};
let reads = 0;
const signalGroup = vi.fn();
const environment = vi.fn((pid: number) => {
if (pid === 104)
return Promise.reject(new Error("environment unavailable"));
if (pid !== 102)
return Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId });
reads += 1;
return Promise.resolve({
REA_PROCESS_RUN_ID: reads === 1 ? "other-run" : ownership.runId,
});
});
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([
{ ...launcher, command: "[MainThread]" },
{ ...launcher, pid: 101, parentPid: 100 },
])
.mockResolvedValueOnce([reused, unreadable])
.mockResolvedValue([reused]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{ listProcesses, environment, signalGroup },
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(reads).toBeGreaterThan(1);
expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
});
});
describe("identityless cleanup revalidates a group that reappears", () => {
const leader = (pid: number, command = "fixture"): ProcessTableEntry => ({
...launcher,
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: pid,
command,
});
it("signals a token-owned group that reappears after it was already processed", async () => {
const signalGroup = vi.fn();
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce([leader(100), leader(101)])
.mockResolvedValueOnce([leader(100)])
.mockResolvedValueOnce([leader(100, "[MainThread]")])
.mockResolvedValueOnce([leader(102)])
.mockResolvedValueOnce([leader(100, "[MainThread]")])
.mockResolvedValueOnce([leader(101)])
.mockResolvedValue([leader(101)]);
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: () =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
signalGroup,
},
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[101, "SIGKILL"],
]);
});
it("stays unresolved when a processed group keeps reappearing", async () => {
const signalGroup = vi.fn();
const tables = [
[leader(100), leader(101)],
[leader(100)],
[leader(100, "[MainThread]")],
[leader(102)],
[leader(100, "[MainThread]")],
[leader(101)],
[leader(100, "[MainThread]")],
[leader(102)],
[leader(100, "[MainThread]")],
[leader(101)],
];
let reads = 0;
const listProcesses = vi.fn<ProcessOwnershipHost["listProcesses"]>(() => {
const table = tables[reads] ?? [leader(101)];
reads += 1;
return Promise.resolve(table);
});
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture" },
{
listProcesses,
environment: () =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
signalGroup,
},
),
).resolves.toMatchObject({
cleaned: false,
reason: "owned process group 101 reappeared after revalidation",
});
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
});
});
describe("owned process-group cleanup validation: launcher exit races", () => {
it.each([101, 999])(
"retains cleanup uncertainty for a detached descendant in group %s after launcher exit",
+202 -10
View File
@@ -239,6 +239,27 @@ export const observeOwnedProcessLineage = async (
return unavailableLineage(ownership, identityFailure);
const descendants = descendantsOf([launcher.pid], processes);
const verifiedDescendants: ProcessTableEntry[] = [];
// Seed every initial row. A later refresh reads a row again when its parent,
// group, or launcher-tree membership changes. A row whose token was read is
// read again on every later refresh: parent and group are not a generation.
const initialTree = new Set(descendants.map(({ pid }) => pid));
const initialRelations = new Map<number, ExaminedLineageRefresh>();
const examinedRefresh = new Map<number, ExaminedLineageRefresh>();
for (const process of processes) {
if (process.pid === launcher.pid) continue;
const relation: ExaminedLineageRefresh = {
parentPid: process.parentPid,
processGroupId: process.processGroupId,
inTree: initialTree.has(process.pid),
tokenChecked: false,
descendant: undefined,
};
initialRelations.set(process.pid, relation);
examinedRefresh.set(process.pid, relation);
}
let refreshDescendants: readonly ProcessTableEntry[] = [];
let suppressedRefreshPids = new Set<number>();
let latestRefreshPids: ReadonlySet<number> | undefined;
for await (const { process: member, observation } of readProcessRunTokens(
host,
[launcher, ...descendants],
@@ -250,19 +271,43 @@ export const observeOwnedProcessLineage = async (
"process lineage contains an unowned or PID-reused process",
);
if (member.pid !== launcher.pid) verifiedDescendants.push(member);
const seeded = examinedRefresh.get(member.pid);
if (seeded !== undefined)
examinedRefresh.set(member.pid, { ...seeded, tokenChecked: true });
} else {
if (await processIsGone(host, member.pid)) {
if (member.pid === launcher.pid)
return unavailableLineage(
ownership,
"owned launcher exited during lineage validation",
);
continue;
let refreshed: readonly ProcessTableEntry[];
try {
refreshed = liveProcesses(await host.listProcesses());
} catch (cause: unknown) {
void cause;
return unavailableLineage(
ownership,
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
);
}
return unavailableLineage(
if (refreshed.some((process) => process.pid === member.pid))
return unavailableLineage(
ownership,
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
);
if (member.pid === launcher.pid)
return unavailableLineage(
ownership,
"owned launcher exited during lineage validation",
);
const classified = await classifyIdentitylessLineageRefresh(
ownership,
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
launcher.pid,
refreshed,
host,
examinedRefresh,
initialRelations,
);
if (classified.status === "unavailable")
return unavailableLineage(ownership, classified.reason);
refreshDescendants = classified.descendants;
suppressedRefreshPids = new Set(classified.suppressedPids);
latestRefreshPids = new Set(refreshed.map(({ pid }) => pid));
}
}
return {
@@ -273,7 +318,15 @@ export const observeOwnedProcessLineage = async (
launcherPid: launcher.pid,
launcherParentPid: launcher.parentPid,
processGroupId: launcher.processGroupId,
descendants: verifiedDescendants
descendants: [
...verifiedDescendants.filter(
({ pid }) =>
(latestRefreshPids === undefined || latestRefreshPids.has(pid)) &&
!suppressedRefreshPids.has(pid) &&
!refreshDescendants.some((descendant) => descendant.pid === pid),
),
...refreshDescendants,
]
.sort((left, right) => left.pid - right.pid)
.map(({ pid, parentPid, processGroupId }) => ({
pid,
@@ -431,6 +484,145 @@ const observeNativeOwnedProcessLineage = async (
};
};
/** Relation already classified for one PID seen by an identityless refresh. */
interface ExaminedLineageRefresh {
readonly parentPid: number;
readonly processGroupId: number;
/** True when this row was a launcher descendant at this classification. */
readonly inTree: boolean;
/** True after this refresh has read the row's run token. */
readonly tokenChecked: boolean;
readonly descendant: ProcessTableEntry | undefined;
}
/**
* A liveness refresh is a process table, not a boolean. New rows still under
* the launcher join the published lineage after a matching token. A matching
* token outside that tree, or an unreadable live row, blocks a verified result.
* A readable foreign token outside the tree is not part of this run. A row is
* read again when its parent, process group, or launcher-tree membership
* changes. A row whose token was already read is read again on every later
* refresh, because those fields do not identify a process generation.
* Unchanged rows from the initial snapshot are not read again. The launcher
* is read on every refresh; a missing, unowned, or relation-mismatched
* launcher PID is unavailable.
*/
const classifyIdentitylessLineageRefresh = async (
ownership: OwnedProcessGroup,
launcherPid: number,
refreshed: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
examined: Map<number, ExaminedLineageRefresh>,
initialRelations: ReadonlyMap<number, ExaminedLineageRefresh>,
): Promise<
| {
readonly status: "classified";
readonly descendants: readonly ProcessTableEntry[];
readonly suppressedPids: readonly number[];
}
| { readonly status: "unavailable"; readonly reason: string }
> => {
const refreshedLauncher = refreshed.find(
(process) => process.pid === launcherPid,
);
if (refreshedLauncher === undefined)
return {
status: "unavailable",
reason: "owned launcher exited during lineage validation",
};
for await (const { observation } of readProcessRunTokens(host, [
refreshedLauncher,
])) {
if (observation.state !== "readable")
return {
status: "unavailable",
reason: `process ownership could not be revalidated for PID ${String(launcherPid)}: ${observation.reason}`,
};
if (observation.runId !== ownership.runId)
return {
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
};
}
const identityFailure = launcherIdentityFailure(refreshedLauncher, ownership);
if (identityFailure !== null)
return { status: "unavailable", reason: identityFailure };
const inTree = new Set(
descendantsOf([launcherPid], refreshed).map(({ pid }) => pid),
);
const due = refreshed.filter((process) => {
if (process.pid === launcherPid) return false;
const previous = examined.get(process.pid);
return (
previous === undefined ||
previous.tokenChecked ||
previous.parentPid !== process.parentPid ||
previous.processGroupId !== process.processGroupId ||
previous.inTree !== inTree.has(process.pid)
);
});
if (due.length > 0) {
const included = new Map<number, ProcessTableEntry>();
const ownedOutside: number[] = [];
for await (const { process, observation } of readProcessRunTokens(
host,
due,
)) {
if (observation.state !== "readable")
return {
status: "unavailable",
reason: `process ownership could not be revalidated for PID ${String(process.pid)}: ${observation.reason}`,
};
if (observation.runId !== ownership.runId) {
if (inTree.has(process.pid))
return {
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
};
continue;
}
if (inTree.has(process.pid)) included.set(process.pid, process);
else ownedOutside.push(process.pid);
}
if (ownedOutside.length > 0)
return {
status: "unavailable",
reason: `process lineage refresh contained owned PID(s) outside the observed descendants: ${ownedOutside
.sort((left, right) => left - right)
.join(", ")}`,
};
for (const process of due)
examined.set(process.pid, {
parentPid: process.parentPid,
processGroupId: process.processGroupId,
inTree: inTree.has(process.pid),
tokenChecked: true,
descendant: included.get(process.pid),
});
}
const livePids = new Set(refreshed.map(({ pid }) => pid));
for (const pid of examined.keys())
if (!livePids.has(pid)) examined.delete(pid);
const suppressedPids = [...examined.keys()].filter((pid) => {
const seed = initialRelations.get(pid);
const entry = examined.get(pid);
return (
seed !== undefined &&
entry !== undefined &&
(seed.parentPid !== entry.parentPid ||
seed.processGroupId !== entry.processGroupId ||
seed.inTree !== entry.inTree)
);
});
return {
status: "classified",
descendants: [...examined.values()].flatMap((entry) =>
entry.descendant === undefined ? [] : [entry.descendant],
),
suppressedPids,
};
};
const processWithNativeRelation = (
process: ProcessTableEntry,
identity: ProcessIdentityObservation,