Skip to content

Commit 94cc83e

Browse files
committed
fix(onboard): name the failed GPU trust-gate check in preflight
When the GPU trust gate rejected an nvidia-smi report, preflight printed only the bare "no GPU detected", which points the user at hardware and drivers that can be working. The check that actually failed — an absent /proc/driver/nvidia interface, an unattempted proof on multiple GPU rows, an unavailable prover, or a failed bounded CUDA workload — was known at rejection time and then discarded. detectGpu() now reports the rejecting check through an injected onTrustGateRejection callback. The reason is fixed text only and never echoes nvidia-smi output, which is untrusted. The readiness-gated runtime preflight carries the reason from the newest detection on its result, and onboard preflight prints it under the existing "Local NIM unavailable — no GPU detected" line. Closes #9000 Signed-off-by: Dongni Yang <dongniy@nvidia.com>
1 parent a774d0a commit 94cc83e

6 files changed

Lines changed: 262 additions & 10 deletions

File tree

docs/reference/troubleshooting.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,7 @@ A failed or timed-out workload leaves the GPU unproven and does not enable GPU p
951951
The names-only unified-memory fallback does not run this workload and rejects denylisted names.
952952
WSL hosts that are not Docker Desktop-backed do not run the workload and continue to report the GPU as unavailable.
953953

954+
When GPU detection rejects the `nvidia-smi` report, preflight prints the failed check under the `Local NIM unavailable — no GPU detected` line, for example an absent `/proc/driver/nvidia` interface or a failed bounded CUDA proof.
954955
If NemoClaw rejects the detected GPU name during preflight, select a CPU or remote inference provider, or move the setup to a host with a supported NVIDIA GPU and current drivers.
955956

956957
Jetson/Tegra hosts support sandbox GPU passthrough through the compatibility route.

src/lib/inference/nim.gpu-proof-plausible-name.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,110 @@ describe("detectGpu CUDA proof for a plausible, non-placeholder NVIDIA GPU name
188188
});
189189
});
190190
});
191+
192+
describe("detectGpu trust-gate rejection reasons (#9000)", () => {
193+
const collectReasons = () => {
194+
const reasons: string[] = [];
195+
return { reasons, onTrustGateRejection: (reason: string) => reasons.push(reason) };
196+
};
197+
198+
it("reports the absent kernel interface when the CUDA proof fails (#9000)", () => {
199+
const { reasons, onTrustGateRejection } = collectReasons();
200+
onWsl2Arm64WithoutKernelInterface(() => {
201+
expect(
202+
detectGpu({
203+
proveArm64WslDockerDesktopGpu: failingProver(),
204+
runCaptureImpl: makeRunCapture(`${PLAUSIBLE_NAME}, 8128, 7000\n`),
205+
isWsl: true,
206+
onTrustGateRejection,
207+
}),
208+
).toBeNull();
209+
});
210+
expect(reasons).toEqual(["/proc/driver/nvidia is absent and the bounded CUDA proof failed"]);
211+
});
212+
213+
it("reports an unattempted proof when no prover is available (#9000)", () => {
214+
const { reasons, onTrustGateRejection } = collectReasons();
215+
onWsl2Arm64WithoutKernelInterface(() => {
216+
expect(
217+
detectGpu({
218+
proveArm64WslDockerDesktopGpu: null,
219+
runCaptureImpl: makeRunCapture(`${PLAUSIBLE_NAME}, 8128, 7000\n`),
220+
isWsl: true,
221+
onTrustGateRejection,
222+
}),
223+
).toBeNull();
224+
});
225+
expect(reasons).toEqual([
226+
"/proc/driver/nvidia is absent and the bounded CUDA proof was not attempted",
227+
]);
228+
});
229+
230+
it("reports an unrecognized GPU name without attempting the proof (#9000)", () => {
231+
const { reasons, onTrustGateRejection } = collectReasons();
232+
onWsl2Arm64WithoutKernelInterface(() => {
233+
expect(
234+
detectGpu({
235+
proveArm64WslDockerDesktopGpu: passingProver(),
236+
runCaptureImpl: makeRunCapture("Graphics Device, 8128, 7000\n"),
237+
isWsl: true,
238+
onTrustGateRejection,
239+
}),
240+
).toBeNull();
241+
});
242+
expect(reasons).toEqual([
243+
"nvidia-smi reported a GPU name that is not a recognized NVIDIA product and the bounded CUDA proof was not attempted",
244+
]);
245+
});
246+
247+
it("reports multiple GPU rows without attempting the proof (#9000)", () => {
248+
const { reasons, onTrustGateRejection } = collectReasons();
249+
onWsl2Arm64WithoutKernelInterface(() => {
250+
expect(
251+
detectGpu({
252+
proveArm64WslDockerDesktopGpu: passingProver(),
253+
runCaptureImpl: makeRunCapture(
254+
`${PLAUSIBLE_NAME}, 8128, 7000\nNVIDIA GeForce RTX 4090 Laptop GPU, 16376, 15000\n`,
255+
),
256+
isWsl: true,
257+
onTrustGateRejection,
258+
}),
259+
).toBeNull();
260+
});
261+
expect(reasons).toEqual([
262+
"/proc/driver/nvidia is absent and the bounded CUDA proof was not attempted for multiple GPU rows",
263+
]);
264+
});
265+
266+
it("reports a placeholder GPU name when its CUDA proof fails (#9000)", () => {
267+
const { reasons, onTrustGateRejection } = collectReasons();
268+
onWsl2Arm64WithoutKernelInterface(() => {
269+
expect(
270+
detectGpu({
271+
proveArm64WslDockerDesktopGpu: failingProver(),
272+
runCaptureImpl: makeRunCapture("JMJWOA-Generic-GPU, 65471, 65000\n"),
273+
isWsl: true,
274+
onTrustGateRejection,
275+
}),
276+
).toBeNull();
277+
});
278+
expect(reasons).toEqual([
279+
"nvidia-smi reported a placeholder GPU name and the bounded CUDA proof failed",
280+
]);
281+
});
282+
283+
it("emits no rejection reason when the CUDA proof passes (#9000)", () => {
284+
const { reasons, onTrustGateRejection } = collectReasons();
285+
onWsl2Arm64WithoutKernelInterface(() => {
286+
expect(
287+
detectGpu({
288+
proveArm64WslDockerDesktopGpu: passingProver(),
289+
runCaptureImpl: makeRunCapture(`${PLAUSIBLE_NAME}, 8128, 7000\n`),
290+
isWsl: true,
291+
onTrustGateRejection,
292+
}),
293+
).not.toBeNull();
294+
});
295+
expect(reasons).toEqual([]);
296+
});
297+
});

src/lib/inference/nim.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ export interface DetectGpuDeps {
109109
runCaptureImpl?: typeof runCapture;
110110
/** Override WSL detection for deterministic tests. */
111111
isWsl?: boolean;
112+
// Receives one sentence naming the check that rejected an nvidia-smi probe
113+
// when the trust gate returns no GPU, so preflight can say which check
114+
// failed instead of the bare "no GPU detected" (#9000). The reason is built
115+
// from fixed text only — never from nvidia-smi output, which is untrusted.
116+
onTrustGateRejection?: (reason: string) => void;
112117
}
113118

114119
// Lazily construct the default ARM64 Linux GPU prover. Keep it behind a require
@@ -467,16 +472,21 @@ export function detectGpu(deps: DetectGpuDeps = {}): GpuDetection | null {
467472
// The all-GPU CUDA workload proves that at least one usable device
468473
// exists. It does not establish which nvidia-smi rows or capacities
469474
// are genuine, so a multi-row response stays untrusted.
470-
const passesBoundedCudaProof = (): boolean => {
475+
// Null when the proof passed; otherwise a fixed-text fragment naming
476+
// why it did not, composed into the onTrustGateRejection reason.
477+
const boundedCudaProofRejection = (): string | null => {
471478
if (parsed.length !== 1) {
472-
return false;
479+
return "the bounded CUDA proof was not attempted for multiple GPU rows";
473480
}
474481
const prover =
475482
deps.proveArm64WslDockerDesktopGpu === undefined
476483
? defaultArm64WslDockerDesktopGpuProver()
477484
: deps.proveArm64WslDockerDesktopGpu;
478485
const proof = prover ? prover(parsed.map((p: ParsedGpu) => p.name)) : null;
479-
return !!proof && proof.passed;
486+
if (!proof) {
487+
return "the bounded CUDA proof was not attempted";
488+
}
489+
return proof.passed ? null : "the bounded CUDA proof failed";
480490
};
481491
let trusted: ParsedGpu[];
482492
let wslDockerDesktopGpuProofPassed = false;
@@ -489,7 +499,11 @@ export function detectGpu(deps: DetectGpuDeps = {}): GpuDetection | null {
489499
// A bounded Docker `--gpus` workload proves that the single reported
490500
// row has a usable CUDA device. The Snapdragon shim cannot pass it
491501
// (#4565 without reopening #3988/#4424).
492-
if (!passesBoundedCudaProof()) {
502+
const proofRejection = boundedCudaProofRejection();
503+
if (proofRejection) {
504+
deps.onTrustGateRejection?.(
505+
`nvidia-smi reported a placeholder GPU name and ${proofRejection}`,
506+
);
493507
return null;
494508
}
495509
trusted = parsed;
@@ -504,7 +518,15 @@ export function detectGpu(deps: DetectGpuDeps = {}): GpuDetection | null {
504518
// name the filter below would discard never starts the Docker workload.
505519
// Without a passing proof this path stays fail-closed as before.
506520
const plausible = parsed.every((p: ParsedGpu) => isPlausibleNvidiaGpuName(p.name));
507-
if (!plausible || !passesBoundedCudaProof()) {
521+
if (!plausible) {
522+
deps.onTrustGateRejection?.(
523+
"nvidia-smi reported a GPU name that is not a recognized NVIDIA product and the bounded CUDA proof was not attempted",
524+
);
525+
return null;
526+
}
527+
const proofRejection = boundedCudaProofRejection();
528+
if (proofRejection) {
529+
deps.onTrustGateRejection?.(`/proc/driver/nvidia is absent and ${proofRejection}`);
508530
return null;
509531
}
510532
wslDockerDesktopGpuProofPassed = true;

src/lib/onboard.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1361,7 +1361,7 @@ async function preflight(
13611361
preflightOpts: PreflightOptions = {},
13621362
): Promise<ReturnType<typeof nim.detectGpu>> {
13631363
step(1, 8, "Preflight checks");
1364-
const { gpu, host, sandboxGpuConfig } =
1364+
const { gpu, host, sandboxGpuConfig, gpuTrustGateRejection } =
13651365
await onboardPreflightGatewayAuthority.runRuntimePreflight(preflightOpts);
13661366

13671367
await preflightUtils.checkContainerRuntimeResources(host, {
@@ -1515,6 +1515,9 @@ async function preflight(
15151515
console.log(" ⓘ Local NIM unavailable — requires NVIDIA GPU");
15161516
} else {
15171517
console.log(" ⓘ Local NIM unavailable — no GPU detected");
1518+
if (gpuTrustGateRejection) {
1519+
console.log(` GPU detection rejected the nvidia-smi report: ${gpuTrustGateRejection}`);
1520+
}
15181521
}
15191522

15201523
if (sandboxGpuConfig.sandboxGpuEnabled) {

src/lib/onboard/fatal-runtime-preflight.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,89 @@ describe("readiness-gated runtime preflight", () => {
670670
expect(gpu).not.toHaveBeenCalled();
671671
});
672672
});
673+
674+
describe("GPU trust-gate rejection reason propagation (#9000)", () => {
675+
const gatedContext = (detectGpu: (deps?: DetectGpuDeps) => GpuDetection | null, host: HostAssessment) => ({
676+
nonInteractive: true,
677+
collectGatewayReadiness: async () => managedGatewayReadiness(),
678+
assessHost: () => host,
679+
detectGpu,
680+
warnIfHostProxyMissesLoopback: vi.fn(),
681+
assertDockerBridgeAndContainerDnsHealthy: vi.fn(),
682+
validateSandboxGpuPreflight: vi.fn(),
683+
});
684+
685+
it("carries the runtime-proof rejection reason when the bounded proof fails (#9000)", async () => {
686+
const detectGpu = vi.fn((deps?: DetectGpuDeps): GpuDetection | null => {
687+
const isObservation = deps?.proveArm64WslDockerDesktopGpu === null;
688+
deps?.onTrustGateRejection?.(
689+
isObservation
690+
? "/proc/driver/nvidia is absent and the bounded CUDA proof was not attempted"
691+
: "/proc/driver/nvidia is absent and the bounded CUDA proof failed",
692+
);
693+
return null;
694+
});
695+
696+
const result = await runReadinessGatedRuntimePreflight(
697+
{},
698+
gatedContext(detectGpu, wslDockerDesktopHost()),
699+
);
700+
701+
expect(result.gpu).toBeNull();
702+
expect(result.gpuTrustGateRejection).toBe(
703+
"/proc/driver/nvidia is absent and the bounded CUDA proof failed",
704+
);
705+
});
706+
707+
it("carries the observation rejection reason when no runtime proof is required (#9000)", async () => {
708+
const detectGpu = vi.fn((deps?: DetectGpuDeps): GpuDetection | null => {
709+
deps?.onTrustGateRejection?.(
710+
"/proc/driver/nvidia is absent and the bounded CUDA proof was not attempted",
711+
);
712+
return null;
713+
});
714+
715+
const result = await runReadinessGatedRuntimePreflight(
716+
{},
717+
gatedContext(detectGpu, {
718+
...hostWithRuntime("docker"),
719+
hasNvidiaGpu: true,
720+
nvidiaContainerToolkitInstalled: true,
721+
dockerCdiSpecDirs: ["/etc/cdi"],
722+
}),
723+
);
724+
725+
expect(result.gpu).toBeNull();
726+
expect(result.gpuTrustGateRejection).toBe(
727+
"/proc/driver/nvidia is absent and the bounded CUDA proof was not attempted",
728+
);
729+
});
730+
731+
it("omits the rejection reason when the runtime proof passes (#9000)", async () => {
732+
const detectGpu = vi.fn((deps?: DetectGpuDeps): GpuDetection | null => {
733+
const isObservation = deps?.proveArm64WslDockerDesktopGpu === null;
734+
isObservation &&
735+
deps?.onTrustGateRejection?.(
736+
"/proc/driver/nvidia is absent and the bounded CUDA proof was not attempted",
737+
);
738+
return isObservation
739+
? null
740+
: {
741+
type: "nvidia",
742+
count: 1,
743+
totalMemoryMB: 32_768,
744+
perGpuMB: 32_768,
745+
nimCapable: true,
746+
wslDockerDesktopGpuProofPassed: true,
747+
};
748+
});
749+
750+
const result = await runReadinessGatedRuntimePreflight(
751+
{},
752+
gatedContext(detectGpu, wslDockerDesktopHost()),
753+
);
754+
755+
expect(result.gpu).toMatchObject({ wslDockerDesktopGpuProofPassed: true });
756+
expect(result.gpuTrustGateRejection).toBeUndefined();
757+
});
758+
});

src/lib/onboard/fatal-runtime-preflight.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ export interface FatalRuntimePreflightResult {
7474
host: HostAssessment;
7575
readinessReport: SystemReadinessReport;
7676
sandboxGpuConfig: SandboxGpuConfig;
77+
// Which trust-gate check rejected the newest GPU detection, so preflight can
78+
// name the failed check instead of the bare "no GPU detected" (#9000).
79+
// Absent when detection found a GPU, never ran the trust gate, or discarded
80+
// every row through the plausible-name filter, which records no reason.
81+
gpuTrustGateRejection?: string;
7782
}
7883

7984
export type ReadinessGatedRuntimePreflightContext = Omit<
@@ -239,9 +244,15 @@ function refreshOnboardHostReadiness(
239244
const now = context.now ?? (() => new Date());
240245
const observedAt = now().toISOString();
241246
const host = (context.assessHost ?? assessHost)();
247+
let gpuTrustGateRejection: string | undefined;
242248
const gpu = runtimeGpu
243249
? runtimeGpu.value
244-
: (context.detectGpu ?? detectGpu)({ proveArm64WslDockerDesktopGpu: null });
250+
: (context.detectGpu ?? detectGpu)({
251+
proveArm64WslDockerDesktopGpu: null,
252+
onTrustGateRejection: (reason) => {
253+
gpuTrustGateRejection = reason;
254+
},
255+
});
245256
const sandboxGpuConfig = resolveSandboxGpuConfig(gpu, {
246257
flag: resolveSandboxGpuFlagFromOptions(options),
247258
device: options.sandboxGpuDevice ?? null,
@@ -256,7 +267,13 @@ function refreshOnboardHostReadiness(
256267
observedAt,
257268
now,
258269
});
259-
return { gpu, host, readinessReport, sandboxGpuConfig };
270+
return {
271+
gpu,
272+
host,
273+
readinessReport,
274+
sandboxGpuConfig,
275+
...(gpuTrustGateRejection ? { gpuTrustGateRejection } : {}),
276+
};
260277
}
261278

262279
/** Resolve the bounded WSL GPU proof only after canonical readiness admission. */
@@ -266,13 +283,21 @@ function resolveRuntimeGpuProof(
266283
context: FatalRuntimePreflightContext,
267284
): { result: FatalRuntimePreflightResult; proofRan: boolean } {
268285
if (!requiresRuntimeGpuProof(result, options)) return { result, proofRan: false };
269-
const gpu = (context.detectGpu ?? detectGpu)();
286+
let gpuTrustGateRejection: string | undefined;
287+
const gpu = (context.detectGpu ?? detectGpu)({
288+
onTrustGateRejection: (reason) => {
289+
gpuTrustGateRejection = reason;
290+
},
291+
});
270292
const sandboxGpuConfig = resolveSandboxGpuConfig(gpu, {
271293
flag: resolveSandboxGpuFlagFromOptions(options),
272294
device: options.sandboxGpuDevice ?? null,
273295
});
296+
// The proof-phase detection replaces the observation-phase result, so its
297+
// rejection reason (or its absence, when the proof passed) replaces the
298+
// observation-phase reason too.
274299
return {
275-
result: { ...result, gpu, sandboxGpuConfig },
300+
result: { ...result, gpu, sandboxGpuConfig, gpuTrustGateRejection },
276301
proofRan: true,
277302
};
278303
}
@@ -380,6 +405,14 @@ export async function runReadinessGatedRuntimePreflight(
380405
? false
381406
: runtimeGpu.result.gpu.wslDockerDesktopGpuProofPassed,
382407
});
408+
// The refresh reuses the proof-phase GPU value without re-detecting, so
409+
// carry the proof-phase rejection reason alongside it (#9000).
410+
refreshedResult = {
411+
...refreshedResult,
412+
...(runtimeGpu.result.gpuTrustGateRejection
413+
? { gpuTrustGateRejection: runtimeGpu.result.gpuTrustGateRejection }
414+
: {}),
415+
};
383416
}
384417
gatewayReadiness = refreshGatewayReadinessProjection(gatewayReadiness);
385418
assertOnboardGatewayReadiness(gatewayReadiness, exitProcess);

0 commit comments

Comments
 (0)