diff --git a/src/lib/onboard/docker-gpu-patch-mode-selection.test.ts b/src/lib/onboard/docker-gpu-patch-mode-selection.test.ts index 182278e6ddf..f0f40a75ff1 100644 --- a/src/lib/onboard/docker-gpu-patch-mode-selection.test.ts +++ b/src/lib/onboard/docker-gpu-patch-mode-selection.test.ts @@ -19,7 +19,7 @@ function cdiHostDeps(): DockerGpuPatchDeps { dockerCapture: vi.fn(() => JSON.stringify(["/etc/cdi"])), readDir: (dir: string) => (dir === "/etc/cdi" ? ["nvidia.yaml"] : null), readFile: (file: string) => - file === "/etc/cdi/nvidia.yaml" + file.replace(/\\/g, "/") === "/etc/cdi/nvidia.yaml" ? "cdiVersion: 0.6.0\nkind: nvidia.com/gpu\ndevices:\n - name: all\n" : null, dockerRm: vi.fn(() => ({ status: 0 })), @@ -145,3 +145,59 @@ describe("docker-gpu-patch CDI-first mode selection (#4948)", () => { expect(detachedArgs).not.toContain("--gpus"); }); }); + +describe("docker-gpu-patch Docker Desktop WSL mode selection (#5512)", () => { + it("prefers --gpus and skips CDI on Docker Desktop WSL even when CDI specs are visible", () => { + const dockerRun = vi.fn(() => ({ status: 0, stdout: "probe-id" })); + const selected = selectDockerGpuPatchMode( + { image: "openshell/sandbox:abc", dockerDesktopWsl: true }, + { ...cdiHostDeps(), dockerRun }, + ); + + expect(selected.mode?.kind).toBe("gpus"); + expect(selected.attempts.map((attempt) => attempt.mode.kind)).toEqual(["gpus"]); + expect(dockerRun).toHaveBeenCalledWith( + expect.arrayContaining(["create", "--gpus", "all"]), + expect.objectContaining({ ignoreError: true }), + ); + expect( + dockerRun.mock.calls.some(([args]) => (args as readonly string[]).includes("--device")), + ).toBe(false); + }); + + it("passes --gpus to docker run when recreating on Docker Desktop WSL", () => { + const dockerCapture = vi.fn((args: readonly string[]) => { + if (args[0] === "ps") return "old-container-id\n"; + if (args[0] === "inspect") return JSON.stringify([inspectFixture()]); + if (args[0] === "info") return JSON.stringify(["/etc/cdi"]); + return ""; + }); + const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" })); + const host = cdiHostDeps(); + + const result = recreateOpenShellDockerSandboxWithGpu( + { sandboxName: "alpha", timeoutSecs: 1, dockerDesktopWsl: true }, + { + dockerCapture, + readDir: host.readDir, + readFile: host.readFile, + dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })), + dockerRunDetached, + dockerRename: vi.fn(() => ({ status: 0 })), + dockerStop: vi.fn(() => ({ status: 0 })), + dockerRm: vi.fn(() => ({ status: 0 })), + runOpenshell: vi.fn(() => ({ status: 0 })), + sleep: vi.fn(), + now: () => new Date("2026-05-12T00:00:00Z"), + }, + ); + + expect(result.mode.kind).toBe("gpus"); + expect(dockerRunDetached).toHaveBeenCalledWith( + expect.arrayContaining(["--name", "openshell-alpha", "--gpus", "all"]), + expect.objectContaining({ ignoreError: true }), + ); + const detachedArgs = (dockerRunDetached.mock.calls[0] as unknown[])[0] as string[]; + expect(detachedArgs).not.toContain("--device"); + }); +}); diff --git a/src/lib/onboard/docker-gpu-patch.test.ts b/src/lib/onboard/docker-gpu-patch.test.ts index 5e7146e31e1..e95cb1c4f78 100644 --- a/src/lib/onboard/docker-gpu-patch.test.ts +++ b/src/lib/onboard/docker-gpu-patch.test.ts @@ -461,7 +461,7 @@ describe("docker-gpu-patch", () => { // candidate list prefers `cdi` ahead of `--gpus all` on CDI hosts (#4948). const readDir = vi.fn((dirPath: string) => (dirPath === "/etc/cdi" ? ["nvidia.yaml"] : null)); const readFile = vi.fn((filePath: string) => - filePath === "/etc/cdi/nvidia.yaml" + filePath.replace(/\\/g, "/") === "/etc/cdi/nvidia.yaml" ? "cdiVersion: 0.6.0\nkind: nvidia.com/gpu\ndevices:\n - name: all\n" : null, ); @@ -493,7 +493,7 @@ describe("docker-gpu-patch", () => { dirPath === "/var/run/cdi" ? ["nvidia.json"] : null, ); const readFile = vi.fn((filePath: string) => - filePath === "/var/run/cdi/nvidia.json" + filePath.replace(/\\/g, "/") === "/var/run/cdi/nvidia.json" ? JSON.stringify({ cdiVersion: "0.6.0", kind: "nvidia.com/gpu" }) : null, ); diff --git a/src/lib/onboard/docker-gpu-patch.ts b/src/lib/onboard/docker-gpu-patch.ts index e48bff8c98c..5473f310989 100644 --- a/src/lib/onboard/docker-gpu-patch.ts +++ b/src/lib/onboard/docker-gpu-patch.ts @@ -948,11 +948,23 @@ function probeDockerGpuMode( } } +/** + * Choose the first Docker GPU injection mode accepted by the runtime for the + * sandbox image. + */ export function selectDockerGpuPatchMode( - options: { image: string; device?: string | null; backend?: DockerGpuPatchBackend }, + options: { + image: string; + device?: string | null; + backend?: DockerGpuPatchBackend; + dockerDesktopWsl?: boolean; + }, deps: DockerGpuPatchDeps = {}, ): { mode: DockerGpuPatchMode | null; attempts: DockerGpuPatchModeAttempt[] } { - const cdiAvailable = options.backend === "jetson" ? false : dockerReportsNvidiaCdiDevices(deps); + const cdiAvailable = + options.backend === "jetson" || options.dockerDesktopWsl === true + ? false + : dockerReportsNvidiaCdiDevices(deps); const attempts: DockerGpuPatchModeAttempt[] = []; for (const mode of buildDockerGpuModeCandidates(options.device, { cdiAvailable, @@ -1006,6 +1018,10 @@ export function getDockerGpuPatchFailureContext( return null; } +/** + * Recreate an existing OpenShell Docker sandbox with the selected NVIDIA GPU + * access mode applied. + */ export function recreateOpenShellDockerSandboxWithGpu( options: { sandboxName: string; @@ -1014,6 +1030,7 @@ export function recreateOpenShellDockerSandboxWithGpu( waitForSupervisor?: boolean; openshellSandboxCommand?: readonly string[] | null; backend?: DockerGpuPatchBackend; + dockerDesktopWsl?: boolean; }, deps: DockerGpuPatchDeps = {}, ): DockerGpuPatchResult { @@ -1037,7 +1054,12 @@ export function recreateOpenShellDockerSandboxWithGpu( if (!image) throw new Error("OpenShell sandbox container inspect did not include an image."); const selection = selectDockerGpuPatchMode( - { image, device: options.gpuDevice, backend: options.backend }, + { + image, + device: options.gpuDevice, + backend: options.backend, + dockerDesktopWsl: options.dockerDesktopWsl, + }, deps, ); context.modeAttempts = selection.attempts; @@ -1046,7 +1068,9 @@ export function recreateOpenShellDockerSandboxWithGpu( const modeMessage = options.backend === "jetson" ? "Docker did not accept the Jetson NVIDIA runtime GPU mode." - : "Docker did not accept --gpus, NVIDIA runtime, or CDI GPU modes."; + : options.dockerDesktopWsl + ? "Docker did not accept Docker Desktop WSL --gpus or NVIDIA runtime GPU modes." + : "Docker did not accept --gpus, NVIDIA runtime, or CDI GPU modes."; throw new Error(modeMessage); } @@ -1172,6 +1196,10 @@ function printDockerGpuPatchCleanup(sandboxName: string): void { } } +/** + * Apply the Docker GPU sandbox recreation path and print actionable recovery + * guidance before exiting on failure. + */ export function applyDockerGpuPatchOrExit( options: { sandboxName: string; @@ -1183,6 +1211,7 @@ export function applyDockerGpuPatchOrExit( // `ensureApplied` fallback path would recreate the container without // /dev/nvmap group access. backend?: DockerGpuPatchBackend; + dockerDesktopWsl?: boolean; openshellSandboxCommand?: readonly string[] | null; }, deps: Pick, diff --git a/src/lib/onboard/docker-gpu-sandbox-create.test.ts b/src/lib/onboard/docker-gpu-sandbox-create.test.ts index 59236572e4d..9c38a3f564d 100644 --- a/src/lib/onboard/docker-gpu-sandbox-create.test.ts +++ b/src/lib/onboard/docker-gpu-sandbox-create.test.ts @@ -190,6 +190,35 @@ describe("createDockerGpuSandboxCreatePatch composed flow", () => { expect(onPatchFailureExit).not.toHaveBeenCalled(); }); + it("passes Docker Desktop WSL mode into the deferred recreate path", () => { + const deps = makeDeps(); + const result = deferredCreateResult(); + const recreatePatch = vi.fn(() => result); + const findContainerIds = vi.fn(() => ["existing-container"]); + + const patch = createDockerGpuSandboxCreatePatch({ + enabled: true, + sandboxName: "alpha", + timeoutSecs: 60, + dockerDesktopWsl: true, + deps, + overrides: { + findContainerIds, + recreatePatch, + waitForSupervisor: vi.fn(() => true), + finalizeBackup: vi.fn(() => ({ backupRemoved: true, rolledBack: false })), + onPatchFailureExit: vi.fn(), + }, + }); + + patch.maybeApplyDuringCreate(); + + expect(recreatePatch).toHaveBeenCalledWith( + expect.objectContaining({ dockerDesktopWsl: true, waitForSupervisor: false }), + expect.objectContaining({ runCaptureOpenshell: deps.runCaptureOpenshell }), + ); + }); + it("records patchError when recreate throws and exitOnPatchError reports it via printDockerGpuPatchFailureAndExit", () => { const deps = makeDeps(); const recreatePatch = vi.fn(() => { diff --git a/src/lib/onboard/docker-gpu-sandbox-create.ts b/src/lib/onboard/docker-gpu-sandbox-create.ts index 51eb4ed0426..60158c5cf04 100644 --- a/src/lib/onboard/docker-gpu-sandbox-create.ts +++ b/src/lib/onboard/docker-gpu-sandbox-create.ts @@ -26,6 +26,7 @@ import { detectWslDockerDesktopStatus } from "./wsl-docker-desktop-gpu"; let cachedDockerDesktopWslRuntime: boolean | null = null; +/** Detect whether the current WSL Docker runtime is Docker Desktop backed. */ export function isDockerDesktopWslRuntime(): boolean { if (cachedDockerDesktopWslRuntime === null) { cachedDockerDesktopWslRuntime = detectWslDockerDesktopStatus({}) === "docker-desktop"; @@ -33,6 +34,7 @@ export function isDockerDesktopWslRuntime(): boolean { return cachedDockerDesktopWslRuntime; } +/** Clear the cached Docker Desktop WSL runtime probe result for tests. */ export function resetIsDockerDesktopWslRuntimeCache(): void { cachedDockerDesktopWslRuntime = null; } @@ -46,9 +48,11 @@ type RecreatePatchFn = typeof recreateOpenShellDockerSandboxWithGpu; type WaitSupervisorFn = typeof waitForOpenShellSupervisorReconnect; type FindContainerIdsFn = typeof findOpenShellDockerSandboxContainerIds; type FinalizeBackupFn = typeof finalizeDockerGpuPatchBackup; -// Loosen the override return type from `never` to `void` so tests can pass a -// plain `vi.fn()` mock. Production wires `printDockerGpuPatchFailureAndExit` -// which has return type `never`; that is assignable to `void`. +/** + * Loosen the override return type from `never` to `void` so tests can pass a + * plain `vi.fn()` mock. Production wires `printDockerGpuPatchFailureAndExit` + * which has return type `never`; that is assignable to `void`. + */ type PatchFailureExitFn = ( sandboxName: string, error: unknown, @@ -62,6 +66,7 @@ type DockerGpuSandboxCreatePatchOptions = { openshellSandboxCommand?: readonly string[] | null; timeoutSecs: number; backend?: DockerGpuPatchBackend; + dockerDesktopWsl?: boolean; deps: DockerGpuSandboxCreateDeps; /** * Test seams. The production composition uses the canonical @@ -90,11 +95,17 @@ type DockerGpuSandboxCreatePlan = { }; export type DockerGpuSandboxCreatePatch = { + /** Apply the Docker GPU patch after OpenShell creates the sandbox container. */ maybeApplyDuringCreate: () => void; + /** Return a user-facing create failure message when patch application failed. */ createFailureMessage: () => string | null; + /** Exit through the shared patch failure diagnostics when patch application failed. */ exitOnPatchError: () => void; + /** Require the patch result before continuing with GPU verification. */ ensureApplied: () => void; + /** Wait for supervisor reconnect and roll back the patch when reconnect fails. */ waitForSupervisorReconnectIfNeeded: () => void; + /** Return the Docker GPU injection mode selected during patch application. */ selectedMode: () => DockerGpuPatchMode | null; /** * Print the Docker GPU readiness-failure block (including the Error-phase @@ -114,6 +125,10 @@ export type DockerGpuSandboxCreatePatch = { ) => SandboxGpuProofResult; }; +/** + * Build the create-time Docker GPU patch hooks that recreate a sandbox once + * OpenShell has materialized its Docker container. + */ export function createDockerGpuSandboxCreatePatch( options: DockerGpuSandboxCreatePatchOptions, ): DockerGpuSandboxCreatePatch { @@ -136,9 +151,11 @@ export function createDockerGpuSandboxCreatePatch( openshellSandboxCommand: options.openshellSandboxCommand ?? null, timeoutSecs: options.timeoutSecs, backend: options.backend, + dockerDesktopWsl: options.dockerDesktopWsl ?? isDockerDesktopWslRuntime(), }; return { + /** Apply the Docker GPU patch immediately after OpenShell creates its container. */ maybeApplyDuringCreate() { if (!options.enabled || result || patchError) return; const containerIds = findContainerIds(options.sandboxName); @@ -158,11 +175,13 @@ export function createDockerGpuSandboxCreatePatch( } }, + /** Return the create-phase patch failure summary for the onboarding wait loop. */ createFailureMessage() { if (!patchError) return null; return "Docker GPU patch failed while OpenShell sandbox create was still waiting."; }, + /** Exit with Docker GPU patch diagnostics when the deferred create patch failed. */ exitOnPatchError() { if (!patchError) return; onPatchFailureExit(options.sandboxName, patchError, { @@ -171,11 +190,13 @@ export function createDockerGpuSandboxCreatePatch( }); }, + /** Apply the Docker GPU patch after create if it was not already applied. */ ensureApplied() { if (!options.enabled || result) return; result = applyDockerGpuPatchOrExit(applyOptions, options.deps); }, + /** Wait for supervisor reconnect and roll back the patch if reconnect fails. */ waitForSupervisorReconnectIfNeeded() { if (!needsSupervisorWait) return; const supervisorReconnectTimeoutSecs = getDockerGpuSupervisorReconnectTimeoutSecs( @@ -201,14 +222,7 @@ export function createDockerGpuSandboxCreatePatch( ? finalizeBackup({ result, supervisorReady }, options.deps) : null; if (supervisorReady) return; - const failureMessage = (() => { - if (!finalizeOutcome) { - return "OpenShell supervisor did not reconnect to the GPU-enabled container."; - } - return finalizeOutcome.rolledBack - ? "OpenShell supervisor did not reconnect to the GPU-enabled container; pre-patch sandbox restored." - : "OpenShell supervisor did not reconnect to the GPU-enabled container and rollback failed; pre-patch sandbox was NOT restored."; - })(); + const failureMessage = supervisorReconnectFailureMessage(finalizeOutcome); onPatchFailureExit(options.sandboxName, new Error(failureMessage), { runCaptureOpenshell: options.deps.runCaptureOpenshell, dockerCapture: options.deps.dockerCapture, @@ -223,10 +237,12 @@ export function createDockerGpuSandboxCreatePatch( }); }, + /** Return the selected Docker GPU injection mode, if the patch ran. */ selectedMode() { return result?.mode ?? null; }, + /** Print readiness diagnostics when onboarding failed after the patch was enabled. */ printReadinessFailureIfEnabled() { if (!options.enabled) return; printDockerGpuReadinessFailure(options.sandboxName, result?.mode ?? null, { @@ -236,6 +252,7 @@ export function createDockerGpuSandboxCreatePatch( }); }, + /** Run the sandbox GPU proof and surface Docker GPU diagnostics on failure. */ verifyGpuOrExit(verifyDirectSandboxGpu) { // Before issuing GPU proof commands through `openshell sandbox exec`, // confirm the sandbox is still in a live phase. A sandbox that @@ -284,6 +301,19 @@ export function createDockerGpuSandboxCreatePatch( }; } +/** Build the supervisor reconnect failure message from the rollback outcome. */ +function supervisorReconnectFailureMessage( + finalizeOutcome: { rolledBack: boolean } | null, +): string { + if (!finalizeOutcome) { + return "OpenShell supervisor did not reconnect to the GPU-enabled container."; + } + return finalizeOutcome.rolledBack + ? "OpenShell supervisor did not reconnect to the GPU-enabled container; pre-patch sandbox restored." + : "OpenShell supervisor did not reconnect to the GPU-enabled container and rollback failed; pre-patch sandbox was NOT restored."; +} + +/** Build the shared Docker GPU patch failure context used by diagnostics. */ function buildFailureContext( sandboxName: string, result: DockerGpuPatchResult | null, @@ -300,6 +330,7 @@ function buildFailureContext( }; } +/** Decide whether sandbox creation should use the Docker GPU patch flow. */ export function shouldUseDockerGpuPatchForCreate( config: DockerGpuSandboxConfig, options: { @@ -323,6 +354,7 @@ export function shouldUseDockerGpuPatchForCreate( return enabled; } +/** Resolve the create-time Docker GPU patch plan and user-facing log message. */ export function resolveDockerGpuSandboxCreatePlan( config: DockerGpuSandboxConfig, options: {