diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index ef7fe661b0..d81cb7e239 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -159,6 +159,8 @@ npx hyperframes render --output final.mp4 ``` It starts quickly and can use the computer’s browser GPU. +On WSL2, Chrome may not reach the GPU; see +[the render is slow under WSL2](/guides/troubleshooting#the-render-is-slow-under-wsl2). Use Docker when a controlled Chrome, FFmpeg, and font environment matters: diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx index 26d97f5074..cd7d696d23 100644 --- a/docs/guides/troubleshooting.mdx +++ b/docs/guides/troubleshooting.mdx @@ -229,6 +229,36 @@ During iteration: Run `npx hyperframes benchmark` when tuning worker settings. +### The render is slow under WSL2 + +WSL2 can expose a graphics card to Linux, but headless Chrome may not be able to use +it. The render then falls back to software rendering, which can be several times +slower on a long or effect-heavy video. The render log says so: + +```text +[hyperframes] browserGpuMode probe → software (WebGL unavailable) +[hyperframes] WSL: the GPU check found no hardware GPU, so this render uses software WebGL ... +``` + +The lines after it say what HyperFrames checked. If they say your user is not in +the `render` group, add yourself, restart WSL, and render again: + +```bash +sudo usermod -aG render $USER +# then, in Windows PowerShell: +wsl --shutdown +``` + +If the GPU is still not used, render from Windows instead. Copy the project to +a Windows drive (for example `C:\Users\you\renders\my-video`) and run the same +command from PowerShell, where Chrome uses the graphics card through Direct3D: + +```powershell +npx hyperframes render --output final.mp4 +``` + +Run it from a normal Windows folder, not a `\\wsl.localhost\...` path. + ### Expected HDR but received SDR HDR needs a supported output and correct source color metadata. MP4 is the normal HDR output path; WebM and MOV may fall back to SDR. diff --git a/packages/cli/src/telemetry/platform.ts b/packages/cli/src/telemetry/platform.ts index ac8b89fbc1..b36fec733f 100644 --- a/packages/cli/src/telemetry/platform.ts +++ b/packages/cli/src/telemetry/platform.ts @@ -1,18 +1,5 @@ -import { platform, release } from "node:os"; -import { readFileSync } from "node:fs"; - // Shared host-platform detectors used by both system.ts (overall metadata) // and agent_runtime.ts (sandbox fingerprinting). Lives in its own module // to avoid an import cycle between those two files. -export function detectWSL(): boolean { - if (platform() !== "linux") return false; - try { - const osRelease = release().toLowerCase(); - if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; - const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase(); - return procVersion.includes("microsoft") || procVersion.includes("wsl"); - } catch { - return false; - } -} +export { detectWsl as detectWSL } from "@hyperframes/engine/wsl-host"; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index ea2258b3f5..f59bf0e967 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -101,6 +101,7 @@ var __dirname = __hf_dirname(__filename);`, "./chrome-host-ceiling", "./shader-transitions", "./system-memory", + "./wsl-host", ]), }; options.loader = { ...options.loader, ".browser.js": "text" }; diff --git a/packages/engine/package-subpaths.json b/packages/engine/package-subpaths.json index 7fab5f1b3f..a0366430b4 100644 --- a/packages/engine/package-subpaths.json +++ b/packages/engine/package-subpaths.json @@ -25,6 +25,12 @@ "types": "./dist/services/systemMemory.d.ts", "environments": ["bun", "node"] }, + "./wsl-host": { + "source": "./src/services/wslHost.ts", + "runtime": "./dist/services/wslHost.js", + "types": "./dist/services/wslHost.d.ts", + "environments": ["bun", "node"] + }, "./shader-transitions": { "source": "./src/utils/shaderTransitions.ts", "runtime": "./dist/utils/shaderTransitions.js", diff --git a/packages/engine/package.json b/packages/engine/package.json index dfb3ce5c71..ffba3c134f 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -35,6 +35,11 @@ "import": "./src/services/systemMemory.ts", "types": "./src/services/systemMemory.ts" }, + "./wsl-host": { + "bun": "./src/services/wslHost.ts", + "import": "./src/services/wslHost.ts", + "types": "./src/services/wslHost.ts" + }, "./shader-transitions": { "bun": "./src/utils/shaderTransitions.ts", "import": "./src/utils/shaderTransitions.ts", @@ -61,6 +66,10 @@ "import": "./dist/services/systemMemory.js", "types": "./dist/services/systemMemory.d.ts" }, + "./wsl-host": { + "import": "./dist/services/wslHost.js", + "types": "./dist/services/wslHost.d.ts" + }, "./shader-transitions": { "import": "./dist/utils/shaderTransitions.js", "types": "./dist/utils/shaderTransitions.d.ts" diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index 50acb9a876..97ed0c5201 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -369,6 +369,13 @@ describe("resolveBrowserGpuMode", () => { return { close, launch }; }; + const notWsl = { + platform: "linux" as const, + release: "6.8.0-45-generic", + readProcVersion: () => "Linux version 6.8.0-45-generic", + renderNodeAccess: () => ({ state: "read-write" as const }), + }; + beforeEach(() => { _resetAutoBrowserGpuModeCacheForTests(); }); @@ -414,15 +421,15 @@ describe("resolveBrowserGpuMode", () => { renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))", }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const mode = await resolveBrowserGpuMode("hardware", { platform: "linux" }); + const mode = await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl }); expect(mode).toBe("hardware"); const warning = warn.mock.calls.map((call) => String(call[0])).join("\n"); expect(warning).toContain("browserGpuMode=hardware was requested"); expect(warning).toContain("--gpus all"); // Once per process, not once per worker — the render path resolves the // mode for the probe browser plus every parallel worker. - await resolveBrowserGpuMode("hardware", { platform: "linux" }); - await resolveBrowserGpuMode("hardware", { platform: "linux" }); + await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl }); + await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl }); expect(warn).toHaveBeenCalledTimes(1); }); @@ -440,7 +447,9 @@ describe("resolveBrowserGpuMode", () => { renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))", }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(await resolveBrowserGpuMode("hardware", { platform: "darwin" })).toBe("hardware"); + expect(await resolveBrowserGpuMode("hardware", { platform: "darwin", wslHost: notWsl })).toBe( + "hardware", + ); const warning = String(warn.mock.calls[0]?.[0]); expect(warning).toContain("host exposes a GPU"); expect(warning).not.toContain("--gpus all"); @@ -454,13 +463,127 @@ describe("resolveBrowserGpuMode", () => { launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")), } as unknown as PuppeteerNode); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(await resolveBrowserGpuMode("hardware", { platform: "linux" })).toBe("hardware"); + expect(await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl })).toBe( + "hardware", + ); const warning = String(warn.mock.calls[0]?.[0]); expect(warning).toContain("GPU probe could not run"); expect(warning).toContain("hyperframes doctor"); expect(warning).not.toContain("--gpus all"); }); + describe("under WSL (heygen-com/hyperframes#5548)", () => { + const wslHost = { + platform: "linux" as const, + release: "5.15.167.4-microsoft-standard-WSL2", + readProcVersion: () => "", + renderNodeAccess: () => ({ state: "denied" as const, group: "render", userInGroup: false }), + }; + const output = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.map((call) => String(call[0])).join("\n"); + const neverAnsweringProbe = () => { + const launch = vi.fn().mockResolvedValue({ + newPage: vi.fn().mockResolvedValue({ evaluate: () => new Promise(() => {}) }), + close: () => new Promise(() => {}), + process: () => ({ kill: vi.fn() }), + disconnect: vi.fn().mockResolvedValue(undefined), + }); + _setPuppeteerForTests({ launch } as unknown as PuppeteerNode); + }; + + it("auto mode says it fell back to software, why, and the fix, once", async () => { + setMockWebGlProbe({ hasWebGL: false, vendor: "", renderer: "" }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("auto", { platform: "linux", wslHost })).toBe("software"); + await resolveBrowserGpuMode("auto", { platform: "linux", wslHost }); + const text = output(error); + expect(text).toContain("browserGpuMode probe → software (WebGL unavailable)"); + expect(text).toContain( + "WSL: the GPU check found no hardware GPU, so this render uses software", + ); + expect(text).toContain('is not in its group ("render")'); + expect(text).toContain("sudo usermod -aG render $USER"); + expect(text.match(/usermod/g)).toHaveLength(1); + }); + + it("auto mode says the check did not finish when the probe page times out", async () => { + vi.useFakeTimers(); + try { + neverAnsweringProbe(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const mode = resolveBrowserGpuMode("auto", { platform: "linux", wslHost }); + await vi.advanceTimersByTimeAsync(15_000 + 250); + await expect(mode).resolves.toBe("software"); + const text = output(error); + expect(text).toContain("did not answer within 15000ms"); + expect(text).toContain("WSL: the GPU check could not finish"); + expect(text).not.toContain("found no hardware GPU"); + } finally { + vi.useRealTimers(); + } + }); + + it("explicit 'hardware' after a timeout stays unverified and never claims software", async () => { + vi.useFakeTimers(); + try { + neverAnsweringProbe(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mode = resolveBrowserGpuMode("hardware", { platform: "linux", wslHost }); + await vi.advanceTimersByTimeAsync(15_000 + 250); + await expect(mode).resolves.toBe("hardware"); + const warning = output(warn); + expect(warning).toContain("hardware acceleration is UNVERIFIED"); + expect(warning).toContain("hyperframes doctor"); + expect(warning).toContain("sudo usermod -aG render $USER"); + expect(warning).not.toContain("found no hardware GPU"); + expect(output(error)).not.toContain("this render uses software"); + } finally { + vi.useRealTimers(); + } + }); + + it("explicit 'hardware' with a broken Chrome keeps the Chrome-install advice", async () => { + _setPuppeteerForTests({ + launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")), + } as unknown as PuppeteerNode); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost })).toBe( + "hardware", + ); + const warning = output(warn); + expect(warning).toContain("GPU probe could not run"); + expect(warning).toContain("hyperframes doctor"); + expect(warning).not.toContain("--gpus all"); + expect(output(error)).not.toContain("this render uses software"); + }); + + it("explicit 'hardware' with no hardware GPU gives the WSL fix, not Docker passthrough", async () => { + setMockWebGlProbe({ hasWebGL: false, vendor: "", renderer: "" }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost })).toBe( + "hardware", + ); + const warning = output(warn); + expect(warning).toContain("sudo usermod -aG render $USER"); + expect(warning).not.toContain("--gpus all"); + expect(output(error)).not.toContain("this render uses software"); + }); + + it("stays quiet when WSL hands Chrome a hardware GPU", async () => { + setMockWebGlProbe({ + hasWebGL: true, + vendor: "Google Inc. (Microsoft Corporation)", + renderer: "ANGLE (Microsoft Corporation, D3D12 (Intel(R) Graphics), OpenGL ES 3.0)", + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("auto", { platform: "linux", wslHost })).toBe("hardware"); + expect(output(error)).not.toContain("WSL"); + }); + }); + it("falls back to 'software' within the probe budget when the probe page never answers", async () => { vi.useFakeTimers(); try { diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index c1cd877248..d02899c22a 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -15,6 +15,7 @@ import { homedir } from "os"; import { chromeMajorCeiling, exceedsChromeCeiling } from "./chromeHostCeiling.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js"; +import { describeWslGpuAccess, type WslHostFacts } from "./wslHost.js"; import { BrowserLeasePool, type BrowserLaunchFingerprint, @@ -83,6 +84,7 @@ interface GpuProbeOptions { browserTimeout?: number; platform?: NodeJS.Platform; probeLifecycle?: GpuProbeLifecycle; + wslHost?: WslHostFacts; } const GPU_PROBE_TIMEOUT_MS = 15_000; @@ -495,6 +497,8 @@ export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProb interface GpuProbeOutcome { mode: "software" | "hardware"; cause?: "no-gpu" | "probe-error"; + /** Under WSL, what we checked on its GPU path (see describeWslGpuAccess). */ + wslAccess?: string[]; } /** @@ -514,6 +518,7 @@ let _autoBrowserGpuModeCache: Promise | undefined; export function _resetAutoBrowserGpuModeCacheForTests(): void { _autoBrowserGpuModeCache = undefined; _unverifiedHardwareGpuWarned = false; + _wslSoftwareNoteLogged = false; } async function getPuppeteerOrNull(): Promise { @@ -570,13 +575,19 @@ async function probeAutoBrowserGpuMode(options: GpuProbeOptions): Promise { + const lines = describeWslGpuAccess(options.wslHost); + return lines ? { wslAccess: lines } : {}; +} + /** * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer. * @@ -610,7 +621,15 @@ export function resolveBrowserGpuMode( if (mode === "software") return Promise.resolve(mode); _autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options); - if (mode === "auto") return _autoBrowserGpuModeCache.then((probed) => probed.mode); + if (mode === "auto") { + return _autoBrowserGpuModeCache.then((probed) => { + if (probed.mode === "software" && probed.wslAccess && !_wslSoftwareNoteLogged) { + _wslSoftwareNoteLogged = true; + console.error(buildWslSoftwareNote(probed)); + } + return probed.mode; + }); + } return _autoBrowserGpuModeCache.then((probed) => { // Warn once per cache lifetime, not once per caller: `createCaptureSession` @@ -618,9 +637,7 @@ export function resolveBrowserGpuMode( // an un-deduplicated warning prints N+1 times and buries itself. if (probed.mode === "software" && !_unverifiedHardwareGpuWarned) { _unverifiedHardwareGpuWarned = true; - console.warn( - buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed.cause), - ); + console.warn(buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed)); } return "hardware"; }); @@ -631,6 +648,21 @@ export function resolveBrowserGpuMode( * cache lifetime (re-armed by `_resetAutoBrowserGpuModeCacheForTests`). */ let _unverifiedHardwareGpuWarned = false; +let _wslSoftwareNoteLogged = false; + +/** Auto mode under WSL selected software: say so, with what we checked. */ +function buildWslSoftwareNote({ cause, wslAccess = [] }: GpuProbeOutcome): string { + const check = + cause === "probe-error" + ? "the GPU check could not finish (see the probe line above)" + : "the GPU check found no hardware GPU"; + return [ + `[hyperframes] WSL: ${check}, so this render uses software WebGL (SwiftShader) and runs ` + + "at CPU speed.", + ...wslAccess, + " Pass --no-browser-gpu to skip the GPU check and render in software.", + ].join("\n"); +} /** * Warning text for "you asked for hardware GPU and we could not confirm it". @@ -643,7 +675,7 @@ let _unverifiedHardwareGpuWarned = false; */ function buildUnverifiedHardwareGpuWarning( platform: NodeJS.Platform | string, - cause: GpuProbeOutcome["cause"], + { cause, wslAccess }: GpuProbeOutcome, ): string { if (cause === "probe-error") { return ( @@ -654,22 +686,28 @@ function buildUnverifiedHardwareGpuWarning( "`browserGpuMode probe → software (probe failed ...)` line above for the underlying " + "error, which usually means Chrome could not launch (bad HYPERFRAMES_BROWSER_PATH, " + "missing shared libraries, or a denied sandbox) rather than a GPU problem.\n" + - " Run `hyperframes doctor` to check the Chrome install." + " Run `hyperframes doctor` to check the Chrome install." + + (wslAccess + ? ["", " Under WSL, a GPU path that is slow to start or unreachable can also cause it:"] + .concat(wslAccess) + .join("\n") + : "") ); } const remediation = - platform === "linux" - ? "Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " + + wslAccess?.join("\n") ?? + (platform === "linux" + ? " Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " + "Container Toolkit installed, or `--device /dev/dri` for Mesa/AMD/Intel. The image " + "also needs the matching userspace driver + libEGL. Verify with " + "`hyperframes render --browser-gpu` and watch for this warning disappearing." - : "Check that the host exposes a GPU to this process and that the graphics drivers are " + - "installed."; + : " Check that the host exposes a GPU to this process and that the graphics drivers " + + "are installed."); return ( "[hyperframes] browserGpuMode=hardware was requested, but the WebGL probe found no " + "hardware GPU, so Chrome will silently fall back to software WebGL and the capture will " + "run at CPU speed. Honouring the explicit request anyway.\n" + - ` ${remediation}\n` + + `${remediation}\n` + " Pass --no-browser-gpu to select deterministic SwiftShader instead of waiting on a " + "hardware path that is not there." ); diff --git a/packages/engine/src/services/wslHost.test.ts b/packages/engine/src/services/wslHost.test.ts new file mode 100644 index 0000000000..2e93608a81 --- /dev/null +++ b/packages/engine/src/services/wslHost.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { describeWslGpuAccess, detectWsl, type WslHostFacts } from "./wslHost.js"; + +const RENDER_NODE = "/dev/dri/renderD128"; + +function fakeHost(overrides: Partial = {}): WslHostFacts { + return { + platform: "linux", + release: "6.18.54.1-microsoft-standard-WSL2", + readProcVersion: () => "Linux version 6.18.54.1-microsoft-standard-WSL2", + renderNodeAccess: () => ({ state: "denied", group: "render", userInGroup: false }), + ...overrides, + }; +} + +const note = (host: WslHostFacts) => describeWslGpuAccess(host)?.join("\n") ?? null; + +describe("detectWsl", () => { + it("detects WSL from the kernel release", () => { + expect(detectWsl(fakeHost({ readProcVersion: () => "" }))).toBe(true); + }); + + it("falls back to /proc/version when the release string is generic", () => { + expect(detectWsl(fakeHost({ release: "6.8.0-45-generic" }))).toBe(true); + }); + + it("is false on plain Linux and on other platforms", () => { + const plainLinux = fakeHost({ + release: "6.8.0-45-generic", + readProcVersion: () => "Linux version 6.8.0-45-generic (buildd@ubuntu)", + }); + expect(detectWsl(plainLinux)).toBe(false); + expect(detectWsl(fakeHost({ platform: "win32" }))).toBe(false); + }); + + it("is false when /proc/version cannot be read", () => { + const host = fakeHost({ + release: "6.8.0-45-generic", + readProcVersion: () => { + throw new Error("EACCES"); + }, + }); + expect(detectWsl(host)).toBe(false); + }); +}); + +describe("describeWslGpuAccess", () => { + it("gives the usermod fix only when access is denied and the user is not in the node's group", () => { + const text = note(fakeHost()); + expect(text).toContain( + `cannot read and write ${RENDER_NODE} and is not in its group ("render")`, + ); + expect(text).toContain("sudo usermod -aG render $USER"); + expect(text).toContain("wsl --shutdown"); + expect(text).toContain("native Windows"); + expect(text).not.toContain("Docker"); + }); + + it("uses the numeric group id when the group has no name", () => { + const host = fakeHost({ + renderNodeAccess: () => ({ state: "denied", group: "993", userInGroup: false }), + }); + expect(note(host)).toContain("sudo usermod -aG 993 $USER"); + }); + + it("does not suggest joining a group the user is already in", () => { + const host = fakeHost({ + renderNodeAccess: () => ({ state: "denied", group: "render", userInGroup: true }), + }); + const text = note(host); + expect(text).toContain('is in the "render" group but still cannot read and write'); + expect(text).toContain(`ls -l ${RENDER_NODE}`); + expect(text).not.toContain("usermod"); + }); + + it("does not blame permissions when the node is readable and writable or missing", () => { + const readWrite = note(fakeHost({ renderNodeAccess: () => ({ state: "read-write" }) })); + expect(readWrite).toContain(`${RENDER_NODE} is readable and writable, so file permissions`); + expect(readWrite).not.toContain("usermod"); + expect(readWrite).toContain("native Windows"); + const missing = note(fakeHost({ renderNodeAccess: () => ({ state: "missing" }) })); + expect(missing).toContain(`${RENDER_NODE} does not exist`); + expect(missing).not.toContain("usermod"); + }); + + it("returns null outside WSL", () => { + expect(describeWslGpuAccess(fakeHost({ platform: "darwin" }))).toBeNull(); + }); +}); diff --git a/packages/engine/src/services/wslHost.ts b/packages/engine/src/services/wslHost.ts new file mode 100644 index 0000000000..db1d0a97b5 --- /dev/null +++ b/packages/engine/src/services/wslHost.ts @@ -0,0 +1,102 @@ +/** + * WSL host detection and what we can check about its GPU path when Chrome's + * GPU probe does not confirm a hardware GPU (heygen-com/hyperframes#5548). + */ + +import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs"; +import { platform, release } from "node:os"; + +export type RenderNodeAccess = + | { state: "missing" | "read-write" } + | { state: "denied"; group: string; userInGroup: boolean }; + +/** The host facts these checks read; tests pass a fake. */ +export interface WslHostFacts { + platform: NodeJS.Platform; + release: string; + readProcVersion(): string; + renderNodeAccess(path: string): RenderNodeAccess; +} + +function groupName(gid: number): string { + try { + const line = readFileSync("/etc/group", "utf-8") + .split("\n") + .find((entry) => entry.split(":")[2] === String(gid)); + return line?.split(":")[0] || String(gid); + } catch { + return String(gid); + } +} + +const realHost: WslHostFacts = { + get platform() { + return platform(); + }, + get release() { + return release(); + }, + readProcVersion: () => readFileSync("/proc/version", "utf-8"), + renderNodeAccess: (path) => { + if (!existsSync(path)) return { state: "missing" }; + try { + accessSync(path, constants.R_OK | constants.W_OK); + return { state: "read-write" }; + } catch { + const gid = statSync(path, { throwIfNoEntry: false })?.gid ?? -1; + return { + state: "denied", + group: groupName(gid), + userInGroup: process.getgroups?.().includes(gid) ?? false, + }; + } + }, +}; + +export function detectWsl(host: WslHostFacts = realHost): boolean { + if (host.platform !== "linux") return false; + try { + const osRelease = host.release.toLowerCase(); + if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; + const procVersion = host.readProcVersion().toLowerCase(); + return procVersion.includes("microsoft") || procVersion.includes("wsl"); + } catch { + return false; + } +} + +const RENDER_NODE = "/dev/dri/renderD128"; + +const NATIVE_WINDOWS = + "render from native Windows (project copied to a Windows drive, run from PowerShell), " + + "where Chrome uses the GPU through Direct3D."; + +/** + * Indented lines saying what we checked on the WSL GPU path and what to try, + * or null outside WSL. Says nothing about the render mode: callers own that. + */ +export function describeWslGpuAccess(host: WslHostFacts = realHost): string[] | null { + if (!detectWsl(host)) return null; + const access = host.renderNodeAccess(RENDER_NODE); + if (access.state === "denied" && !access.userInGroup) { + return [ + ` Your Linux user cannot read and write ${RENDER_NODE} and is not in its group ` + + `("${access.group}"), which can stop Mesa from reaching the GPU.`, + ` Fix: run \`sudo usermod -aG ${access.group} $USER\`, then \`wsl --shutdown\` from ` + + "Windows, reopen WSL and render again.", + ` Or ${NATIVE_WINDOWS}`, + ]; + } + if (access.state === "denied") { + return [ + ` Your Linux user is in the "${access.group}" group but still cannot read and write ` + + `${RENDER_NODE}; check its permissions with \`ls -l ${RENDER_NODE}\`.`, + ` Or ${NATIVE_WINDOWS}`, + ]; + } + const seen = access.state === "missing" ? "does not exist" : "is readable and writable"; + return [ + ` ${RENDER_NODE} ${seen}, so file permissions are not the cause.`, + ` To use the GPU, ${NATIVE_WINDOWS}`, + ]; +}