Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/lib/onboard/docker-gpu-patch-mode-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down Expand Up @@ -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");
});
});
4 changes: 2 additions & 2 deletions src/lib/onboard/docker-gpu-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down
37 changes: 33 additions & 4 deletions src/lib/onboard/docker-gpu-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -1014,6 +1030,7 @@ export function recreateOpenShellDockerSandboxWithGpu(
waitForSupervisor?: boolean;
openshellSandboxCommand?: readonly string[] | null;
backend?: DockerGpuPatchBackend;
dockerDesktopWsl?: boolean;
},
deps: DockerGpuPatchDeps = {},
): DockerGpuPatchResult {
Expand All @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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<DockerGpuPatchDeps, "runOpenshell" | "runCaptureOpenshell" | "sleep">,
Expand Down
29 changes: 29 additions & 0 deletions src/lib/onboard/docker-gpu-sandbox-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
54 changes: 43 additions & 11 deletions src/lib/onboard/docker-gpu-sandbox-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ 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";
}
return cachedDockerDesktopWslRuntime;
}

/** Clear the cached Docker Desktop WSL runtime probe result for tests. */
export function resetIsDockerDesktopWslRuntimeCache(): void {
cachedDockerDesktopWslRuntime = null;
}
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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, {
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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, {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -300,6 +330,7 @@ function buildFailureContext(
};
}

/** Decide whether sandbox creation should use the Docker GPU patch flow. */
export function shouldUseDockerGpuPatchForCreate(
config: DockerGpuSandboxConfig,
options: {
Expand All @@ -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: {
Expand Down
Loading