mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
Part of #9881. Move the limits checked on each call into update_analysis_settings, keep their REA_ seeds and bounds, and reject updates outside the allowed range.
463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import {
|
|
DEBUGGER_COMMAND_DEADLINE_SETTING,
|
|
DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING,
|
|
type DebuggerAdmissionLimit,
|
|
} from "../config/debuggerSessionLimits.js";
|
|
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
|
|
import type { AnalysisSettingId } from "../domain/analysisSettings.js";
|
|
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
|
|
import { OwnedDebuggerCleanupError } from "./ownedDebuggerCleanupError.js";
|
|
|
|
/** Result of terminating one debugger process owned by this application. */
|
|
export type OwnedDebuggerStopResult =
|
|
| { readonly status: "stopped" | "already_exited" }
|
|
| { readonly status: "incomplete"; readonly reason: string };
|
|
|
|
const RUNTIME_DEBUGGER_SETTINGS = {
|
|
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "debugger.command_deadline_ms",
|
|
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]:
|
|
"debugger.max_command_output_bytes",
|
|
} as const satisfies Partial<Record<string, AnalysisSettingId>>;
|
|
|
|
const runtimeDebuggerSetting = (
|
|
admission: DebuggerAdmissionLimit,
|
|
): AnalysisSettingId | undefined =>
|
|
RUNTIME_DEBUGGER_SETTINGS[
|
|
admission.setting as keyof typeof RUNTIME_DEBUGGER_SETTINGS
|
|
];
|
|
|
|
/** Structured limit for a debugger budget that update_analysis_settings can change. */
|
|
export const debuggerAnalysisLimit = (admission: DebuggerAdmissionLimit) => {
|
|
const setting = runtimeDebuggerSetting(admission);
|
|
return setting === undefined
|
|
? undefined
|
|
: analysisSettingLimit(setting, admission.value, admission.source);
|
|
};
|
|
|
|
/** Structured facts a limit error exposes. The sentence is display text. */
|
|
export const debuggerAdmissionDiagnostics = (
|
|
admission: DebuggerAdmissionLimit,
|
|
) => {
|
|
const analysisSetting = runtimeDebuggerSetting(admission);
|
|
const update_mode = analysisSetting === undefined ? "restart" : "runtime";
|
|
return {
|
|
setting: admission.setting,
|
|
effective_value: admission.value,
|
|
source: admission.source,
|
|
default_value: admission.defaultValue,
|
|
minimum: admission.minimum,
|
|
maximum: admission.maximum,
|
|
unit: admission.unit,
|
|
update_mode,
|
|
pending_restart: false as const,
|
|
...(analysisSetting === undefined
|
|
? {}
|
|
: { analysis_setting: analysisSetting }),
|
|
};
|
|
};
|
|
|
|
/** Name the setting, the effective value, its source, and the range. */
|
|
export const debuggerAdmissionRecovery = (
|
|
admission: DebuggerAdmissionLimit,
|
|
): string => {
|
|
const facts = debuggerAdmissionDiagnostics(admission);
|
|
const identity =
|
|
facts.analysis_setting === undefined
|
|
? admission.setting
|
|
: `${facts.analysis_setting} (launch seed ${admission.setting})`;
|
|
const change =
|
|
facts.update_mode === "runtime"
|
|
? `It can be changed at runtime with update_analysis_settings.`
|
|
: `Set ${admission.setting} in the environment that starts REA to a positive decimal integer from ${String(admission.minimum)} to ${String(admission.maximum)}, then restart REA.`;
|
|
return `The effective ${identity} value is ${String(admission.value)} ${admission.unit} (source ${admission.source}; default ${String(admission.defaultValue)} ${admission.unit}, floor ${String(admission.minimum)} ${admission.unit}, maximum ${String(admission.maximum)} ${admission.unit}). ${change}`;
|
|
};
|
|
|
|
/** Fixed startup deadline. Distinct from the shared command deadline setting. */
|
|
export const fixedDeadlineGuidance = (
|
|
what: string,
|
|
timeoutMs: number,
|
|
): { readonly userMessage: string; readonly remediationAction: string } => ({
|
|
userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`,
|
|
remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
|
|
});
|
|
|
|
/** Shared command deadline. The setting, not a per-provider constant, owns the cap. */
|
|
export const configurableDeadlineGuidance = (
|
|
what: string,
|
|
admission: DebuggerAdmissionLimit,
|
|
): {
|
|
readonly userMessage: string;
|
|
readonly remediationAction: string;
|
|
readonly diagnostics: ReturnType<typeof debuggerAdmissionDiagnostics>;
|
|
} => {
|
|
const recovery = debuggerAdmissionRecovery(admission);
|
|
const limit = debuggerAnalysisLimit(admission);
|
|
return {
|
|
userMessage: `${what} reached its ${String(admission.value)} ms deadline (deadlineExpired). ${recovery}`,
|
|
remediationAction: recovery,
|
|
diagnostics: debuggerAdmissionDiagnostics(admission),
|
|
...(limit === undefined ? {} : { limit }),
|
|
};
|
|
};
|
|
|
|
/** Capacity refusal whose projection carries the setting, value, source, and range. */
|
|
export const debuggerResourceConstraintError = (
|
|
operation: string,
|
|
resource: "memory" | "transport",
|
|
summary: string,
|
|
remediation: string,
|
|
admission: DebuggerAdmissionLimit,
|
|
reported: Readonly<Record<string, JsonValue>>,
|
|
): AnalysisResourceConstraintError => {
|
|
const facts = debuggerAdmissionDiagnostics(admission);
|
|
const recovery = debuggerAdmissionRecovery(admission);
|
|
const limit = debuggerAnalysisLimit(admission);
|
|
return new AnalysisResourceConstraintError(
|
|
operation,
|
|
resource,
|
|
`${summary} ${recovery}`,
|
|
{ ...facts, ...reported },
|
|
{
|
|
remediationAction: `${remediation} ${recovery}`,
|
|
diagnostics: facts,
|
|
...(limit === undefined ? {} : { limit }),
|
|
},
|
|
);
|
|
};
|
|
|
|
const DETACH_TIMEOUT_MS = 1_000;
|
|
const MAX_LATE_DEBUGGER_OUTPUT_BYTES = 64 * 1024;
|
|
export type OwnedDebuggerDetachResult =
|
|
| { readonly status: "no-inferior" }
|
|
| { readonly status: "detached"; readonly outputSink: "durable" };
|
|
export interface OwnedInferiorIdentity {
|
|
readonly pid: string;
|
|
readonly startTime: string;
|
|
}
|
|
|
|
export type ProcessStartTimeReader = (pid: string) => Promise<string | null>;
|
|
|
|
/** Read one Linux identity; absence is distinct from unreadable or malformed data. */
|
|
export const readLinuxProcessStartTime: ProcessStartTimeReader = async (
|
|
pid,
|
|
) => {
|
|
if (process.platform !== "linux")
|
|
throw new Error(
|
|
"Inferior process identity verification requires Linux /proc.",
|
|
);
|
|
let stat: string;
|
|
try {
|
|
stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
} catch (cause: unknown) {
|
|
if (
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT"
|
|
)
|
|
return null;
|
|
throw cause;
|
|
}
|
|
const parsed = parseLinuxProcessStat(stat);
|
|
if (parsed === undefined || parsed.pid !== Number(pid))
|
|
throw new Error(`Inferior PID ${pid} has an unreadable process identity.`);
|
|
return parsed.startTime;
|
|
};
|
|
|
|
/** Confirm only that every previously identified inferior process has ended. */
|
|
export const verifyOwnedInferiorsEnded = async (
|
|
identities: Iterable<OwnedInferiorIdentity>,
|
|
readStartTime: ProcessStartTimeReader = readLinuxProcessStartTime,
|
|
): Promise<boolean> => {
|
|
let foundIdentity = false;
|
|
for (const identity of identities) {
|
|
foundIdentity = true;
|
|
const observed = await readStartTime(identity.pid);
|
|
if (observed === identity.startTime) return false;
|
|
}
|
|
return foundIdentity;
|
|
};
|
|
|
|
const detachAttempts = new WeakMap<
|
|
object,
|
|
Promise<OwnedDebuggerDetachResult>
|
|
>();
|
|
const detachOutcomes = new WeakMap<
|
|
object,
|
|
"confirmed" | "unconfirmed" | "inferior-ended"
|
|
>();
|
|
|
|
const isVerifiedDetachResult = (
|
|
result: unknown,
|
|
): result is OwnedDebuggerDetachResult =>
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
(("status" in result && result.status === "no-inferior") ||
|
|
("status" in result &&
|
|
result.status === "detached" &&
|
|
"outputSink" in result &&
|
|
result.outputSink === "durable"));
|
|
|
|
const waitForInferiorDetach = async (
|
|
session: object,
|
|
detach: () => Promise<OwnedDebuggerDetachResult>,
|
|
): Promise<OwnedDebuggerDetachResult | undefined> => {
|
|
let attempt = detachAttempts.get(session);
|
|
if (attempt === undefined) {
|
|
// Providers may use `this` to access their command transport.
|
|
const pending = Promise.resolve().then(() => detach.call(session));
|
|
const tracked = pending.then(
|
|
(result) => {
|
|
detachAttempts.delete(session);
|
|
detachOutcomes.set(
|
|
session,
|
|
isVerifiedDetachResult(result) ? "confirmed" : "unconfirmed",
|
|
);
|
|
return result;
|
|
},
|
|
(cause: unknown) => {
|
|
detachAttempts.delete(session);
|
|
detachOutcomes.set(session, "unconfirmed");
|
|
throw cause;
|
|
},
|
|
);
|
|
attempt = tracked;
|
|
detachAttempts.set(session, tracked);
|
|
}
|
|
let timer!: NodeJS.Timeout;
|
|
try {
|
|
return await Promise.race([
|
|
attempt,
|
|
new Promise<undefined>((resolve) => {
|
|
timer = setTimeout(() => resolve(undefined), DETACH_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
|
|
interface OwnedDebuggerProcess {
|
|
readonly id: string;
|
|
readonly exited: boolean;
|
|
readonly inferiorStateUnknown?: () => boolean;
|
|
readonly verifyInferiorsEndedAfterExit?: () => Promise<boolean>;
|
|
readonly launched: {
|
|
readonly process: { kill(signal: NodeJS.Signals): boolean };
|
|
};
|
|
/** Must return only after the provider verifies detachment or no inferior. */
|
|
/** Confirms no inferior, or detaches only after verifying a durable output sink. */
|
|
readonly detachInferior?: () => Promise<OwnedDebuggerDetachResult>;
|
|
readonly supervisor: {
|
|
waitForExit(timeoutMs: number): Promise<boolean>;
|
|
/** WindowsOwnedProcess closes output only after the whole Job Object settles. */
|
|
waitForOutputClose(timeoutMs: number): Promise<boolean>;
|
|
unrefOutputStreams(): void;
|
|
/** Cap bytes retained after debugger shutdown while continuing to drain. */
|
|
limitAdditionalOutputBytes(maxBytes: number): void;
|
|
dispose(): void;
|
|
};
|
|
}
|
|
|
|
const finishExitedDebuggerSession = async (
|
|
session: OwnedDebuggerProcess,
|
|
debuggerName: string,
|
|
platform: NodeJS.Platform,
|
|
status: "stopped" | "already_exited",
|
|
): Promise<OwnedDebuggerStopResult> => {
|
|
try {
|
|
session.supervisor.limitAdditionalOutputBytes(
|
|
MAX_LATE_DEBUGGER_OUTPUT_BYTES,
|
|
);
|
|
if (!(await session.supervisor.waitForOutputClose(1_000))) {
|
|
return {
|
|
status: "incomplete",
|
|
reason:
|
|
platform === "win32"
|
|
? `Owned ${debuggerName} process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.`
|
|
: `Owned ${debuggerName} process leader exited, but inherited output streams are still open or have not reported settlement.`,
|
|
};
|
|
}
|
|
} catch (cause: unknown) {
|
|
return {
|
|
status: "incomplete",
|
|
reason:
|
|
platform === "win32"
|
|
? `Owned ${debuggerName} Windows Job Object settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
: `Owned ${debuggerName} inherited output stream settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
if (platform !== "win32") session.supervisor.unrefOutputStreams();
|
|
session.supervisor.dispose();
|
|
return { status };
|
|
};
|
|
|
|
/**
|
|
* Stop only the directly owned debugger process, never its process group or target.
|
|
* The caller must permanently close command admission before the first attempt;
|
|
* retries retain completed detach facts while process termination is pending.
|
|
*/
|
|
export const stopOwnedDebuggerProcess = async (
|
|
session: OwnedDebuggerProcess,
|
|
debuggerName: string,
|
|
options: { readonly platform?: NodeJS.Platform } = {},
|
|
): Promise<OwnedDebuggerStopResult> => {
|
|
const platform = options.platform ?? process.platform;
|
|
if (session.exited) {
|
|
if (detachAttempts.has(session))
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach is still pending after the process exited.`,
|
|
};
|
|
const detachOutcome = detachOutcomes.get(session);
|
|
if (
|
|
detachOutcome === "unconfirmed" ||
|
|
(detachOutcome === undefined && session.inferiorStateUnknown?.() === true)
|
|
) {
|
|
try {
|
|
if ((await session.verifyInferiorsEndedAfterExit?.()) !== true)
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior disposition was not verified before the debugger exited, and no owner proof confirms the identified inferior processes have ended.`,
|
|
};
|
|
detachOutcomes.set(session, "inferior-ended");
|
|
} catch (cause: unknown) {
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior process identity could not be verified after debugger exit: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
}
|
|
return finishExitedDebuggerSession(
|
|
session,
|
|
debuggerName,
|
|
platform,
|
|
"already_exited",
|
|
);
|
|
}
|
|
if (platform === "win32")
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.`,
|
|
};
|
|
// Admission remains closed once cleanup starts. A verified detach is an
|
|
// owned completion fact, even when terminating the debugger later fails.
|
|
// Retrying the command transport can no longer improve that fact and may
|
|
// block termination if shutdown already made the transport unavailable.
|
|
if (detachOutcomes.get(session) !== "confirmed") {
|
|
try {
|
|
if (session.detachInferior === undefined) {
|
|
detachOutcomes.set(session, "unconfirmed");
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach cannot be verified because the provider has no detach capability.`,
|
|
};
|
|
}
|
|
const detached = await waitForInferiorDetach(
|
|
session,
|
|
session.detachInferior,
|
|
);
|
|
if (detached === undefined)
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach was not verified within ${String(DETACH_TIMEOUT_MS)} ms. This release has no runtime override.`,
|
|
};
|
|
if (!isVerifiedDetachResult(detached)) {
|
|
detachOutcomes.set(session, "unconfirmed");
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} provider did not confirm no inferior or detachment with a durable output sink.`,
|
|
};
|
|
}
|
|
detachOutcomes.set(session, "confirmed");
|
|
} catch (cause: unknown) {
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
}
|
|
session.launched.process.kill("SIGTERM");
|
|
if (!(await session.supervisor.waitForExit(300))) {
|
|
session.launched.process.kill("SIGKILL");
|
|
const exitedAfterKill = await session.supervisor.waitForExit(1_000);
|
|
if (exitedAfterKill)
|
|
return finishExitedDebuggerSession(
|
|
session,
|
|
debuggerName,
|
|
platform,
|
|
"stopped",
|
|
);
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} process did not exit after direct termination.`,
|
|
};
|
|
}
|
|
return finishExitedDebuggerSession(
|
|
session,
|
|
debuggerName,
|
|
platform,
|
|
"stopped",
|
|
);
|
|
};
|
|
|
|
/** What closing all sessions needs from a session it may have to retain. */
|
|
interface RetainableDebuggerSession {
|
|
readonly id: string;
|
|
readonly launched: {
|
|
readonly process: { readonly pid?: number | undefined };
|
|
};
|
|
readonly supervisor: { ref(): void; unref(): void };
|
|
}
|
|
|
|
/**
|
|
* Stop every owned session and retain failed sessions for possible recovery.
|
|
* Callers must block replacement admission and drain in-flight starts until
|
|
* this settles; incomplete cleanup leaves the old owner in the map for retry.
|
|
*
|
|
* Closing all sessions means their owner is finishing, so a retained session
|
|
* stops holding the host's event loop, as a supervised provider does after
|
|
* incomplete cleanup. Ownership in this process is unchanged, and a retry
|
|
* restores the references.
|
|
*/
|
|
export const closeOwnedDebuggerSessions = async <
|
|
Session extends RetainableDebuggerSession,
|
|
>(
|
|
sessions: Map<string, Session>,
|
|
stop: (session: Session) => Promise<OwnedDebuggerStopResult>,
|
|
debuggerName: string,
|
|
): Promise<void> => {
|
|
const entries = [...sessions.entries()];
|
|
for (const [, session] of entries) session.supervisor.ref();
|
|
const results = await Promise.allSettled(
|
|
entries.map(async ([, session]) => stop(session)),
|
|
);
|
|
const failures: Error[] = [];
|
|
const retained: Session[] = [];
|
|
for (const [index, settled] of results.entries()) {
|
|
const [key, session] = entries[index]!;
|
|
if (settled.status === "rejected") {
|
|
failures.push(
|
|
settled.reason instanceof Error
|
|
? settled.reason
|
|
: new Error(String(settled.reason)),
|
|
);
|
|
retained.push(session);
|
|
} else if (settled.value.status === "incomplete") {
|
|
failures.push(new Error(settled.value.reason));
|
|
retained.push(session);
|
|
} else if (sessions.get(key) === session) sessions.delete(key);
|
|
}
|
|
if (failures.length === 0) return;
|
|
for (const session of retained) session.supervisor.unref();
|
|
throw new OwnedDebuggerCleanupError(
|
|
failures,
|
|
`One or more owned ${debuggerName} processes did not stop cleanly`,
|
|
retained.flatMap(({ launched }) => launched.process.pid ?? []),
|
|
);
|
|
};
|