mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
* fix(process): re-read unreadable tokens before signaling and name the process Darwin KERN_PROCARGS2 samples a process's argument length and stack top before it reads the address space, and exec publishes the new length before it copies the new strings out (xnu sysctl_procargsx, exec_copyout_strings). A read during exec can return a zero-filled or stale strings area, and exec keeps the PID and start time that bracket the read. The final sweep already re-read such tokens, but the pre-signal group check read each member's token once, so one transient read of a live group member failed the whole cleanup. Move the bounded re-read into one shared fact-reading helper and use it for both the sweep and the pre-signal group check. The group check binds re-reads to the generation it first observed, so a reused PID stays unresolved; the sweep keeps classifying the PID's current process. An unreadable-token failure now carries the native identity read after the token and the summary names the candidate by its native name, parent, group and start identity. The Darwin helper and Linux procfs reader report the short native process name (p_comm/comm) for display. Fixes #8337 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(process): refresh other pre-signal members after token re-read waits The pre-signal group check re-read only members whose token was unavailable, so a readable member kept the relation it had before the retry waits. A member that moved to another process group during those waits could be validated on its old group, and the old group signaled while the moved member kept running. After any retry wait, the shared re-read helper now reads every other live pre-signal fact once more with the same generation binding. A moved member then takes the existing changed-group path, which retains its ownership evidence and fails cleanup. An inconclusive refresh replaces the stale fact so it fails closed. Nothing changes when no re-read ran, and the sweep keeps classifying current processes because every group it finds is revalidated before signaling. Also cover PID reuse that happens before the re-read starts, where both re-read identities agree and only the first-read generation rejects the replacement's token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(process): keep known identity changes final across token re-reads The pre-signal re-read treated a first read without a same-generation identity as unbound and replaced it with the re-read. On Linux the first read can already establish PID reuse (an ESRCH token whose exit check sees another generation); the re-read then classified the replacement, so a zombie replacement could let cleanup succeed. A pre-signal check now re-reads only same-generation facts. Changed, exited, exiting and foreign-token facts are final. Each attempt also re-reads the group's owned members instead of one separate refresh after the loop, so a member that moves groups or turns unreadable while another waits is seen within the same bound. The caller's run token selects the owned members, and the sweep keeps classifying current processes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
4.2 KiB
TypeScript
122 lines
4.2 KiB
TypeScript
import { expect } from "vitest";
|
|
|
|
import type { ProcessCaptureError } from "../../src/process/capture/ProcessCaptureError.js";
|
|
|
|
type SerializedCleanupReport = {
|
|
readonly owned_process_group: {
|
|
readonly state: unknown;
|
|
readonly reason: unknown;
|
|
};
|
|
readonly terminal_renderer: { readonly state: unknown };
|
|
readonly temporary_root: {
|
|
readonly state: unknown;
|
|
readonly reason: unknown;
|
|
};
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null;
|
|
|
|
const isSerializedCleanupReport = (
|
|
value: unknown,
|
|
): value is SerializedCleanupReport =>
|
|
isRecord(value) &&
|
|
isRecord(value.owned_process_group) &&
|
|
"state" in value.owned_process_group &&
|
|
"reason" in value.owned_process_group &&
|
|
isRecord(value.terminal_renderer) &&
|
|
"state" in value.terminal_renderer &&
|
|
isRecord(value.temporary_root) &&
|
|
"state" in value.temporary_root &&
|
|
"reason" in value.temporary_root;
|
|
|
|
const expectHostCleanupReport = (report: {
|
|
readonly owned_process_group: {
|
|
readonly state: unknown;
|
|
readonly reason: unknown;
|
|
};
|
|
readonly terminal_renderer: { readonly state: unknown };
|
|
readonly temporary_root: {
|
|
readonly state: unknown;
|
|
readonly reason: unknown;
|
|
};
|
|
}): void => {
|
|
expect(report.owned_process_group.state).toBe("unverified");
|
|
const reason = report.owned_process_group.reason;
|
|
expect(typeof reason).toBe("string");
|
|
if (typeof reason !== "string")
|
|
throw new Error("cleanup report omitted reason");
|
|
const category =
|
|
process.platform === "linux"
|
|
? "environment_errno_(?:EACCES|EPERM)"
|
|
: "environment_unavailable";
|
|
// Native identity facts may follow; a JSON-quoted name can hold parentheses.
|
|
const candidate =
|
|
'[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM))(?: \\((?:[^()"]|"(?:[^"\\\\]|\\\\.)*")*\\))?';
|
|
expect(reason).toMatch(
|
|
new RegExp(
|
|
`^process ownership token could not be read for [1-9][0-9]* live process\\(es\\): ` +
|
|
`${category}=[1-9][0-9]*(?:, ${category}=[1-9][0-9]*)*; ` +
|
|
`live candidates ${candidate}(?:, ${candidate})*$`,
|
|
"u",
|
|
),
|
|
);
|
|
expect(report.terminal_renderer.state).toBe("cleaned");
|
|
// The temporary root is retained while owned process cleanup is unverified.
|
|
expect(report.temporary_root).toEqual({
|
|
state: "unverified",
|
|
reason: `temporary root retained because owned process cleanup is unverified: ${reason}`,
|
|
});
|
|
};
|
|
|
|
/** Permit only the host ownership-token cleanup flake seen by real captures. */
|
|
export const expectUnverifiedHostCleanup = (
|
|
error: ProcessCaptureError,
|
|
): void => {
|
|
expect(error.reason, error.message).toBe("cleanup_incomplete");
|
|
const report = error.cleanupReport;
|
|
expect(report).toBeDefined();
|
|
if (report === undefined)
|
|
throw new Error("cleanup-incomplete error omitted report");
|
|
expectHostCleanupReport(report);
|
|
};
|
|
|
|
/**
|
|
* Assert the same narrowly-defined cleanup flake after CLI/MCP serialization
|
|
* and return the observations that callers must continue to verify.
|
|
*/
|
|
export const expectSerializedUnverifiedHostCleanup = (
|
|
value: unknown,
|
|
): unknown => {
|
|
expect(typeof value).toBe("object");
|
|
expect(value).not.toBeNull();
|
|
if (typeof value !== "object" || value === null || !("error" in value))
|
|
throw new Error("cleanup error omitted its serialized error envelope");
|
|
const error = value.error;
|
|
expect(typeof error).toBe("object");
|
|
expect(error).not.toBeNull();
|
|
if (
|
|
typeof error !== "object" ||
|
|
error === null ||
|
|
!("code" in error) ||
|
|
!("details" in error)
|
|
)
|
|
throw new Error("cleanup error omitted its serialized details");
|
|
expect(error.code).toBe("cleanup_incomplete");
|
|
const details = error.details;
|
|
expect(typeof details).toBe("object");
|
|
expect(details).not.toBeNull();
|
|
if (
|
|
typeof details !== "object" ||
|
|
details === null ||
|
|
!("cleanup_report" in details) ||
|
|
!("partial_observation" in details)
|
|
)
|
|
throw new Error("cleanup error omitted report or partial observation");
|
|
const cleanupReport = details.cleanup_report;
|
|
if (!isSerializedCleanupReport(cleanupReport))
|
|
throw new Error("cleanup error report is incomplete");
|
|
expectHostCleanupReport(cleanupReport);
|
|
return details.partial_observation;
|
|
};
|