Files
rea/src/process/ProviderProcess.ts
T
morluto e567033a71 fix(process): preserve completed observations and cleanup authority
Retain physical stream errors and late caller cancellation independently. Expose an exact failed Darwin preparation root for cleanup retry without closing the shared reader.

Validation: 24 owned-command tests, 6 cancellation controls, 2 targeted Darwin cleanup retry controls, and check:fast. Controlled macOS processes only; no new real JVM or Ghidra verification.

Refs #4561, #4638
2026-10-17 11:10:02 +08:00

840 lines
27 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import type { EventEmitter } from "node:events";
import type { Readable, Writable } from "node:stream";
import { WindowsOwnedProcess } from "../windows/WindowsOwnedProcess.js";
import type {
OwnedProcessGroup,
ProcessOwnershipBaseline,
ProcessCleanupFailure,
ProcessCleanupResult,
} from "./ProcessOwnership.js";
import {
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "./ProcessOwnershipObservation.js";
const DEFAULT_TERMINATION_GRACE_MS = 250;
const DEFAULT_KILL_GRACE_MS = 1_000;
/** Spawn coordinates shared by owned, long-lived provider processes. */
export interface OwnedProviderProcessSpawnOptions {
readonly command: string;
readonly arguments: readonly string[];
readonly runId: string;
/** Null disables argv-prefix matching for interpreter-driven launch scripts. */
readonly expectedCommand?: string | null;
/** Preserve a pre-quoted Windows command-interpreter invocation exactly. */
readonly windowsVerbatimArguments?: boolean;
readonly cwd?: string;
readonly env?: NodeJS.ProcessEnv;
/** Host platform used to choose detached process-group behavior. */
readonly platform?: NodeJS.Platform;
/** Host environment used as the base for the child environment. */
readonly hostEnvironment?: NodeJS.ProcessEnv;
/** Opt into a writable protocol stream; other providers retain ignored stdin. */
readonly stdin?: "pipe";
/**
* Commit limit in bytes for the whole owned process tree, enforced by its
* Windows Job Object. Other launchers cannot enforce it and refuse it.
*/
readonly memoryLimitBytes?: number;
/** Sweep exact run-token descendants during POSIX cleanup. */
readonly sweepTokenOwnedProcesses?: boolean;
/** Cancels native ownership preparation before provider process creation. */
readonly signal?: AbortSignal;
}
/** Spawned process paired with the identity proof required for group cleanup. */
export interface SpawnedOwnedProviderProcess {
readonly process: ProviderProcessHandle;
readonly ownership: OwnedProcessGroup;
readonly cleanup?: () => Promise<ProcessCleanupResult>;
}
/** Process events and streams required by provider lifecycle supervision. */
export interface ProviderProcessHandle extends Pick<
EventEmitter,
"on" | "once" | "off"
> {
readonly pid?: number | undefined;
readonly stdout: Readable | null;
readonly stdin?: Writable | null;
readonly stderr: Readable | null;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
/** True once an enforced process memory limit reported an allocation failure. */
readonly memoryLimitReached?: boolean;
/** True if a Windows backpressured stream was destroyed before output drained. */
readonly outputIncomplete?: boolean;
kill(signal?: NodeJS.Signals | number): boolean;
unref?(): void;
ref?(): void;
}
/** Process handle returned by a provider-specific launcher. */
export type ProviderProcessLaunch =
| {
readonly process: ProviderProcessHandle;
readonly ownsProcessLifetime: false;
readonly ownership?: never;
readonly cleanup?: never;
}
| {
readonly process: ProviderProcessHandle;
readonly ownsProcessLifetime: true;
readonly ownership?: OwnedProcessGroup;
readonly cleanup?: () => Promise<ProcessCleanupResult>;
};
/**
* Process exit recorded by the supervisor.
* Independent of retained stream diagnostics.
*/
export interface ProviderProcessExitObservation {
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
}
/** Detached diagnostics captured for one supervised provider process. */
export interface ProviderProcessSnapshot {
/** True when retained diagnostics omit bytes due to the budget or provider output loss. */
readonly diagnosticTruncated?: boolean;
readonly stdout: {
readonly text: string;
/** True when retained bytes cannot be represented exactly as UTF-8 text. */
readonly invalidUtf8?: true;
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
readonly bytes: number;
/** All bytes drained from this stream since construction or resetOutput. */
readonly observedBytes: number;
};
readonly stderr: {
readonly text: string;
/** True when retained bytes cannot be represented exactly as UTF-8 text. */
readonly invalidUtf8?: true;
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
readonly bytes: number;
readonly observedBytes: number;
};
readonly exitCode: number | null | undefined;
readonly signal: NodeJS.Signals | null | undefined;
/** Present only when the process tree reached its enforced memory limit. */
readonly memoryLimitReached?: true;
/** Present when a provider stream was destroyed before all bytes were observed. */
readonly outputIncomplete?: true;
}
declare const processOutputCheckpointBrand: unique symbol;
/** Opaque cursor into one supervisor's raw, byte-counted output history. */
export interface ProviderProcessOutputCheckpoint {
readonly [processOutputCheckpointBrand]: true;
}
/** Output produced after a checkpoint; incomplete when bytes were not retained or drained. */
export interface ProviderProcessOutputSince {
readonly snapshot: ProviderProcessSnapshot;
readonly complete: boolean;
}
const outputCheckpoints = new WeakMap<
ProviderProcessOutputCheckpoint,
{
readonly owner: ProviderProcessSupervisor;
readonly epoch: number;
readonly stdout: number;
readonly stderr: number;
}
>();
/** Resource-safe events emitted without imposing provider protocol semantics. */
export type ProviderProcessDiagnostic =
| {
readonly type: "output";
readonly stream: "stdout" | "stderr";
readonly bytes: number;
readonly totalBytes: number;
readonly truncated?: boolean;
}
| {
readonly type: "exit";
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
readonly snapshot: ProviderProcessSnapshot;
}
| {
readonly type: "error";
readonly message: string;
readonly cause?: Error;
};
/** Options for process lifecycle diagnostics. */
export interface ProviderProcessSupervisorOptions {
/** Retain stdout for diagnostics; disable when it carries a parsed protocol. Defaults to true. */
readonly captureStdout?: boolean;
/** Aggregate retained stdout/stderr byte budget; overflow is drained and reported, not retained. */
readonly maxDiagnosticBytes?: number;
readonly onDiagnostic?: (event: ProviderProcessDiagnostic) => void;
}
/** Optional result of provider-specific cleanup performed before stop. */
export interface ProviderProcessStopOptions {
readonly cleanupResult?: ProcessCleanupResult;
readonly terminationGraceMs?: number;
readonly killGraceMs?: number;
}
/** Exact disposition of one owned process stop attempt. */
export type ProviderProcessStopResult =
| {
readonly status:
| "not-owned"
| "already-exited"
| "verified-cleanup"
| "terminated"
| "killed";
}
| {
readonly status: "incomplete";
readonly reason: string;
readonly cleanupResult?: ProcessCleanupFailure;
};
/**
* Spawn a provider in an owned Windows job or dedicated POSIX process group.
*
* The caller remains responsible for persisting any ownership manifest and for
* selecting provider-specific command arguments or environment values.
*/
export const spawnOwnedProviderProcess = async (
options: OwnedProviderProcessSpawnOptions,
): Promise<SpawnedOwnedProviderProcess> => {
options.signal?.throwIfAborted();
await prepareProcessOwnershipInspection(options.signal);
options.signal?.throwIfAborted();
const platform = options.platform ?? process.platform;
const sweepTokenOwnedProcesses =
options.sweepTokenOwnedProcesses === true && platform !== "win32";
let captureBaseline: ProcessOwnershipBaseline | undefined;
if (sweepTokenOwnedProcesses) {
const capture = systemProcessOwnershipHost.captureBaseline;
if (capture === undefined)
throw new Error("Process ownership baseline is unavailable on this host");
captureBaseline = await capture(options.signal);
options.signal?.throwIfAborted();
}
const hostEnvironment = options.hostEnvironment ?? process.env;
const environment = {
...hostEnvironment,
...options.env,
REA_PROCESS_RUN_ID: options.runId,
};
if (platform === "win32" && process.platform === "win32") {
const child = new WindowsOwnedProcess(
options.command,
options.arguments,
options.cwd,
environment,
options.windowsVerbatimArguments ?? false,
{
protocolStdin: options.stdin === "pipe",
...(options.memoryLimitBytes === undefined
? {}
: { memoryLimitBytes: options.memoryLimitBytes }),
},
);
return {
process: child,
ownership: {
runId: options.runId,
leaderPid: child.pid,
processGroupId: child.pid,
expectedParentPid: process.pid,
},
cleanup: () => child.cleanup(),
};
}
if (options.memoryLimitBytes !== undefined)
throw new Error(
"Owned process memory limits require the Windows Job Object launcher; no limit can be enforced on this host.",
);
const child = spawn(options.command, [...options.arguments], {
shell: false,
windowsHide: true,
stdio: [options.stdin ?? "ignore", "pipe", "pipe"],
detached: platform !== "win32",
windowsVerbatimArguments: options.windowsVerbatimArguments ?? false,
env: environment,
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
});
await waitForSpawn(child);
const pid = child.pid;
if (pid === undefined) throw new Error("Provider launcher has no process ID");
const ownership: OwnedProcessGroup = {
runId: options.runId,
leaderPid: pid,
processGroupId: pid,
expectedParentPid: process.pid,
...(sweepTokenOwnedProcesses
? {
sweepTokenOwnedProcesses: true,
captureBaseline: captureBaseline!,
}
: {}),
...(options.expectedCommand === null
? {}
: { expectedCommand: options.expectedCommand ?? options.command }),
};
return { process: child, ownership };
};
/**
* Supervises only generic process resources; wire protocol remains adapter-owned.
*
* Output retention is complete per stream; adapters own resource budgets. Stop
* first honors token-verified group cleanup when supplied, otherwise it uses a
* bounded TERM-to-KILL escalation for a directly owned child.
*/
export class ProviderProcessSupervisor {
readonly #options: ProviderProcessSupervisorOptions;
readonly #stdout = new ProcessOutputCapture();
readonly #stderr = new ProcessOutputCapture();
readonly #exit = deferred<ProviderProcessExitObservation>();
readonly #outputClose = deferred<void>();
#outputClosed = false;
#stdoutObservedBytes = 0;
#stderrObservedBytes = 0;
#diagnosticTruncated = false;
readonly #onStdout = (chunk: Buffer | string): void => {
this.#capture("stdout", this.#stdout, chunk);
};
readonly #onStderr = (chunk: Buffer | string): void => {
this.#capture("stderr", this.#stderr, chunk);
};
readonly #onStdoutError = (cause: Error): void => {
this.#recordStreamError("stdout", cause);
};
readonly #onStderrError = (cause: Error): void => {
this.#recordStreamError("stderr", cause);
};
readonly #onExit = (
code: number | null,
signal: NodeJS.Signals | null,
): void => {
this.#recordExit(code, signal);
};
readonly #onClose = (
code: number | null,
signal: NodeJS.Signals | null,
): void => {
this.#recordExit(code, signal);
this.#outputClosed = true;
this.#outputClose.resolve(undefined);
};
readonly #onError = (cause: Error): void => {
this.#options.onDiagnostic?.({
type: "error",
message: cause.message,
cause,
});
};
#exitObservation: ProviderProcessExitObservation | undefined;
#stopPromise: Promise<ProviderProcessStopResult> | undefined;
#disposed = false;
#maxDiagnosticBytes: number | undefined;
#outputEpoch = 0;
constructor(
readonly launch: ProviderProcessLaunch,
options: ProviderProcessSupervisorOptions = {},
) {
this.#options = options;
this.#maxDiagnosticBytes = options.maxDiagnosticBytes;
if (options.captureStdout !== false)
this.#attach(launch.process.stdout, "stdout");
this.#attach(launch.process.stderr, "stderr");
launch.process.once("exit", this.#onExit);
launch.process.once("close", this.#onClose);
launch.process.on("error", this.#onError);
if (launch.process.exitCode !== null || launch.process.signalCode !== null)
this.#recordExit(launch.process.exitCode, launch.process.signalCode);
}
/**
* Recorded process exit without concatenating retained stream diagnostics.
* Undefined until exit or close is observed.
*/
observedExit(): ProviderProcessExitObservation | undefined {
const exit = this.#exitObservation;
if (exit === undefined) return undefined;
return { code: exit.code, signal: exit.signal };
}
/** Latest retained output and exit observation, with explicit configured truncation. */
snapshot(): ProviderProcessSnapshot {
const outputIncomplete = this.launch.process.outputIncomplete === true;
return {
...(this.#diagnosticTruncated || outputIncomplete
? { diagnosticTruncated: true }
: {}),
stdout: this.#stdout.snapshot(this.#stdoutObservedBytes),
stderr: this.#stderr.snapshot(this.#stderrObservedBytes),
exitCode: this.#exitObservation?.code,
signal: this.#exitObservation?.signal,
...(this.launch.process.memoryLimitReached === true
? { memoryLimitReached: true }
: {}),
...(outputIncomplete ? { outputIncomplete: true } : {}),
};
}
/** Mark the current raw-byte position without copying or retaining output. */
checkpointOutput(): ProviderProcessOutputCheckpoint {
const checkpoint = Object.freeze({}) as ProviderProcessOutputCheckpoint;
outputCheckpoints.set(checkpoint, {
owner: this,
epoch: this.#outputEpoch,
stdout: this.#stdoutObservedBytes,
stderr: this.#stderrObservedBytes,
});
return checkpoint;
}
/** Snapshot only bytes produced after a valid checkpoint on this supervisor. */
snapshotOutputSince(
checkpoint: ProviderProcessOutputCheckpoint,
): ProviderProcessOutputSince {
const position =
typeof checkpoint === "object" && checkpoint !== null
? outputCheckpoints.get(checkpoint)
: undefined;
if (
position === undefined ||
position.owner !== this ||
position.epoch !== this.#outputEpoch
)
return {
snapshot: {
stdout: { text: "", bytes: 0, observedBytes: 0 },
stderr: { text: "", bytes: 0, observedBytes: 0 },
exitCode: this.#exitObservation?.code,
signal: this.#exitObservation?.signal,
outputIncomplete: true,
diagnosticTruncated: true,
},
complete: false,
};
const stdout = this.#stdout.snapshotRange(
position.stdout,
this.#stdoutObservedBytes,
);
const stderr = this.#stderr.snapshotRange(
position.stderr,
this.#stderrObservedBytes,
);
const incomplete =
stdout.complete === false ||
stderr.complete === false ||
this.launch.process.outputIncomplete === true;
return {
snapshot: {
...(incomplete || this.#diagnosticTruncated
? { diagnosticTruncated: true }
: {}),
stdout: stdout.stream,
stderr: stderr.stream,
exitCode: this.#exitObservation?.code,
signal: this.#exitObservation?.signal,
...(this.launch.process.memoryLimitReached === true
? { memoryLimitReached: true }
: {}),
...(incomplete ? { outputIncomplete: true } : {}),
},
complete: !incomplete && !this.#diagnosticTruncated,
};
}
/** Begin a new observation on a retained process without retaining earlier output. */
resetOutput(): void {
this.#outputEpoch += 1;
this.#stdout.reset();
this.#stderr.reset();
this.#stdoutObservedBytes = 0;
this.#stderrObservedBytes = 0;
this.#diagnosticTruncated = false;
}
/** Wait for process exit up to a caller-owned bounded interval. */
async waitForExit(timeoutMs: number): Promise<boolean> {
return waitForProcessEvent(
this.#exitObservation !== undefined,
this.#exit.promise,
timeoutMs,
);
}
/** Wait until output streams close; WindowsOwnedProcess closes only after its whole Job Object settles. */
async waitForOutputClose(
timeoutMs: number,
signal?: AbortSignal,
): Promise<boolean> {
return waitForProcessEvent(
this.#outputClosed,
this.#outputClose.promise,
timeoutMs,
signal,
);
}
/** Bound additional retained bytes while still draining all later output. */
limitAdditionalOutputBytes(maxBytes: number): void {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
throw new RangeError(
"Additional output limit must be a non-negative safe integer.",
);
const retained = this.#stdout.bytes + this.#stderr.bytes;
const requestedLimit = Math.min(
Number.MAX_SAFE_INTEGER,
retained + maxBytes,
);
this.#maxDiagnosticBytes =
this.#maxDiagnosticBytes === undefined
? requestedLimit
: Math.min(this.#maxDiagnosticBytes, requestedLimit);
}
/** Unreference inherited output pipes without closing streams shared with descendants. */
unrefOutputStreams(): void {
for (const stream of [
this.launch.process.stdout,
this.launch.process.stderr,
]) {
const unref = (stream as (Readable & { unref?: () => void }) | null)
?.unref;
unref?.call(stream);
}
}
/** Share each stop attempt; incomplete cleanup retains ownership and permits retry. */
stop(
options: ProviderProcessStopOptions = {},
): Promise<ProviderProcessStopResult> {
this.#stopPromise ??= this.#stop(options).then((result) => {
if (result.status === "incomplete") this.#stopPromise = undefined;
return result;
});
return this.#stopPromise;
}
/** Detach capture and process listeners after an adapter releases ownership. */
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.launch.process.stdout?.off("data", this.#onStdout);
this.launch.process.stdout?.off("error", this.#onStdoutError);
this.launch.process.stderr?.off("data", this.#onStderr);
this.launch.process.stderr?.off("error", this.#onStderrError);
this.launch.process.off("exit", this.#onExit);
this.launch.process.off("close", this.#onClose);
this.launch.process.off("error", this.#onError);
}
/** Release event-loop references so incomplete cleanup does not prevent host exit. */
unref(): void {
const process = this.launch.process;
process.unref?.();
(process.stdin as { unref?: () => void } | null | undefined)?.unref?.();
(process.stdout as { unref?: () => void } | null | undefined)?.unref?.();
(process.stderr as { unref?: () => void } | null | undefined)?.unref?.();
}
/** Restore event-loop references when retrying supervised process cleanup. */
ref(): void {
const process = this.launch.process;
process.ref?.();
(process.stdin as { ref?: () => void } | null | undefined)?.ref?.();
(process.stdout as { ref?: () => void } | null | undefined)?.ref?.();
(process.stderr as { ref?: () => void } | null | undefined)?.ref?.();
}
async #stop(
options: ProviderProcessStopOptions,
): Promise<ProviderProcessStopResult> {
this.ref();
let completed = false;
try {
const result = await this.#stopWithin(options);
completed = result.status !== "incomplete";
if (!completed) this.unref();
return result;
} finally {
if (completed) this.dispose();
}
}
async #stopWithin(
options: ProviderProcessStopOptions,
): Promise<ProviderProcessStopResult> {
try {
if (!this.launch.ownsProcessLifetime) return { status: "not-owned" };
if (this.launch.cleanup !== undefined) {
const cleanup = await this.#ownedCleanup(options.cleanupResult);
if (!cleanup.cleaned)
return {
status: "incomplete",
reason: cleanup.reason,
cleanupResult: cleanup,
};
const graceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
if (await this.waitForExit(graceMs))
return { status: "verified-cleanup" };
const retried = await this.launch.cleanup();
if (!retried.cleaned)
return {
status: "incomplete",
reason: retried.reason,
cleanupResult: retried,
};
if (await this.waitForExit(graceMs))
return { status: "verified-cleanup" };
return {
status: "incomplete",
reason: "verified process-group cleanup did not stop the launcher",
};
}
if (this.#exitObservation !== undefined)
return { status: "already-exited" };
this.#signal("SIGTERM");
if (
await this.waitForExit(
options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS,
)
)
return { status: "terminated" };
this.#signal("SIGKILL");
if (await this.waitForExit(options.killGraceMs ?? DEFAULT_KILL_GRACE_MS))
return { status: "killed" };
return {
status: "incomplete",
reason: "owned provider process did not exit after SIGKILL",
};
} catch (cause: unknown) {
return {
status: "incomplete",
reason:
cause instanceof Error
? cause.message
: "owned provider process cleanup failed",
};
}
}
#ownedCleanup(
supplied: ProcessCleanupResult | undefined,
): Promise<ProcessCleanupResult> {
if (supplied !== undefined) return Promise.resolve(supplied);
const cleanup = this.launch.cleanup;
return cleanup === undefined
? Promise.resolve({
cleaned: false,
reason: "owned provider process has no cleanup capability",
})
: cleanup();
}
#signal(signal: NodeJS.Signals): void {
if (this.#exitObservation !== undefined) return;
this.launch.process.kill(signal);
}
#attach(stream: Readable | null, name: "stdout" | "stderr"): void {
if (name === "stdout") {
stream?.on("data", this.#onStdout);
stream?.on("error", this.#onStdoutError);
} else {
stream?.on("data", this.#onStderr);
stream?.on("error", this.#onStderrError);
}
}
#recordStreamError(stream: "stdout" | "stderr", cause: Error): void {
this.#options.onDiagnostic?.({
type: "error",
message: `${stream} stream failed: ${cause.message}`,
cause,
});
}
#capture(
stream: "stdout" | "stderr",
capture: ProcessOutputCapture,
chunk: Buffer | string,
): void {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (stream === "stdout") this.#stdoutObservedBytes += bytes.byteLength;
else this.#stderrObservedBytes += bytes.byteLength;
const remaining =
this.#maxDiagnosticBytes === undefined
? bytes.byteLength
: Math.max(
0,
this.#maxDiagnosticBytes - this.#stdout.bytes - this.#stderr.bytes,
);
const retained = Math.min(bytes.byteLength, remaining);
if (retained < bytes.byteLength) this.#diagnosticTruncated = true;
if (retained > 0) capture.append(bytes.subarray(0, retained));
this.#options.onDiagnostic?.({
type: "output",
stream,
bytes: bytes.byteLength,
totalBytes:
stream === "stdout"
? this.#stdoutObservedBytes
: this.#stderrObservedBytes,
...(retained < bytes.byteLength ? { truncated: true } : {}),
});
}
#recordExit(code: number | null, signal: NodeJS.Signals | null): void {
if (this.#exitObservation !== undefined) return;
this.#exitObservation = { code, signal };
this.#exit.resolve(this.#exitObservation);
this.#options.onDiagnostic?.({
type: "exit",
code,
signal,
snapshot: this.snapshot(),
});
}
}
class ProcessOutputCapture {
readonly #chunks: Buffer[] = [];
#bytes = 0;
get bytes(): number {
return this.#bytes;
}
reset(): void {
this.#chunks.length = 0;
this.#bytes = 0;
}
append(chunk: Buffer): void {
this.#bytes += chunk.byteLength;
this.#chunks.push(Buffer.from(chunk));
}
snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] {
return this.#snapshotBuffer(
Buffer.concat(this.#chunks, this.#bytes),
observedBytes,
);
}
snapshotRange(
start: number,
end: number,
): { stream: ProviderProcessSnapshot["stdout"]; complete: boolean } {
const availableEnd = Math.min(end, this.#bytes);
const availableStart = Math.min(start, availableEnd);
const chunks: Buffer[] = [];
let offset = 0;
for (const chunk of this.#chunks) {
const chunkEnd = offset + chunk.byteLength;
const from = Math.max(availableStart, offset);
const to = Math.min(availableEnd, chunkEnd);
if (to > from) chunks.push(chunk.subarray(from - offset, to - offset));
offset = chunkEnd;
if (offset >= availableEnd) break;
}
const buffer = Buffer.concat(chunks, availableEnd - availableStart);
return {
stream: this.#snapshotBuffer(buffer, Math.max(0, end - start)),
complete: start <= this.#bytes && end <= this.#bytes,
};
}
#snapshotBuffer(
buffer: Buffer,
observedBytes: number,
): ProviderProcessSnapshot["stdout"] {
const text = buffer.toString("utf8");
let invalidUtf8 = false;
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
} catch {
invalidUtf8 = true;
}
return {
text,
bytes: buffer.byteLength,
observedBytes,
...(invalidUtf8 ? { invalidUtf8: true } : {}),
};
}
}
interface Deferred<Value> {
readonly promise: Promise<Value>;
readonly resolve: (value: Value) => void;
}
const deferred = <Value>(): Deferred<Value> => {
let resolver: ((value: Value) => void) | undefined;
const promise = new Promise<Value>((resolve) => {
resolver = resolve;
});
return {
promise,
resolve: (value) => {
resolver?.(value);
},
};
};
const waitForSpawn = (child: ChildProcess): Promise<void> =>
new Promise((resolve, reject) => {
const onSpawn = (): void => {
child.off("error", onError);
resolve();
};
const onError = (cause: Error): void => {
child.off("spawn", onSpawn);
reject(cause);
};
child.once("spawn", onSpawn);
child.once("error", onError);
});
const waitForProcessEvent = async (
observed: boolean,
event: Promise<unknown>,
timeoutMs: number,
signal?: AbortSignal,
): Promise<boolean> => {
if (observed) return true;
if (signal?.aborted === true) return false;
let timer: NodeJS.Timeout | undefined;
let onAbort: (() => void) | undefined;
try {
return await Promise.race([
event.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs));
if (signal !== undefined) {
onAbort = () => resolve(false);
signal.addEventListener("abort", onAbort, { once: true });
}
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
if (onAbort !== undefined) signal?.removeEventListener("abort", onAbort);
}
};