mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
43ac3d1857
* fix(windows): retain native handles whose close is unconfirmed CloseHandle failure no longer drops the numeric value. A still-open handle stays owned and can be closed again; a value missing from the handle table is dropped and never closed again; unreadable liveness blocks another close until a probe says the value is open. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(process): keep scratch until inherited pipes close Lock the execFileOutput settlement contract: an inherited-pipe descendant holds the caller's scratch root until it exits, and destroying the readers is not process-family completion. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(windows): retry admitted file closes before runtime removal A failed private-file close stays on the runtime that opened it. Runtime removal closes that same wrapper before the tree walk, including when the read that opened the file has already returned. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(windows): resume a close after part of the owner is released A runtime whose directory handle is already released continues with its parent handles instead of walking that invalid root. A process whose settlement was already observed retries process close alone, without kill or poll against the released job. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(process): signal only an owned inherited-pipe descendant Skip the descendant SIGKILL after pipe settlement. On an earlier failure, signal only when the process command line still contains that test's path. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(windows): do not close a handle again after a failed close An open liveness probe can observe a reused numeric value. After CloseHandle fails, drop the value only when the handle table no longer contains it, and do not call CloseHandle on that number again. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(windows): stop using a close-blocked pipe for I/O A broken pipe whose CloseHandle fails stays owned, but its numeric value may have been reused. Later polls and stdin writes treat that pipe as ended instead of reading or writing the number. 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>
221 lines
7.8 KiB
TypeScript
221 lines
7.8 KiB
TypeScript
import {
|
|
execFile,
|
|
type ExecFileOptionsWithBufferEncoding,
|
|
} from "node:child_process";
|
|
|
|
type ExecFileOutputOptions = Omit<
|
|
ExecFileOptionsWithBufferEncoding,
|
|
"encoding" | "maxBuffer"
|
|
> & {
|
|
readonly maxBuffer?: number;
|
|
/**
|
|
* Signal that stops the process on abort or timeout. The promise then
|
|
* settles only after that process's stdio pipes close, so a caller can
|
|
* remove files the command wrote. A descendant that inherited those pipes
|
|
* keeps them open until it exits; destroying the readers is not
|
|
* process-family completion and must not unblock this settlement. A process
|
|
* still running after a grace period is killed. Without this signal, Node's
|
|
* execFile sends SIGTERM on abort and rejects before the process exits.
|
|
*/
|
|
readonly stopSignal?: NodeJS.Signals;
|
|
};
|
|
|
|
/** How long a stopped process may take to exit before it is killed. */
|
|
const STOP_GRACE_MS = 5_000;
|
|
|
|
/** Cancellation observed by the command owner, with the caller's exact reason. */
|
|
export class ExecFileOutputCancelledError extends Error {
|
|
readonly _tag = "ExecFileOutputCancelledError";
|
|
readonly code = "ABORT_ERR";
|
|
|
|
constructor(reason: unknown) {
|
|
super("The operation was aborted", { cause: reason });
|
|
this.name = "AbortError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A command stopped at its timeout can still exit cleanly, so the timeout is
|
|
* the failure. Like Node's execFile timeout, it reports `killed: true`.
|
|
*/
|
|
const timeoutError = (
|
|
command: string,
|
|
timeout: number,
|
|
exit: Error | null,
|
|
): Error => {
|
|
const error = new Error(
|
|
`Command timed out after ${String(timeout)} ms: ${command}`,
|
|
exit === null ? undefined : { cause: exit },
|
|
);
|
|
Reflect.set(error, "code", "ETIMEDOUT");
|
|
Reflect.set(error, "killed", true);
|
|
Reflect.set(
|
|
error,
|
|
"signal",
|
|
exit === null ? null : (Reflect.get(exit, "signal") ?? null),
|
|
);
|
|
return error;
|
|
};
|
|
|
|
/**
|
|
* The `timeout` contract for both modes, checked before spawning. A stoppable
|
|
* command keeps its own deadline, so execFile never validates it. Node 26's
|
|
* execFile also caps it at Number.MAX_SAFE_INTEGER; Node 22 and 24 accept
|
|
* larger integers, which their timers then clamp to 1 ms.
|
|
*/
|
|
const invalidTimeout = (timeout: number): RangeError | undefined => {
|
|
if (Number.isSafeInteger(timeout) && timeout >= 0) return undefined;
|
|
const error = new RangeError(
|
|
`The value of "timeout" is out of range. It must be an unsigned integer. Received ${String(timeout)}`,
|
|
);
|
|
Reflect.set(error, "code", "ERR_OUT_OF_RANGE");
|
|
return error;
|
|
};
|
|
|
|
/** Captured subprocess output read from an execFileOutput rejection. */
|
|
export interface ExecFileOutputFailure {
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
readonly code: string | number | null;
|
|
readonly signal: string | null;
|
|
readonly killed: boolean;
|
|
readonly outputTruncated: boolean;
|
|
readonly stdoutBytes?: number;
|
|
readonly stderrBytes?: number;
|
|
readonly collectionFailure?: boolean;
|
|
}
|
|
|
|
/** Read captured output metadata from a failed execFileOutput invocation. */
|
|
export const execFileOutputFailure = (
|
|
cause: unknown,
|
|
): ExecFileOutputFailure | undefined => {
|
|
if (!(cause instanceof Error)) return undefined;
|
|
const stdout = Reflect.get(cause, "stdout");
|
|
const stderr = Reflect.get(cause, "stderr");
|
|
const code = Reflect.get(cause, "code");
|
|
const signal = Reflect.get(cause, "signal");
|
|
const killed = Reflect.get(cause, "killed");
|
|
const outputTruncated = Reflect.get(cause, "outputTruncated");
|
|
const stdoutBytes = Reflect.get(cause, "stdoutBytes");
|
|
const stderrBytes = Reflect.get(cause, "stderrBytes");
|
|
const collectionFailure = Reflect.get(cause, "collectionFailure");
|
|
if (typeof stdout !== "string" || typeof stderr !== "string")
|
|
return undefined;
|
|
return {
|
|
stdout,
|
|
stderr,
|
|
...(typeof stdoutBytes === "number" &&
|
|
Number.isSafeInteger(stdoutBytes) &&
|
|
stdoutBytes >= 0
|
|
? { stdoutBytes }
|
|
: {}),
|
|
...(typeof stderrBytes === "number" &&
|
|
Number.isSafeInteger(stderrBytes) &&
|
|
stderrBytes >= 0
|
|
? { stderrBytes }
|
|
: {}),
|
|
code: typeof code === "string" || typeof code === "number" ? code : null,
|
|
signal: typeof signal === "string" ? signal : null,
|
|
killed: killed === true,
|
|
outputTruncated:
|
|
outputTruncated === true || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
...(collectionFailure === true ? { collectionFailure: true } : {}),
|
|
};
|
|
};
|
|
|
|
/** Run a shell-free command while capturing its complete output bytes. */
|
|
export const execFileBytes = (
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
options: ExecFileOutputOptions = {},
|
|
): Promise<{ readonly stdout: Buffer; readonly stderr: Buffer }> =>
|
|
new Promise((resolve, reject) => {
|
|
const { stopSignal, signal, timeout, ...execOptions } = options;
|
|
const invalid = timeout === undefined ? undefined : invalidTimeout(timeout);
|
|
if (invalid !== undefined) {
|
|
reject(invalid);
|
|
return;
|
|
}
|
|
if (stopSignal !== undefined && signal?.aborted === true) {
|
|
reject(new ExecFileOutputCancelledError(signal.reason));
|
|
return;
|
|
}
|
|
let stopped: "abort" | "timeout" | undefined;
|
|
let deadline: NodeJS.Timeout | undefined;
|
|
let grace: NodeJS.Timeout | undefined;
|
|
const child = execFile(
|
|
command,
|
|
[...arguments_],
|
|
{
|
|
...execOptions,
|
|
// A stoppable command's abort and timeout are handled below, because
|
|
// execFile neither waits for the exit on abort nor kills a process
|
|
// that ignores its timeout signal.
|
|
...(stopSignal === undefined
|
|
? {
|
|
...(signal === undefined ? {} : { signal }),
|
|
...(timeout === undefined ? {} : { timeout }),
|
|
}
|
|
: { killSignal: stopSignal }),
|
|
encoding: "buffer",
|
|
maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
clearTimeout(deadline);
|
|
clearTimeout(grace);
|
|
const failure =
|
|
stopped === "abort" && signal !== undefined
|
|
? new ExecFileOutputCancelledError(signal.reason)
|
|
: stopped === "timeout" && timeout !== undefined
|
|
? timeoutError(command, timeout, error)
|
|
: error?.name === "AbortError" && error.code === "ABORT_ERR"
|
|
? new ExecFileOutputCancelledError(error.cause)
|
|
: error;
|
|
if (failure !== null) {
|
|
Reflect.set(failure, "stdout", stdout.toString("utf8"));
|
|
Reflect.set(failure, "stderr", stderr.toString("utf8"));
|
|
Reflect.set(failure, "stdoutBytes", stdout.byteLength);
|
|
Reflect.set(failure, "stderrBytes", stderr.byteLength);
|
|
reject(failure);
|
|
return;
|
|
}
|
|
resolve({ stdout, stderr });
|
|
},
|
|
);
|
|
// The first stop request names the failure; a later one is not resent.
|
|
const stop = (reason: "abort" | "timeout"): void => {
|
|
if (stopped !== undefined) return;
|
|
stopped = reason;
|
|
child.kill(stopSignal);
|
|
grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
|
|
};
|
|
const onAbort = (): void => {
|
|
stop("abort");
|
|
};
|
|
if (stopSignal === undefined) return;
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
if (timeout !== undefined && timeout > 0)
|
|
deadline = setTimeout(() => {
|
|
stop("timeout");
|
|
}, timeout);
|
|
});
|
|
|
|
/** Run a shell-free command while capturing its complete UTF-8 output. */
|
|
export const execFileOutput = (
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
options: ExecFileOutputOptions = {},
|
|
): Promise<{
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
readonly stdoutBytes: number;
|
|
readonly stderrBytes: number;
|
|
}> =>
|
|
execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({
|
|
stdout: stdout.toString("utf8"),
|
|
stderr: stderr.toString("utf8"),
|
|
stdoutBytes: stdout.byteLength,
|
|
stderrBytes: stderr.byteLength,
|
|
}));
|