2398 lines · 64.6 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto";import { rmSync } from "node:fs";import { constants as osConstants } from "node:os";import { posix as posixPath } from "node:path";import type { NativeMountPluginDescriptor } from "./descriptors.js";import type {	ConnectTerminalOptions,	Kernel,	KernelExecOptions,	KernelExecResult,	KernelSpawnOptions,	ManagedProcess,	OpenShellOptions,	ProcessInfo,	ShellHandle,	VirtualFileSystem,	VirtualStat,} from "./test-runtime.js";import type {	AuthenticatedSession,	CreatedVm,	GuestFilesystemStat,	SidecarProcess,	SidecarMountDescriptor,	SidecarProcessSnapshotEntry,	SidecarResourceSnapshot,	SidecarSignalHandlerRegistration,	SidecarSocketStateEntry,} from "./sidecar-process.js"; export interface PlainMountConfig {	path: string;	driver: VirtualFileSystem;	guestSource?: string;	guestFstype?: string;	readOnly?: boolean;} export interface NativeMountConfig {	path: string;	plugin: NativeMountPluginDescriptor;	guestSource?: string;	guestFstype?: string;	readOnly?: boolean;} export function serializeMountConfigForSidecar(	mount: PlainMountConfig | NativeMountConfig,): SidecarMountDescriptor {	if ("driver" in mount) {		return {			guestPath: mount.path,			guestSource: mount.guestSource ?? "js_bridge",			guestFstype: mount.guestFstype ?? "js_bridge",			readOnly: mount.readOnly ?? false,			plugin: {				id: "js_bridge",				config: {},			},		};	} 	return {		guestPath: mount.path,		guestSource: mount.guestSource ?? mount.plugin.id,		guestFstype: mount.guestFstype ?? mount.plugin.id,		readOnly: mount.readOnly ?? false,		plugin: {			id: mount.plugin.id,			config: mount.plugin.config ?? {},		},	};} const SYNTHETIC_PID_BASE = 1_000_000;const MISSING_EXIT_EVENT_GRACE_MS = 500;const PROTECTED_READ_ONLY_GUEST_ROOTS = ["/etc/secure-exec"] as const;const TRAILING_OUTPUT_DRAIN_INTERVAL_MS = 10;const TRAILING_OUTPUT_DRAIN_MAX_MS = 250;const TRAILING_OUTPUT_DRAIN_QUIET_TURNS = 2; async function drainTrailingProcessOutputTurn(	delayMs = 0,): Promise<void> {	// Native-sidecar `process_output` events can lag one macrotask behind the	// terminal `process_exited` notification for very short-lived processes, and	// under suite load the sidecar event pump can need a little extra time to	// flush delayed output through its listener callbacks.	await new Promise<void>((resolve) => {		setTimeout(resolve, delayMs);	});} const PREFERRED_SIGNAL_NAMES = [	"SIGHUP",	"SIGINT",	"SIGQUIT",	"SIGILL",	"SIGTRAP",	"SIGABRT",	"SIGBUS",	"SIGFPE",	"SIGKILL",	"SIGUSR1",	"SIGSEGV",	"SIGUSR2",	"SIGPIPE",	"SIGALRM",	"SIGTERM",	"SIGSTKFLT",	"SIGCHLD",	"SIGCONT",	"SIGSTOP",	"SIGTSTP",	"SIGTTIN",	"SIGTTOU",	"SIGURG",	"SIGXCPU",	"SIGXFSZ",	"SIGVTALRM",	"SIGPROF",	"SIGWINCH",	"SIGIO",	"SIGPWR",	"SIGSYS",	"SIGEMT",	"SIGINFO",] as const;const NON_TERMINATING_SIGNALS = new Set([	"0",	"SIGCHLD",	"SIGCONT",	"SIGSTOP",	"SIGURG",	"SIGWINCH",]);const LOCALLY_TERMINATING_SIGNALS = new Set(["SIGKILL", "SIGTERM"]);const NON_CANONICAL_SIGNAL_NAMES = new Set([	"SIGCLD",	"SIGIOT",	"SIGPOLL",	"SIGUNUSED",]);const SIGNAL_NAME_BY_NUMBER = buildSignalNameByNumber();const DOUBLE_QUOTE_ESCAPABLE_CHARACTERS = new Set(['"', "\\", "$", "`"]);function appendDoubleQuotedEscape(current: string, character: string): string {	if (DOUBLE_QUOTE_ESCAPABLE_CHARACTERS.has(character)) {		return current + character;	}	if (character === "\n") {		return current;	}	return `${current}\\${character}`;} function parseSimpleExecCommand(command: string): string[] | null {	const tokens: string[] = [];	let current = "";	let quote: "'" | '"' | null = null;	let escaped = false; 	for (const character of command) {		if (quote === null) {			if (escaped) {				current += character;				escaped = false;				continue;			}			if (character === "\\") {				escaped = true;				continue;			}			if (character === "'" || character === '"') {				quote = character;				continue;			}			if (/\s/.test(character)) {				if (current) {					tokens.push(current);					current = "";				}				continue;			}			if ("|&;<>()$`*?[]{}~!".includes(character)) {				return null;			}			current += character;			continue;		} 		if (quote === "'") {			if (character === "'") {				quote = null;				continue;			}			current += character;			continue;		} 		if (escaped) {			current = appendDoubleQuotedEscape(current, character);			escaped = false;			continue;		}		if (character === "\\") {			escaped = true;			continue;		}		if (character === '"') {			quote = null;			continue;		}		if (character === "$" || character === "`") {			return null;		}		current += character;	} 	if (quote !== null || escaped) {		return null;	}	if (current) {		tokens.push(current);	}	if (tokens.length === 0) {		return null;	}	if (tokens.some((token) => token.length === 0)) {		return null;	}	return tokens;} function canUseDirectExec(	driver: string | undefined,	commandName: string | undefined,): boolean {	return driver === "wasmvm" || (driver === "node" && commandName === "node");} function shellSingleQuote(value: string): string {	if (value.length === 0) {		return "''";	}	return `'${value.replace(/'/g, `'\"'\"'`)}'`;} function buildSignalNameByNumber(): Map<number, string> {	const signals = osConstants.signals as Record<string, number | undefined>;	const names = new Map<number, string>();	for (const name of PREFERRED_SIGNAL_NAMES) {		const value = signals[name];		if (typeof value === "number") {			names.set(value, name);		}	}	for (const [name, value] of Object.entries(signals)) {		if (			typeof value === "number" &&			!NON_CANONICAL_SIGNAL_NAMES.has(name) &&			!names.has(value)		) {			names.set(value, name);		}	}	return names;} export function toSidecarSignalName(signal: number): string {	return SIGNAL_NAME_BY_NUMBER.get(signal) ?? String(signal);} export interface LocalCompatMount {	path: string;	fs: VirtualFileSystem;	readOnly: boolean;} interface KernelSocketSnapshot {	processId: string;	host?: string;	port?: number;	path?: string;} interface KernelSignalState {	handlers: Map<		number,		{			action: SidecarSignalHandlerRegistration["action"];			mask: Set<number>;			flags: number;		}	>;} interface SocketLookupCacheEntry {	value: KernelSocketSnapshot | null;	pending: Promise<void> | null;} interface TrackedProcessEntry {	pid: number;	processId: string;	command: string;	args: string[];	driver: string;	cwd: string;	env: Record<string, string>;	startTime: number;	exitTime: number | null;	hostPid: number | null;	exitCode: number | null;	started: boolean;	startPromise: Promise<void>;	waitPromise: Promise<number>;	resolveWait: (exitCode: number) => void;	rejectWait: (error: Error) => void;	onStdout: Set<(data: Uint8Array) => void>;	onStderr: Set<(data: Uint8Array) => void>;	pendingStdin: Array<string | Uint8Array>;	stdinFlushPromise: Promise<void> | null;	pendingCloseStdin: boolean;	pendingKillSignal: number | null;	waitWithFallbackPromise: Promise<number> | null;	hostExitObservedAt: number | null;	outputGeneration: number;	exitViaEvent: boolean;} interface NativeSidecarKernelProxyOptions {	client: SidecarProcess;	session: AuthenticatedSession;	vm: CreatedVm;	disposeClient?: boolean;	env: Record<string, string>;	cwd: string;	defaultExecCwd?: string;	localMounts: LocalCompatMount[];	commandGuestPaths: ReadonlyMap<string, string>;	onWasmCommandResolved?: (command: string) => void;	onDispose?: () => Promise<void>;} export class NativeSidecarKernelProxy {	readonly env: Record<string, string>;	readonly cwd: string;	readonly commands: ReadonlyMap<string, string>;	readonly vfs: VirtualFileSystem;	readonly processes = new Map<number, ProcessInfo>();	private readonly defaultExecCwd: string | undefined; 	private readonly client: SidecarProcess;	private readonly disposeClient: boolean;	private readonly session: AuthenticatedSession;	private readonly vm: CreatedVm;	private readonly localMounts: LocalCompatMount[];	private readonly commandDrivers: Map<string, string>;	private readonly onWasmCommandResolved:		| ((command: string) => void)		| undefined;	private readonly onDispose: (() => Promise<void>) | undefined;	private readonly trackedProcesses = new Map<number, TrackedProcessEntry>();	private readonly trackedProcessesById = new Map<		string,		TrackedProcessEntry	>();	private readonly listenerLookups = new Map<string, SocketLookupCacheEntry>();	private readonly boundUdpLookups = new Map<string, SocketLookupCacheEntry>();	private readonly signalStates = new Map<number, KernelSignalState>();	private readonly signalRefreshes = new Map<number, Promise<void>>();	private sidecarProcessSnapshot: SidecarProcessSnapshotEntry[] = [];	private processSnapshotRefresh: Promise<void> | null = null;	private readonly observedProcessStartTimes = new Map<string, number>();	private readonly rootView: VirtualFileSystem;	private zombieTimerCountValue = 0;	private zombieTimerCountRefresh: Promise<void> | null = null;	private disposed = false;	private pumpError: Error | null = null;	private nextSyntheticPid = SYNTHETIC_PID_BASE;	private readonly eventPumpAbortController = new AbortController();	private readonly eventPump: Promise<void>; 	constructor(options: NativeSidecarKernelProxyOptions) {		this.client = options.client;		this.disposeClient = options.disposeClient ?? true;		this.session = options.session;		this.vm = options.vm;		this.env = { ...options.env };		this.cwd = options.cwd;		this.defaultExecCwd = options.defaultExecCwd;		this.localMounts = [...options.localMounts].sort(			(left, right) => right.path.length - left.path.length,		);		this.commandDrivers = buildCommandMap(options.commandGuestPaths);		this.onWasmCommandResolved = options.onWasmCommandResolved;		this.onDispose = options.onDispose;		this.commands = this.commandDrivers;		this.vfs = this.createFilesystemView(true);		this.rootView = this.createFilesystemView(false);		this.eventPump = this.runEventPump();		void this.eventPump.catch(() => {});	} 	createRootView(): VirtualFileSystem {		return this.rootView;	} 	get zombieTimerCount(): number {		if (!this.zombieTimerCountRefresh) {			this.zombieTimerCountRefresh = this.refreshZombieTimerCount();		}		return this.zombieTimerCountValue;	} 	registerCommandGuestPaths(		commandGuestPaths: ReadonlyMap<string, string>,	): void {		for (const name of commandGuestPaths.keys()) {			this.commandDrivers.set(name, "wasmvm");		}	} 	async dispose(): Promise<void> {		if (this.disposed) {			return;		}		this.disposed = true;		this.eventPumpAbortController.abort(); 		const liveProcesses = [...this.trackedProcesses.values()].filter(			(entry) => entry.exitCode === null,		);		await Promise.allSettled(			liveProcesses.map((entry) => this.signalProcess(entry, 15)),		); 		await Promise.race([			this.client.disposeVm(this.session, this.vm),			new Promise<void>((resolve) => setTimeout(resolve, 1000)),		]).catch(() => {});		for (const entry of liveProcesses) {			if (entry.exitCode === null) {				// The sidecar dispose path already performs TERM/KILL escalation for any				// guest executions that are still live. Resolve local waiters eagerly so				// VM teardown does not hang on killed ACP adapter processes that never				// surface a terminal process_exited event back to the JS bridge.				this.finishProcess(entry, 143);			}		}		if (this.disposeClient) {			await this.client.dispose().catch(() => {});		}		await this.eventPump.catch(() => {});		await this.onDispose?.().catch(() => {});	} 	async exec(		command: string,		options?: KernelExecOptions,	): Promise<KernelExecResult> {		if (!this.commands.has("sh")) {			throw new Error(				`native sidecar exec requires guest shell command 'sh': ${command}`,			);		} 		const stdoutChunks: Uint8Array[] = [];		const stderrChunks: Uint8Array[] = [];		const effectiveCwd = options?.cwd ?? this.defaultExecCwd ?? this.cwd;		const parsedCommand = parseSimpleExecCommand(command);		const resolveExecPath = (targetPath: string) =>			targetPath.startsWith("/")				? posixPath.normalize(targetPath)				: posixPath.normalize(posixPath.join(effectiveCwd, targetPath));		const runAndCapture = async (			proc: ManagedProcess,			stdinOverride?: string | Uint8Array,			readExitCode?: () => Promise<number>,		): Promise<KernelExecResult> => {			if (stdinOverride !== undefined) {				proc.writeStdin(stdinOverride);			} else if (options?.stdin !== undefined) {				proc.writeStdin(options.stdin);			}			// `kernel.exec()` is a non-interactive run-to-completion API: when the			// caller does not opt into a streaming stdin handle, the guest process			// should observe EOF after any provided input so commands like			// `node -e ...` do not linger behind an inherited open stdin pipe.			proc.closeStdin(); 			const waitPromise = proc.wait();			const shellExitCode =				typeof options?.timeout === "number"					? await new Promise<number>((resolve) => {							const timer = setTimeout(() => {								proc.kill(9);								void proc.wait().then(resolve);							}, options.timeout);							void waitPromise.then((code) => {

Lines 1–500 of 2398.