Files
reverse-engineering-anything/src/process/ProcessOwnership.exitScope.test.ts
T
01e00dae72 fix(process): corroborate exiting Linux ownership candidates (#7958)
* fix(process): corroborate Linux exits after procfs environ read races

A token read failing with ENOENT/ESRCH may race with process exit while
native identity observation still sees a live PID. Recheck native lifetime
and accept absence only when corroborated; keep unverifiable or reused
PIDs fail closed.

Refs #7790

* fix(process): recheck Linux exit after final token retry

A process can exit after the final retry of a failed procfs environment
read. Re-observe native identity before producing final cleanup failures,
and keep PID replacement and uncertain identity fail-closed.

Add five integration-facing sweep regression cases for absence, live,
unavailable, replaced and zombie-replaced candidates.

Refs #7790

* fix(process): bound native exit settlement for token-open races

After a terminal ENOENT/ESRCH run-token read, allow a tightly bounded
native-only settlement window to corroborate pending Linux process exit.
Retain an explicit failure for live/unavailable or changed PID identities,
without inferring exit from the token error itself.

Cover delayed absence, delayed replacement and bounded live retries.

Refs #7790

* fix(process): share native token exit settlement

* fix(process): signal only groups with validated members

* fix(process): retain lineage through exit settlement

* fix(process): type mixed identity observations in exit checks

Map inference collapsed a readable/absent union onto the absent
overload after main tightened ProcessIdentityObservation.

* fix(process): refresh groups whose members exit during revalidation

A same-group child forked after the revalidation snapshot is not an
empty group, so cleanup reads the table again before skipping the signal.

* test(process): arm group-refresh exit from the revalidation snapshot

The regression follows the table read after planning observed the
launcher token, and requires a later refresh to see the forked child.

* test(mcp): assert bundle path issues by field

The compare_bundles rejection reports each relative path as an
invalid_format issue instead of one joined diagnostic sentence.

* test(mcp): assert field issues for bundle paths and Dart ABI

Relative bundle paths and rejected ABI selectors are reported as
input issues on those fields, not as one joined diagnostic sentence.

---------

Co-authored-by: Harshit Sharma <66710144+harshitethic@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-29 18:40:12 +08:00

204 lines
6.3 KiB
TypeScript

import { performance } from "node:perf_hooks";
import { expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessRunTokenObservation,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const row = (pid: number, parentPid: number): ProcessTableEntry => ({
pid,
parentPid,
processGroupId: pid,
state: "R",
command: "/fixture/node",
});
const identity = (
process: ProcessTableEntry,
exiting: boolean,
): ProcessIdentityObservation => ({
state: "readable",
identity: `start:${process.pid}`,
lifecycle: {
state: "observed",
lifecycle: exiting ? "exiting" : "non-zombie",
},
relation: {
state: "observed",
parentPid: process.parentPid,
processGroupId: process.processGroupId,
},
});
it.each([
"readable",
"retried",
"reused",
"retained-parent",
"retained-parent-esrch",
] as const)(
"keeps child scope tied to its native generation after parent exit (%s)",
async (mode) => {
const parent = row(200, 1);
const child = row(300, parent.pid);
const owner = new ProcessOwnershipEvidenceOwner();
if (mode === "retained-parent" || mode === "retained-parent-esrch")
owner.reconcile(
[
{
process: parent,
state: "same-generation",
identityBefore: identity(parent, false),
token: { state: "readable", runId: "run" },
identityAfter: identity(parent, false),
},
],
{
runId: "run",
listedRows: 1,
listedPids: new Set([parent.pid]),
leaderPid: 100,
processGroupIds: new Set([100]),
},
);
const priorSnapshots = owner.coverage.snapshotCount;
let parentReads = 0;
let childTokenReads = 0;
let reparented = false;
const host: ProcessOwnershipHost = {
platform: "linux",
listProcesses: async () => [parent, child],
environment: async (pid) =>
pid === parent.pid ? { REA_PROCESS_RUN_ID: "run" } : {},
runTokens: async (selected) =>
new Map<number, ProcessRunTokenObservation>(
selected.map((process) => [
process.pid,
process.pid === parent.pid
? mode === "retained-parent-esrch"
? {
state: "unavailable",
reason: "opaque failure",
code: "ESRCH",
}
: mode === "retained-parent"
? { state: "unavailable", reason: "EACCES" }
: { state: "readable", runId: "run" }
: ++childTokenReads === 1 && mode !== "readable"
? { state: "unavailable", reason: "EACCES" }
: { state: "readable", runId: undefined },
]),
),
processIdentities: async (selected) =>
new Map<number, ProcessIdentityObservation>(
selected.map((process) => {
if (process.pid === parent.pid && ++parentReads > 2) {
reparented = true;
return [
process.pid,
{ state: "absent", reason: "not-in-snapshot" },
];
}
const observed =
process.pid === child.pid && reparented
? { ...process, parentPid: 1 }
: process;
const current = identity(
observed,
process.pid === parent.pid && parentReads === 2,
);
return [
process.pid,
process.pid === child.pid &&
reparented &&
mode === "reused" &&
current.state === "readable"
? { ...current, identity: "start:replacement" }
: current,
];
}),
),
signalGroup: vi.fn(),
};
const result = await verifyNoTokenOwnedProcesses("run", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: owner,
});
if (mode === "reused") {
// A replacement process cannot inherit the original child's history.
expect(result.cleaned).toBe(true);
expect(owner.candidates).not.toContainEqual(
expect.objectContaining({ pid: child.pid }),
);
} else {
// The original child stays live even though its parent has exited.
expect(result.cleaned).toBe(false);
expect(owner.candidates).toContainEqual(
expect.objectContaining({
pid: child.pid,
identity: "start:300",
applicability: "related-ownership-unresolved",
}),
);
}
expect(host.signalGroup).not.toHaveBeenCalled();
expect(owner.coverage.snapshotCount).toBe(priorSnapshots + 1);
},
);
it("shares native exit waiting before signaling healthy groups in one cleanup attempt", async () => {
const root = row(100, 1);
const rows = [
root,
row(200, root.pid),
row(300, root.pid),
row(400, root.pid),
];
let clock = 0;
let firstSignalAt: number | undefined;
const nativeClock = vi
.spyOn(performance, "now")
.mockImplementation(() => clock);
const host: ProcessOwnershipHost = {
listProcesses: async () => rows,
environment: async () => ({ REA_PROCESS_RUN_ID: "run" }),
processIdentities: async (selected) => {
clock += 25;
return new Map<number, ProcessIdentityObservation>(
selected.map((process) => [
process.pid,
identity(process, process.pid !== root.pid),
]),
);
},
signalGroup: vi.fn((group) => {
expect(group).toBe(root.pid);
firstSignalAt ??= clock;
}),
};
try {
const result = await cleanupOwnedProcessGroup(
{
runId: "run",
leaderPid: root.pid,
processGroupId: root.pid,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
},
host,
);
expect(result.cleaned).toBe(false);
expect(firstSignalAt).toBeDefined();
// Controlled native-observation time, not a wall-clock timeout: three
// stuck helpers must not each add another full second before root signal.
expect(firstSignalAt).toBeLessThan(2_000);
} finally {
nativeClock.mockRestore();
}
});