mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 14:56:32 +00:00
Keep a run-scoped evidence owner from acquisition through settlement and cleanup retry. Bind token observations to native generations and retain related generations and unresolved identity lookups across table omissions, reparenting and process-group changes without treating PID hints as authority. Use native effective UID facts for withholding decisions, corroborate Linux procfs absence, and share the Linux stat parser with sampling and debugger identity checks. Signal only after current token and group revalidation. Complete swept cleanup across all groups with one bounded final family check; retain unresolved owners and successful-peer completion across retries. Validation: npm run check, docs:check and test discovery passed. Focused ownership/consumer coverage totals 513 passed and 1 skipped across 35 files, including the corrected Electron cancellation test rerun independently. Darwin helper compilation, real owned Node child cleanup, CLI/MCP capture and retained cleanup retries ran. Real Electron applications, native Linux races/PID reuse and Windows cleanup were not qualified. The original Linux reports remain open for that work. Fixes #5238 Refs #4322, #5206
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { ElectronCaptureCleanup } from "./ElectronCaptureCleanup.js";
|
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { electronFuseFailure } from "../electron/ElectronFuseFailures.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import type { _electron as electron } from "playwright-core";
|
|
|
|
import type {
|
|
ExecutionOptions,
|
|
ProviderIdentity,
|
|
} from "../application/AnalysisProvider.js";
|
|
import type {
|
|
CanonicalElectronActiveObservationInput,
|
|
ElectronActiveObservationPort,
|
|
} from "../application/javascript/ElectronActiveObservationPort.js";
|
|
import type { ElectronActiveObservationResult } from "../domain/javascript/electronActiveObservation.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type {
|
|
PersistentOwnedProcessGroup,
|
|
ProcessLineageObservation,
|
|
} from "../process/ProcessOwnership.js";
|
|
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
|
|
import {
|
|
observeOwnedProcessLineage,
|
|
prepareProcessOwnershipInspection,
|
|
systemProcessOwnershipHost,
|
|
} from "../process/ProcessOwnershipObservation.js";
|
|
import {
|
|
runElectronActions,
|
|
readApplicationState,
|
|
readFinalApplicationState,
|
|
runWithExecutionLimits,
|
|
} from "./PlaywrightElectronActiveActions.js";
|
|
import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js";
|
|
import { readElectronFuseImage } from "../electron/ElectronFuseReader.js";
|
|
import { assertNodeCliInspectArgumentsSupported } from "./PlaywrightElectronActiveFuses.js";
|
|
|
|
const OPERATION = "capture_electron_scenario" as const;
|
|
const STARTUP_TIMEOUT_MS = 60_000;
|
|
|
|
import { PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
|
|
|
const hookPath = fileURLToPath(
|
|
new URL("../../scripts/electron-active-hook.cjs", import.meta.url),
|
|
);
|
|
|
|
type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
|
|
type ElectronPaths = {
|
|
readonly executable: string;
|
|
readonly application: string;
|
|
readonly root: string;
|
|
};
|
|
|
|
/** Launch an owned Electron application through the official Playwright API. */
|
|
export class PlaywrightElectronActiveProvider implements ElectronActiveObservationPort {
|
|
readonly #resources = new ArtifactResourceScope();
|
|
readonly #shutdown = new AbortController();
|
|
|
|
/** Stop admitting work, cancel and join captures, then retry exact retained resource owners. */
|
|
async close(): Promise<void> {
|
|
this.#shutdown.abort(new Error("Electron active provider is closing"));
|
|
try {
|
|
await this.#resources.close();
|
|
} catch (cause: unknown) {
|
|
throw providerError(cause);
|
|
}
|
|
}
|
|
|
|
constructor(
|
|
private readonly environment: Readonly<Record<string, string | undefined>>,
|
|
// Load Playwright only when a capture launches; MCP startup stays off its module graph.
|
|
private readonly launch: typeof electron.launch = async (options) =>
|
|
(await import("playwright-core"))._electron.launch(options),
|
|
private readonly readFuseImage: typeof readElectronFuseImage = readElectronFuseImage,
|
|
) {}
|
|
|
|
identity(): ProviderIdentity {
|
|
return PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY;
|
|
}
|
|
|
|
async capture(
|
|
input: CanonicalElectronActiveObservationInput,
|
|
options: ExecutionOptions = {},
|
|
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
|
|
const signal = AbortSignal.any([
|
|
this.#shutdown.signal,
|
|
...(options.signal === undefined ? [] : [options.signal]),
|
|
]);
|
|
try {
|
|
return await this.#resources.run(() =>
|
|
this.#capture(input, { ...options, signal }),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause));
|
|
}
|
|
}
|
|
|
|
async #capture(
|
|
input: CanonicalElectronActiveObservationInput,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
|
|
let application: ElectronApplication | undefined;
|
|
let ownership: PersistentOwnedProcessGroup | undefined;
|
|
let lineage: ProcessLineageObservation | undefined;
|
|
let capture: ElectronActiveObservationResult | undefined;
|
|
let paths: ElectronPaths | undefined;
|
|
let initialState:
|
|
| Awaited<ReturnType<typeof readApplicationState>>
|
|
| undefined;
|
|
let actions: ElectronActiveObservationResult["actions"] = [];
|
|
let outcome: Result<ElectronActiveObservationResult, AnalysisError>;
|
|
const runId = randomUUID();
|
|
try {
|
|
if (options.signal?.aborted === true)
|
|
throw new BrowserObservationError(OPERATION, "cancelled");
|
|
await prepareProcessOwnershipInspection(options.signal);
|
|
const startupDeadline = performance.now() + STARTUP_TIMEOUT_MS;
|
|
paths = {
|
|
executable: input.executable_path,
|
|
application: input.application_path,
|
|
root: input.application_root,
|
|
};
|
|
const preflight = assertNodeCliInspectArgumentsSupported(
|
|
input.executable_path,
|
|
{
|
|
resources: this.#resources,
|
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
readFuseImage: this.readFuseImage,
|
|
},
|
|
).catch((cause: unknown) => {
|
|
throw electronFuseFailure(cause, input.executable_path, OPERATION);
|
|
});
|
|
try {
|
|
await runWithExecutionLimits(
|
|
preflight,
|
|
options.signal,
|
|
startupDeadline,
|
|
);
|
|
} catch (interruption: unknown) {
|
|
// A startup interruption cannot abandon an admitted descriptor's final
|
|
// close. Its physical/cleanup failure takes precedence over the race.
|
|
await preflight;
|
|
throw interruption;
|
|
}
|
|
const captureBaseline =
|
|
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
|
|
if (options.signal?.aborted)
|
|
throw new BrowserObservationError(OPERATION, "cancelled");
|
|
const launchOptions: Parameters<typeof electron.launch>[0] = {
|
|
executablePath: paths.executable,
|
|
cwd: paths.root,
|
|
env: safeElectronEnvironment(this.environment, runId),
|
|
args: ["-r", hookPath, paths.application, ...input.args],
|
|
timeout: Math.max(1, startupDeadline - performance.now()),
|
|
};
|
|
application = await containLaunchRejections(() => {
|
|
options.signal?.throwIfAborted();
|
|
return this.launch(launchOptions);
|
|
});
|
|
const leaderPid = application.process().pid;
|
|
if (
|
|
typeof leaderPid !== "number" ||
|
|
!Number.isSafeInteger(leaderPid) ||
|
|
leaderPid <= 0
|
|
)
|
|
throw new BrowserObservationError(
|
|
OPERATION,
|
|
"process_ownership_unavailable",
|
|
);
|
|
ownership = {
|
|
runId,
|
|
leaderPid,
|
|
processGroupId: leaderPid,
|
|
expectedParentPid: process.pid,
|
|
expectedCommand: paths.executable,
|
|
sweepTokenOwnedProcesses: true,
|
|
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
|
...(captureBaseline === undefined ? {} : { captureBaseline }),
|
|
};
|
|
lineage = await observeOwnedProcessLineage(ownership);
|
|
initialState = await runWithExecutionLimits(
|
|
readApplicationState(application),
|
|
options.signal,
|
|
startupDeadline,
|
|
);
|
|
actions = await runElectronActions(application, input, options);
|
|
const state = await readFinalApplicationState(
|
|
application,
|
|
options.signal,
|
|
);
|
|
capture = projectElectronActiveCapture(paths, actions, state);
|
|
// Preserve a cancellation already observed before finalization so a
|
|
// later cleanup failure keeps its cause. Late cancellation is checked below.
|
|
outcome = options.signal?.aborted
|
|
? err(cancelledCapture(capture, options.signal.reason))
|
|
: ok(capture);
|
|
} catch (cause: unknown) {
|
|
if (paths !== undefined && initialState !== undefined) {
|
|
const retainedCapture = projectElectronActiveCapture(
|
|
paths,
|
|
actions,
|
|
initialState,
|
|
);
|
|
capture = {
|
|
...retainedCapture,
|
|
limitations: [
|
|
...retainedCapture.limitations,
|
|
`Capture stopped after the initial pre-action snapshot: ${cause instanceof Error ? cause.message : String(cause)}. Final Windows, metrics, IPC, and timeline state is unknown; the retained snapshot may omit action effects.`,
|
|
],
|
|
};
|
|
}
|
|
if (cause instanceof AnalysisError && cause.cleanupIncomplete)
|
|
outcome = err(cause);
|
|
else if (options.signal?.aborted === true)
|
|
outcome = err(
|
|
new BrowserObservationError(OPERATION, "cancelled", {
|
|
...cancellationCause(cause, options.signal),
|
|
...(capture === undefined
|
|
? {}
|
|
: {
|
|
partialObservation: {
|
|
kind: "electron-active-observation" as const,
|
|
capture,
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
else {
|
|
const error = providerError(cause);
|
|
if (capture !== undefined)
|
|
error.retainPartialObservation({
|
|
kind: "electron-active-observation",
|
|
capture,
|
|
});
|
|
outcome = err(error);
|
|
}
|
|
}
|
|
if (application !== undefined) {
|
|
const owner = new ElectronCaptureCleanup(application, ownership, lineage);
|
|
const released = await owner.release(this.#resources);
|
|
const cleanup = owner.processResult;
|
|
if (outcome.ok && options.signal?.aborted === true)
|
|
outcome = err(cancelledCapture(outcome.value, options.signal.reason));
|
|
if (
|
|
released.processes.kind === "failed" ||
|
|
released.transport.kind === "failed"
|
|
) {
|
|
const processFailure = released.processes.kind === "failed";
|
|
const failures = [released.processes, released.transport].flatMap(
|
|
(attempt) => (attempt.kind === "failed" ? [attempt.cause] : []),
|
|
);
|
|
const cause =
|
|
failures.length === 1
|
|
? failures[0]
|
|
: new AggregateError(
|
|
failures,
|
|
"Electron process and transport cleanup failed",
|
|
);
|
|
const reason = failures
|
|
.map((failure) =>
|
|
failure instanceof Error ? failure.message : String(failure),
|
|
)
|
|
.join("; ");
|
|
const failure = new BrowserObservationError(
|
|
OPERATION,
|
|
"cleanup_failed",
|
|
{
|
|
cause,
|
|
detail: `${processFailure ? "Electron process cleanup could not be verified" : "Electron transport teardown failed after process cleanup"}: ${reason}. Collected observations are retained when available.`,
|
|
cleanup: { reason, resources: owner.pendingResources },
|
|
...(capture === undefined
|
|
? {}
|
|
: {
|
|
partialObservation: {
|
|
kind: "electron-active-observation" as const,
|
|
capture: processFailure
|
|
? {
|
|
...capture,
|
|
application: {
|
|
...capture.application,
|
|
cleanup: "unverified" as const,
|
|
},
|
|
}
|
|
: capture,
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
owner.bind(failure);
|
|
if (outcome.ok) return err(failure);
|
|
const composed = analysisErrorWithCleanupFailure(
|
|
outcome.error,
|
|
failure,
|
|
OPERATION,
|
|
).bindCleanupDependencies([outcome.error, failure]);
|
|
if (failure.partialObservation !== undefined)
|
|
composed.partialObservation = failure.partialObservation;
|
|
return err(composed);
|
|
}
|
|
if (outcome.ok && cleanup?.cleaned && cleanup.unverified !== undefined)
|
|
outcome = ok({
|
|
...outcome.value,
|
|
limitations: [
|
|
...outcome.value.limitations,
|
|
...cleanup.unverified.map(
|
|
({ pid, diagnostic }) =>
|
|
`Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${diagnostic}`,
|
|
),
|
|
],
|
|
});
|
|
}
|
|
// Cleanup failures returned above keep precedence; a completed capture
|
|
// observed under late cancellation is retained as partial Evidence.
|
|
if (outcome.ok && options.signal?.aborted === true)
|
|
return err(cancelledCapture(outcome.value, options.signal.reason));
|
|
return outcome;
|
|
}
|
|
}
|
|
|
|
const cancelledCapture = (
|
|
capture: ElectronActiveObservationResult,
|
|
reason: unknown,
|
|
): BrowserObservationError =>
|
|
new BrowserObservationError(OPERATION, "cancelled", {
|
|
cause: reason,
|
|
partialObservation: { kind: "electron-active-observation", capture },
|
|
});
|
|
|
|
/**
|
|
* A cancellation wrapper stands in for the caller's abort reason and keeps its
|
|
* producer diagnostics; any other failure observed with cancellation stays the
|
|
* primary cause.
|
|
*/
|
|
const cancellationCause = (
|
|
cause: unknown,
|
|
signal: AbortSignal,
|
|
): {
|
|
readonly cause: unknown;
|
|
readonly diagnostics?: AnalysisError["diagnostics"];
|
|
} =>
|
|
cause instanceof AnalysisCancelledError ||
|
|
(cause instanceof AnalysisError && cause.userCategory === "cancelled")
|
|
? {
|
|
cause: signal.reason,
|
|
...(cause.diagnostics === undefined
|
|
? {}
|
|
: { diagnostics: cause.diagnostics }),
|
|
}
|
|
: { cause };
|
|
|
|
export const safeElectronEnvironment = (
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
runId: string,
|
|
platform = process.platform,
|
|
): Record<string, string> =>
|
|
Object.fromEntries([
|
|
...[
|
|
"HOME",
|
|
"LANG",
|
|
"PATH",
|
|
"TMP",
|
|
"TMPDIR",
|
|
"USER",
|
|
"DISPLAY",
|
|
"WAYLAND_DISPLAY",
|
|
...(platform === "win32"
|
|
? [
|
|
"SYSTEMROOT",
|
|
"TEMP",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"USERPROFILE",
|
|
"COMSPEC",
|
|
"PATHEXT",
|
|
"PROGRAMDATA",
|
|
"ALLUSERSPROFILE",
|
|
"USERNAME",
|
|
"SYSTEMDRIVE",
|
|
"HOMEDRIVE",
|
|
"HOMEPATH",
|
|
]
|
|
: []),
|
|
]
|
|
.map((name) => [name, environment[name]] as const)
|
|
.filter(
|
|
(entry): entry is readonly [string, string] => entry[1] !== undefined,
|
|
),
|
|
["REA_PROCESS_RUN_ID", runId] as const,
|
|
]);
|
|
|
|
const PLAYWRIGHT_INTERNAL_FRAME = /playwright-core[\\/]lib[\\/]/;
|
|
interface LaunchRejectionScope {
|
|
failure?: unknown;
|
|
}
|
|
const activeLaunches = new Set<LaunchRejectionScope>();
|
|
const heldLaunchRejections = new Map<
|
|
Promise<unknown>,
|
|
{
|
|
readonly reason: Error;
|
|
readonly candidates: Set<LaunchRejectionScope>;
|
|
readonly hadExternalListener: boolean;
|
|
}
|
|
>();
|
|
|
|
const holdLaunchRejection = (
|
|
reason: unknown,
|
|
promise: Promise<unknown>,
|
|
): void => {
|
|
if (
|
|
!(reason instanceof Error) ||
|
|
!PLAYWRIGHT_INTERNAL_FRAME.test(reason.stack ?? "")
|
|
)
|
|
throw reason;
|
|
if (!heldLaunchRejections.has(promise))
|
|
heldLaunchRejections.set(promise, {
|
|
reason,
|
|
candidates: new Set(activeLaunches),
|
|
hadExternalListener: process
|
|
.listeners("unhandledRejection")
|
|
.some((listener) => listener !== holdLaunchRejection),
|
|
});
|
|
};
|
|
|
|
const forwardLaunchRejection = (
|
|
reason: Error,
|
|
hadExternalListener: boolean,
|
|
): void => {
|
|
// Listeners present at the original event already observed it. When the
|
|
// holding listener was the only listener, restore Node's fatal behavior.
|
|
if (!hadExternalListener)
|
|
process.nextTick(() => {
|
|
throw reason;
|
|
});
|
|
};
|
|
|
|
/** Playwright leaves rejected launch promises unhandled; absorb only those that repeat this launch's failure. */
|
|
const containLaunchRejections = async <T>(
|
|
launch: () => Promise<T>,
|
|
): Promise<T> => {
|
|
const scope: LaunchRejectionScope = {};
|
|
if (activeLaunches.size === 0)
|
|
process.on("unhandledRejection", holdLaunchRejection);
|
|
activeLaunches.add(scope);
|
|
try {
|
|
return await launch();
|
|
} catch (cause: unknown) {
|
|
scope.failure = cause;
|
|
throw cause;
|
|
} finally {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
activeLaunches.delete(scope);
|
|
if (activeLaunches.size === 0)
|
|
process.off("unhandledRejection", holdLaunchRejection);
|
|
for (const [
|
|
promise,
|
|
{ reason, candidates, hadExternalListener },
|
|
] of heldLaunchRejections) {
|
|
if (!candidates.delete(scope)) continue;
|
|
if (
|
|
scope.failure instanceof Error &&
|
|
reason.message !== "" &&
|
|
scope.failure.message.includes(reason.message)
|
|
)
|
|
heldLaunchRejections.delete(promise);
|
|
else if (candidates.size === 0) {
|
|
heldLaunchRejections.delete(promise);
|
|
forwardLaunchRejection(reason, hadExternalListener);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const providerError = (cause: unknown): AnalysisError => {
|
|
if (cause instanceof AnalysisError) return cause;
|
|
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
|
|
const failure = new ProviderCleanupError(
|
|
PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY.id,
|
|
cause.cleanup.resources,
|
|
{ reason: cause.cleanup.reason },
|
|
{ operation: OPERATION, cause },
|
|
);
|
|
bindArtifactFileCleanup(cause, failure);
|
|
return failure;
|
|
}
|
|
return new ProviderAdapterError(
|
|
PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY.id,
|
|
OPERATION,
|
|
{
|
|
cause,
|
|
diagnostics: {
|
|
message: cause instanceof Error ? cause.message : String(cause),
|
|
},
|
|
},
|
|
);
|
|
};
|