341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import {
|
|
hopperStartupExitCode,
|
|
type HopperPrivateDisplayStrategy,
|
|
type HopperStartupDiagnostic,
|
|
type HopperStartupFailureDiagnostic,
|
|
type HopperStartupFailureCode,
|
|
type HopperStartupReadyDiagnostic,
|
|
} from "../domain/hopperStartupFailure.js";
|
|
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
|
import {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
type ProviderProcessLaunch,
|
|
} from "../process/ProviderProcess.js";
|
|
import { parseLinuxPrivateDisplayDiagnostic } from "./LinuxPrivateDisplayDiagnostic.js";
|
|
|
|
const PYTHON_PATH = "/usr/bin/python3";
|
|
const UNSHARE_PATH = "/usr/bin/unshare";
|
|
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
|
|
|
|
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
|
signal?.aborted === true;
|
|
|
|
export type LinuxPrivateDisplayRunnableStrategy = Exclude<
|
|
HopperPrivateDisplayStrategy,
|
|
"unavailable"
|
|
>;
|
|
|
|
export interface LinuxPrivateDisplayProbeProcessResult {
|
|
readonly outcome: "exited" | "timed-out" | "cancelled" | "launch-failed";
|
|
readonly exitCode: number | null | undefined;
|
|
readonly stderr: string;
|
|
readonly stderrBytes: number;
|
|
readonly cleanupIncomplete: boolean;
|
|
}
|
|
|
|
export type LinuxPrivateDisplayProbeRunner = (
|
|
strategy: LinuxPrivateDisplayRunnableStrategy,
|
|
options: {
|
|
readonly helperPath: string;
|
|
readonly environment?: Readonly<NodeJS.ProcessEnv>;
|
|
readonly timeoutMs: number;
|
|
readonly signal?: AbortSignal;
|
|
},
|
|
) => Promise<LinuxPrivateDisplayProbeProcessResult>;
|
|
|
|
export type LinuxPrivateDisplaySelection =
|
|
| {
|
|
readonly ok: true;
|
|
readonly strategy: LinuxPrivateDisplayRunnableStrategy;
|
|
readonly diagnostic: HopperStartupReadyDiagnostic;
|
|
}
|
|
| {
|
|
readonly ok: false;
|
|
readonly strategy: "unavailable";
|
|
readonly exitCode: number;
|
|
readonly diagnostic: HopperStartupFailureDiagnostic;
|
|
};
|
|
|
|
/** Probe the exact Xvfb boundary and select the least-privileged viable strategy. */
|
|
export const selectLinuxPrivateDisplayStrategy = async (options: {
|
|
readonly helperPath: string;
|
|
readonly environment?: Readonly<NodeJS.ProcessEnv>;
|
|
readonly timeoutMs?: number;
|
|
readonly signal?: AbortSignal;
|
|
readonly runProbe?: LinuxPrivateDisplayProbeRunner;
|
|
}): Promise<LinuxPrivateDisplaySelection> => {
|
|
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
const runner = options.runProbe ?? runLinuxPrivateDisplayProbe;
|
|
const direct = await evaluatedProbe("direct", options, timeoutMs, runner);
|
|
if (direct.ok) return direct;
|
|
if (direct.diagnostic.failure_code !== "x11_socket_directory_unusable")
|
|
return unavailable(direct.diagnostic);
|
|
|
|
const isolated = await evaluatedProbe(
|
|
"user-mount-namespace",
|
|
options,
|
|
timeoutMs,
|
|
runner,
|
|
);
|
|
if (!isolated.ok)
|
|
return unavailable({
|
|
...direct.diagnostic,
|
|
strategy: "unavailable",
|
|
reason: "namespace_unavailable",
|
|
fallback_reason: isolated.diagnostic.reason,
|
|
effective_socket_directory_mode:
|
|
isolated.diagnostic.effective_socket_directory_mode,
|
|
effective_mount_read_only: isolated.diagnostic.effective_mount_read_only,
|
|
xvfb_stderr_bytes: isolated.diagnostic.xvfb_stderr_bytes,
|
|
});
|
|
return {
|
|
ok: true,
|
|
strategy: "user-mount-namespace",
|
|
diagnostic: {
|
|
...isolated.diagnostic,
|
|
socket_directory_mode: direct.diagnostic.socket_directory_mode,
|
|
mount_read_only: direct.diagnostic.mount_read_only,
|
|
fallback_reason: null,
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Run one helper probe in an owned process group and retain its full diagnostics. */
|
|
export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
|
|
async (strategy, options) => {
|
|
if (isAborted(options.signal)) return emptyProcessResult("cancelled");
|
|
const command = privateDisplayProbeCommand(options.helperPath, strategy);
|
|
let started: Awaited<ReturnType<typeof spawnOwnedProviderProcess>>;
|
|
try {
|
|
started = await spawnOwnedProviderProcess({
|
|
command: command.command,
|
|
arguments: command.arguments,
|
|
runId: randomUUID(),
|
|
expectedCommand: null,
|
|
...(options.environment === undefined
|
|
? {}
|
|
: { hostEnvironment: options.environment }),
|
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: probe spawn failure is reported as launch-failed.
|
|
void cause;
|
|
return emptyProcessResult(
|
|
isAborted(options.signal) ? "cancelled" : "launch-failed",
|
|
);
|
|
}
|
|
const launch: ProviderProcessLaunch = {
|
|
process: started.process,
|
|
ownsProcessLifetime: true,
|
|
cleanup: () => cleanupOwnedProcessGroup(started.ownership),
|
|
};
|
|
const supervisor = new ProviderProcessSupervisor(launch);
|
|
const outcome = await waitForProbeOutputClose(
|
|
supervisor,
|
|
options.timeoutMs,
|
|
options.signal,
|
|
);
|
|
let cleanupIncomplete = false;
|
|
if (outcome !== "exit") {
|
|
const stopped = await supervisor.stop();
|
|
cleanupIncomplete = stopped.status === "incomplete";
|
|
} else supervisor.dispose();
|
|
const snapshot = supervisor.snapshot();
|
|
return {
|
|
outcome:
|
|
outcome === "exit"
|
|
? "exited"
|
|
: outcome === "timeout"
|
|
? "timed-out"
|
|
: "cancelled",
|
|
exitCode: snapshot.exitCode,
|
|
stderr: snapshot.stderr.text,
|
|
stderrBytes: snapshot.stderr.bytes,
|
|
cleanupIncomplete,
|
|
};
|
|
};
|
|
|
|
const privateDisplayProbeCommand = (
|
|
helperPath: string,
|
|
strategy: LinuxPrivateDisplayRunnableStrategy,
|
|
): { readonly command: string; readonly arguments: readonly string[] } =>
|
|
strategy === "direct"
|
|
? {
|
|
command: PYTHON_PATH,
|
|
arguments: [helperPath, "--probe", "--strategy", "direct"],
|
|
}
|
|
: {
|
|
command: UNSHARE_PATH,
|
|
arguments: [
|
|
"--user",
|
|
"--map-root-user",
|
|
"--mount",
|
|
"--propagation",
|
|
"private",
|
|
PYTHON_PATH,
|
|
helperPath,
|
|
"--probe",
|
|
"--strategy",
|
|
"user-mount-namespace",
|
|
"--mount-private-x11",
|
|
],
|
|
};
|
|
|
|
const evaluatedProbe = async (
|
|
strategy: LinuxPrivateDisplayRunnableStrategy,
|
|
options: {
|
|
readonly helperPath: string;
|
|
readonly environment?: Readonly<NodeJS.ProcessEnv>;
|
|
readonly signal?: AbortSignal;
|
|
},
|
|
timeoutMs: number,
|
|
runner: LinuxPrivateDisplayProbeRunner,
|
|
): Promise<
|
|
| {
|
|
readonly ok: true;
|
|
readonly strategy: LinuxPrivateDisplayRunnableStrategy;
|
|
readonly diagnostic: HopperStartupReadyDiagnostic;
|
|
}
|
|
| {
|
|
readonly ok: false;
|
|
readonly diagnostic: HopperStartupFailureDiagnostic;
|
|
}
|
|
> => {
|
|
let processResult: LinuxPrivateDisplayProbeProcessResult;
|
|
try {
|
|
processResult = await runner(strategy, {
|
|
helperPath: options.helperPath,
|
|
...(options.environment === undefined
|
|
? {}
|
|
: { environment: options.environment }),
|
|
timeoutMs,
|
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
} catch (cause: unknown) {
|
|
// Runner failure is reported with the synthetic probe_failed diagnostic.
|
|
void cause;
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, "probe_failed"),
|
|
};
|
|
}
|
|
if (processResult.outcome === "cancelled")
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, "cancelled"),
|
|
};
|
|
if (processResult.outcome === "timed-out")
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, "probe_timeout"),
|
|
};
|
|
if (processResult.outcome === "launch-failed")
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, "probe_failed"),
|
|
};
|
|
if (processResult.cleanupIncomplete)
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, "cleanup_incomplete"),
|
|
};
|
|
const parsed = parseLinuxPrivateDisplayDiagnostic(processResult.stderr);
|
|
if (!parsed.ok)
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, parsed.reason),
|
|
};
|
|
const diagnostic = parsed.value;
|
|
if (
|
|
diagnostic.strategy !== strategy ||
|
|
diagnostic.operation !== "probe" ||
|
|
!exitMatchesDiagnostic(processResult.exitCode, diagnostic)
|
|
)
|
|
return {
|
|
ok: false,
|
|
diagnostic: syntheticDiagnostic(strategy, "diagnostic_malformed"),
|
|
};
|
|
return diagnostic.status === "ready"
|
|
? { ok: true, strategy, diagnostic }
|
|
: { ok: false, diagnostic };
|
|
};
|
|
|
|
const exitMatchesDiagnostic = (
|
|
exitCode: number | null | undefined,
|
|
diagnostic: HopperStartupDiagnostic,
|
|
): boolean =>
|
|
diagnostic.status === "ready"
|
|
? exitCode === 0
|
|
: hopperStartupExitCode(diagnostic.failure_code) === exitCode;
|
|
|
|
const syntheticDiagnostic = (
|
|
strategy: LinuxPrivateDisplayRunnableStrategy,
|
|
reason: string,
|
|
failureCode: HopperStartupFailureCode = "private_display_unavailable",
|
|
): HopperStartupFailureDiagnostic => ({
|
|
component: "hopper_private_display",
|
|
operation: "probe",
|
|
status: "error",
|
|
failure_code: failureCode,
|
|
reason,
|
|
socket_directory: "/tmp/.X11-unix",
|
|
socket_directory_mode: null,
|
|
mount_read_only: null,
|
|
effective_socket_directory_mode: null,
|
|
effective_mount_read_only: null,
|
|
wsl: false,
|
|
strategy,
|
|
fallback_reason: null,
|
|
xvfb_stderr_bytes: 0,
|
|
});
|
|
|
|
const unavailable = (
|
|
diagnostic: HopperStartupFailureDiagnostic,
|
|
): LinuxPrivateDisplaySelection => ({
|
|
ok: false,
|
|
strategy: "unavailable",
|
|
exitCode: hopperStartupExitCode(diagnostic.failure_code) ?? 70,
|
|
diagnostic: { ...diagnostic, strategy: "unavailable" },
|
|
});
|
|
|
|
const emptyProcessResult = (
|
|
outcome: Extract<
|
|
LinuxPrivateDisplayProbeProcessResult["outcome"],
|
|
"cancelled" | "launch-failed"
|
|
>,
|
|
): LinuxPrivateDisplayProbeProcessResult => ({
|
|
outcome,
|
|
exitCode: undefined,
|
|
stderr: "",
|
|
stderrBytes: 0,
|
|
cleanupIncomplete: false,
|
|
});
|
|
|
|
const waitForProbeOutputClose = async (
|
|
supervisor: ProviderProcessSupervisor,
|
|
timeoutMs: number,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<"exit" | "timeout" | "cancelled"> => {
|
|
if (signal?.aborted === true) return "cancelled";
|
|
let releaseAbort: (() => void) | undefined;
|
|
const aborted = new Promise<"cancelled">((resolve) => {
|
|
if (signal === undefined) return;
|
|
const listener = (): void => resolve("cancelled");
|
|
signal.addEventListener("abort", listener, { once: true });
|
|
releaseAbort = () => signal.removeEventListener("abort", listener);
|
|
});
|
|
try {
|
|
return await Promise.race([
|
|
supervisor
|
|
.waitForOutputClose(Math.max(1, timeoutMs))
|
|
.then((closed): "exit" | "timeout" => (closed ? "exit" : "timeout")),
|
|
aborted,
|
|
]);
|
|
} finally {
|
|
releaseAbort?.();
|
|
}
|
|
};
|