399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { PassThrough, Writable } from "node:stream";
|
|
|
|
import { z } from "zod";
|
|
|
|
import type { ProcessCleanupResult } from "../process/ProcessOwnership.js";
|
|
import {
|
|
requireWindowsNativeAuthority,
|
|
windowsHandleSchema,
|
|
type WindowsNativeAuthority,
|
|
} from "./WindowsNativeLoader.js";
|
|
|
|
const launchSchema = z.strictObject({
|
|
pid: z.number().int().positive().max(0xffff_ffff),
|
|
handle: windowsHandleSchema,
|
|
jobAssigned: z.literal(true),
|
|
killOnOwnerClose: z.literal(true),
|
|
});
|
|
const pollSchema = z.strictObject({
|
|
stdout: z.instanceof(Buffer),
|
|
stderr: z.instanceof(Buffer),
|
|
stdoutEnded: z.boolean(),
|
|
stderrEnded: z.boolean(),
|
|
exitCode: z.number().int().nonnegative().max(0xffff_ffff).nullable(),
|
|
activeProcesses: z.number().int().nonnegative(),
|
|
});
|
|
// A commit-limited job reports its applied limit at launch and its
|
|
// notification state on every poll; an unlimited job reports neither.
|
|
const limitedPollSchema = pollSchema.extend({
|
|
memoryLimitReached: z.boolean(),
|
|
peakJobMemoryBytes: z
|
|
.number()
|
|
.int()
|
|
.nonnegative()
|
|
.max(Number.MAX_SAFE_INTEGER),
|
|
});
|
|
|
|
/** Optional native job controls for one owned Windows process tree. */
|
|
export interface WindowsOwnedProcessOptions {
|
|
/** Open a writable protocol pipe; otherwise stdin is NUL. */
|
|
readonly protocolStdin?: boolean;
|
|
/** Job Object commit limit for the whole process tree, in bytes. */
|
|
readonly memoryLimitBytes?: number;
|
|
/** Verified native authority; defaults to the package-owned artifact. */
|
|
readonly authority?: WindowsNativeAuthority;
|
|
}
|
|
|
|
/** Encode one ordinary Windows argv token using the CRT backslash rules. */
|
|
export const quoteWindowsProcessArgument = (value: string): string => {
|
|
if (value.includes("\0"))
|
|
throw new TypeError("Windows process argument contains NUL");
|
|
return `"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`;
|
|
};
|
|
|
|
/** Process events and streams backed by a retained native Job Object. */
|
|
export class WindowsOwnedProcess extends EventEmitter {
|
|
readonly stdout = new PassThrough();
|
|
readonly stderr = new PassThrough();
|
|
readonly stdin: Writable | null;
|
|
readonly signalCode: NodeJS.Signals | null = null;
|
|
exitCode: number | null = null;
|
|
readonly pid: number;
|
|
readonly #handle: object;
|
|
readonly #authority: WindowsNativeAuthority;
|
|
readonly #settled: Promise<void>;
|
|
#settle: (() => void) | undefined;
|
|
#closed = false;
|
|
#failure: string | undefined;
|
|
#timer: NodeJS.Timeout | undefined;
|
|
#blockedWake: NodeJS.Timeout | undefined;
|
|
readonly #blockedOutput = new Set<PassThrough>();
|
|
#unreferenced = false;
|
|
#cleanup: Promise<ProcessCleanupResult> | undefined;
|
|
readonly #memoryLimitBytes: number | undefined;
|
|
#memoryLimitReached = false;
|
|
#peakJobMemoryBytes: number | undefined;
|
|
#outputIncomplete = false;
|
|
|
|
constructor(
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
cwd: string | undefined,
|
|
environment: NodeJS.ProcessEnv,
|
|
verbatim: boolean,
|
|
options: WindowsOwnedProcessOptions = {},
|
|
) {
|
|
super();
|
|
const protocolStdin = options.protocolStdin === true;
|
|
this.#memoryLimitBytes = options.memoryLimitBytes;
|
|
this.#authority = options.authority ?? requireWindowsNativeAuthority();
|
|
if (protocolStdin && this.#authority.inspection.protocolStdin !== true)
|
|
throw new Error(
|
|
"The bundled Windows native artifact does not support owned protocol stdin; use a matching current Windows x64 REA package.",
|
|
);
|
|
// A requested limit is never dropped: an artifact without the control
|
|
// refuses the launch instead of starting an unlimited process.
|
|
if (
|
|
this.#memoryLimitBytes !== undefined &&
|
|
this.#authority.inspection.jobMemoryLimit !== true
|
|
)
|
|
throw new Error(
|
|
"The bundled Windows native artifact does not support Job Object memory limits; use a matching current Windows x64 REA package.",
|
|
);
|
|
const entries = Object.entries(environment)
|
|
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
.map(([key, value]) => `${key}=${value}`);
|
|
const line = [
|
|
// cmd.exe parses its own command line rather than the CRT argv rules;
|
|
// its executable token must match the native drive-path representation.
|
|
quoteWindowsProcessArgument(command.replaceAll("/", "\\")),
|
|
...arguments_.map((value) =>
|
|
verbatim ? value : quoteWindowsProcessArgument(value),
|
|
),
|
|
].join(" ");
|
|
// Earlier artifacts accept only 4 or 5 arguments; keep that shape unless
|
|
// a commit limit is requested.
|
|
let controls: unknown[] = protocolStdin ? [true] : [];
|
|
if (this.#memoryLimitBytes !== undefined)
|
|
controls = [protocolStdin, this.#memoryLimitBytes];
|
|
const spawned = this.#authority.call("process_spawn", [
|
|
command,
|
|
line,
|
|
cwd ?? "",
|
|
entries,
|
|
...controls,
|
|
]);
|
|
const launched =
|
|
this.#memoryLimitBytes === undefined
|
|
? launchSchema.parse(spawned)
|
|
: launchSchema
|
|
.extend({ jobMemoryLimitBytes: z.literal(this.#memoryLimitBytes) })
|
|
.parse(spawned);
|
|
this.pid = launched.pid;
|
|
this.#handle = launched.handle;
|
|
this.stdin = protocolStdin
|
|
? new Writable({
|
|
write: (chunk: unknown, _encoding, callback) => {
|
|
void this.#writeInput(chunk).then(
|
|
() => callback(),
|
|
(cause: unknown) =>
|
|
callback(
|
|
cause instanceof Error ? cause : new Error(String(cause)),
|
|
),
|
|
);
|
|
},
|
|
final: (callback) => {
|
|
try {
|
|
this.#authority.call("process_stdin_close", [this.#handle]);
|
|
callback();
|
|
} catch (cause: unknown) {
|
|
callback(
|
|
cause instanceof Error ? cause : new Error(String(cause)),
|
|
);
|
|
}
|
|
},
|
|
destroy: (cause, callback) => {
|
|
try {
|
|
if (!this.#closed)
|
|
this.#authority.call("process_stdin_close", [this.#handle]);
|
|
callback(cause);
|
|
} catch (failure: unknown) {
|
|
callback(
|
|
failure instanceof Error ? failure : new Error(String(failure)),
|
|
);
|
|
}
|
|
},
|
|
})
|
|
: null;
|
|
this.#settled = new Promise((resolve) => {
|
|
this.#settle = resolve;
|
|
});
|
|
this.#schedule();
|
|
}
|
|
|
|
/** Whether the job reported reaching its commit limit; sticky once observed. */
|
|
get memoryLimitReached(): boolean {
|
|
return this.#memoryLimitReached;
|
|
}
|
|
|
|
/** Latest observed peak job commit; undefined for an unlimited job. */
|
|
get peakJobMemoryBytes(): number | undefined {
|
|
return this.#peakJobMemoryBytes;
|
|
}
|
|
|
|
/** True when cleanup destroyed a backpressured stream before all output was observed. */
|
|
get outputIncomplete(): boolean {
|
|
return this.#outputIncomplete;
|
|
}
|
|
|
|
async #writeInput(chunk: unknown): Promise<void> {
|
|
const bytes = z.instanceof(Buffer).parse(chunk);
|
|
const written = z
|
|
.number()
|
|
.int()
|
|
.nonnegative()
|
|
.max(Number.MAX_SAFE_INTEGER)
|
|
.parse(
|
|
await this.#authority.call("process_stdin_write", [
|
|
this.#handle,
|
|
bytes,
|
|
]),
|
|
);
|
|
if (written !== bytes.length)
|
|
throw new Error(
|
|
"Owned Windows input write did not preserve all requested bytes",
|
|
);
|
|
}
|
|
|
|
/** Terminate the retained job, never a PID supplied by another caller. */
|
|
kill(signal?: NodeJS.Signals | number): boolean {
|
|
if (signal === 0)
|
|
return (
|
|
!this.#closed &&
|
|
z
|
|
.number()
|
|
.parse(this.#authority.call("process_alive", [this.#handle])) > 0
|
|
);
|
|
return this.#closed
|
|
? false
|
|
: z
|
|
.boolean()
|
|
.parse(this.#authority.call("process_terminate", [this.#handle]));
|
|
}
|
|
|
|
/** Terminate and verify that the entire job has settled before releasing it. */
|
|
cleanup(): Promise<ProcessCleanupResult> {
|
|
this.#cleanup ??= this.#stop().then((result) => {
|
|
if (!result.cleaned) this.#cleanup = undefined;
|
|
return result;
|
|
});
|
|
return this.#cleanup;
|
|
}
|
|
|
|
/** Release event-loop references so unsettled jobs do not prevent host exit. */
|
|
unref(): void {
|
|
this.#unreferenced = true;
|
|
this.#timer?.unref();
|
|
this.#blockedWake?.unref();
|
|
}
|
|
|
|
/** Restore event-loop references when retrying process lifecycle observation. */
|
|
ref(): void {
|
|
this.#unreferenced = false;
|
|
this.#timer?.ref();
|
|
this.#blockedWake?.ref();
|
|
}
|
|
|
|
async #stop(): Promise<ProcessCleanupResult> {
|
|
if (this.#closed) return { cleaned: true, signaled: false };
|
|
let timer: NodeJS.Timeout | undefined;
|
|
try {
|
|
const signaled = this.kill();
|
|
this.#discardBlockedOutput();
|
|
this.#schedule();
|
|
const settled = await Promise.race([
|
|
this.#settled.then(() => true),
|
|
new Promise<boolean>((resolve) => {
|
|
timer = setTimeout(() => resolve(false), 5_000);
|
|
}),
|
|
]);
|
|
return settled
|
|
? { cleaned: true, signaled }
|
|
: {
|
|
cleaned: false,
|
|
reason:
|
|
this.#failure ??
|
|
"Windows Job Object did not settle within the cleanup deadline",
|
|
};
|
|
} catch (cause: unknown) {
|
|
return {
|
|
cleaned: false,
|
|
reason: cause instanceof Error ? cause.message : String(cause),
|
|
};
|
|
} finally {
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
#schedule(delay = 25): void {
|
|
if (
|
|
this.#closed ||
|
|
this.#timer !== undefined ||
|
|
this.#blockedOutput.size > 0
|
|
)
|
|
return;
|
|
this.#timer = setTimeout(() => this.#poll(), delay);
|
|
if (this.#unreferenced) this.#timer.unref();
|
|
}
|
|
|
|
#scheduleBlockedWake(): void {
|
|
if (
|
|
this.#closed ||
|
|
this.#blockedOutput.size === 0 ||
|
|
this.#blockedWake !== undefined
|
|
)
|
|
return;
|
|
this.#blockedWake = setTimeout(() => {
|
|
this.#blockedWake = undefined;
|
|
this.#scheduleBlockedWake();
|
|
}, 1_000);
|
|
if (this.#unreferenced) this.#blockedWake.unref();
|
|
}
|
|
|
|
#clearBlockedWake(): void {
|
|
if (this.#blockedWake !== undefined) clearTimeout(this.#blockedWake);
|
|
this.#blockedWake = undefined;
|
|
}
|
|
|
|
#unblockOutput(stream: PassThrough): void {
|
|
if (stream.destroyed) this.#outputIncomplete = true;
|
|
this.#blockedOutput.delete(stream);
|
|
if (this.#blockedOutput.size === 0) this.#clearBlockedWake();
|
|
this.#schedule(0);
|
|
}
|
|
|
|
#discardBlockedOutput(): void {
|
|
if (this.#blockedOutput.size > 0) this.#outputIncomplete = true;
|
|
for (const stream of this.#blockedOutput) stream.destroy();
|
|
this.#blockedOutput.clear();
|
|
this.#clearBlockedWake();
|
|
}
|
|
|
|
#poll(): void {
|
|
this.#timer = undefined;
|
|
if (this.#closed) return;
|
|
try {
|
|
const observed: unknown = this.#authority.call("process_poll", [
|
|
this.#handle,
|
|
]);
|
|
let state: z.output<typeof pollSchema>;
|
|
if (this.#memoryLimitBytes === undefined)
|
|
state = pollSchema.parse(observed);
|
|
else {
|
|
const limited = limitedPollSchema.parse(observed);
|
|
// Recorded before exit is emitted, so exit listeners observe it.
|
|
this.#memoryLimitReached ||= limited.memoryLimitReached;
|
|
this.#peakJobMemoryBytes = limited.peakJobMemoryBytes;
|
|
state = limited;
|
|
}
|
|
this.#failure = undefined;
|
|
// Stop native reads while either consumer is backpressured. A slower
|
|
// cadence still accumulates unbounded unread output in the parent heap.
|
|
const idle = state.stdout.length === 0 && state.stderr.length === 0;
|
|
this.#writeOutput(this.stdout, state.stdout);
|
|
this.#writeOutput(this.stderr, state.stderr);
|
|
if (state.exitCode !== null && this.exitCode === null) {
|
|
this.exitCode = state.exitCode;
|
|
this.emit("exit", state.exitCode, null);
|
|
}
|
|
if (
|
|
state.exitCode !== null &&
|
|
state.stdoutEnded &&
|
|
state.stderrEnded &&
|
|
state.activeProcesses === 0
|
|
) {
|
|
this.#release();
|
|
} else this.#schedule(idle ? 25 : 0);
|
|
} catch (cause: unknown) {
|
|
const failure = cause instanceof Error ? cause : new Error(String(cause));
|
|
this.#failure = failure.message;
|
|
// Native lifecycle failures belong to the error/cleanup channels, never
|
|
// the captured stderr observation produced by the child itself.
|
|
if (this.listenerCount("error") > 0) this.emit("error", failure);
|
|
// Retain the original job so explicit cleanup can retry its observation.
|
|
}
|
|
}
|
|
|
|
#writeOutput(stream: PassThrough, bytes: Buffer): void {
|
|
if (bytes.length === 0 || stream.writableEnded) return;
|
|
if (stream.destroyed) {
|
|
this.#outputIncomplete = true;
|
|
return;
|
|
}
|
|
if (stream.write(bytes)) return;
|
|
this.#blockedOutput.add(stream);
|
|
const unblock = () => {
|
|
stream.off("drain", unblock);
|
|
stream.off("close", unblock);
|
|
this.#unblockOutput(stream);
|
|
};
|
|
stream.once("drain", unblock);
|
|
stream.once("close", unblock);
|
|
this.#scheduleBlockedWake();
|
|
}
|
|
|
|
#release(): void {
|
|
if (this.#closed) return;
|
|
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
|
this.#clearBlockedWake();
|
|
this.#authority.call("process_close", [this.#handle]);
|
|
this.#closed = true;
|
|
this.stdin?.destroy();
|
|
if (!this.stdout.destroyed) this.stdout.end();
|
|
if (!this.stderr.destroyed) this.stderr.end();
|
|
this.#settle?.();
|
|
this.emit("close", this.exitCode, null);
|
|
}
|
|
}
|