Skip to content

Commit ced6c44

Browse files
committed
test(core): cover NVIDIA fallback invariants
1 parent f02e2ec commit ced6c44

6 files changed

Lines changed: 74 additions & 9 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/src/lib/usecases/projects-up.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ const ensureClaudeCliReady = (
134134
// FORMAT THEOREM: forall t,e: gpu(t)=all and nvidia_runtime_failure(e) -> gpu(retry(t,e))=none
135135
// PURITY: SHELL
136136
// EFFECT: Effect<TemplateConfig, DockerCommandError | FileExistsError | PlatformError, FileSystem | Path | CommandExecutor>
137-
// INVARIANT: fallback never escalates GPU access and rewrites managed files before retry
137+
// INVARIANT: fallback never escalates GPU access and terminates after at most one all -> none downgrade
138138
// COMPLEXITY: O(1) compose attempts plus O(file-size) template rewrite
139139
const runProjectComposeUp = (
140140
projectDir: string,
@@ -145,14 +145,17 @@ const runProjectComposeUp = (
145145
Effect.catchTag("DockerCommandError", (error) => {
146146
const fallbackGpu = gpuModeAfterDockerFailure(template.gpu, error.details)
147147
if (fallbackGpu === template.gpu) {
148+
// Idempotence witness: non-NVIDIA errors and gpu=none cannot produce a lower GPU mode.
148149
return Effect.fail(error)
149150
}
150151

151152
const fallbackTemplate: TemplateConfig = { ...template, gpu: fallbackGpu }
152153
return Effect.gen(function*(_) {
153154
yield* _(
154155
Effect.logWarning(
155-
"NVIDIA runtime failed while GPU access was enabled; rewriting project with GPU access disabled and retrying docker compose up."
156+
`NVIDIA runtime failed while GPU access was enabled (${
157+
error.details ?? "no docker output"
158+
}); rewriting project with GPU access disabled and retrying docker compose up.`
156159
)
157160
)
158161
yield* _(syncManagedProjectFiles(projectDir, fallbackTemplate))

packages/lib/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"eslint-plugin-sonarjs": "^4.0.3",
7575
"eslint-plugin-sort-destructure-keys": "^3.0.0",
7676
"eslint-plugin-unicorn": "^64.0.0",
77+
"fast-check": "^3.23.2",
7778
"@vitest/eslint-plugin": "^1.6.17",
7879
"globals": "^17.6.0",
7980
"jscpd": "^4.1.1",

packages/lib/src/usecases/projects-up.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ const startProjectPostStartSelfHealInBackground = (
181181
// FORMAT THEOREM: forall t,e: gpu(t)=all and nvidia_runtime_failure(e) -> gpu(retry(t,e))=none
182182
// PURITY: SHELL
183183
// EFFECT: Effect<TemplateConfig, DockerCommandError | FileExistsError | PlatformError, FileSystem | Path | CommandExecutor>
184-
// INVARIANT: fallback never escalates GPU access and rewrites managed files before retry
184+
// INVARIANT: fallback never escalates GPU access and terminates after at most one all -> none downgrade
185185
// COMPLEXITY: O(1) compose attempts plus O(file-size) template rewrite
186186
const recoverProjectComposeGpuFailure = (
187187
projectDir: string,
@@ -194,14 +194,17 @@ const recoverProjectComposeGpuFailure = (
194194
Effect.catchTag("DockerCommandError", (error) => {
195195
const fallbackGpu = gpuModeAfterDockerFailure(template.gpu, error.details)
196196
if (fallbackGpu === template.gpu) {
197+
// Idempotence witness: non-NVIDIA errors and gpu=none cannot produce a lower GPU mode.
197198
return Effect.fail(error)
198199
}
199200

200201
const fallbackTemplate: TemplateConfig = { ...template, gpu: fallbackGpu }
201202
return Effect.gen(function*(_) {
202203
yield* _(
203204
Effect.logWarning(
204-
"NVIDIA runtime failed while GPU access was enabled; rewriting project with GPU access disabled and retrying docker compose up."
205+
`NVIDIA runtime failed while GPU access was enabled (${
206+
error.details ?? "no docker output"
207+
}); rewriting project with GPU access disabled and retrying docker compose up.`
205208
)
206209
)
207210
yield* _(syncManagedProjectFiles(projectDir, fallbackTemplate))

packages/lib/tests/usecases/errors.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "@effect/vitest"
2+
import fc from "fast-check"
23

34
import {
45
DockerAccessError,
@@ -41,6 +42,51 @@ describe("renderError", () => {
4142
expect(message).toContain("NVIDIA Container Toolkit")
4243
})
4344

45+
it("shows NVIDIA hint iff docker output contains NVIDIA runtime markers", () => {
46+
const nvidiaContainerCliMarker = "nvidia-container-cli"
47+
const libNvidiaMlMarker = "libnvidia-ml.so.1"
48+
const missingDeviceDriverMarker = "could not select device driver"
49+
const markers: ReadonlyArray<string> = [
50+
nvidiaContainerCliMarker,
51+
libNvidiaMlMarker,
52+
missingDeviceDriverMarker
53+
]
54+
const includesMarker = (details: string): boolean => markers.some((marker) => details.includes(marker))
55+
56+
fc.assert(
57+
fc.property(
58+
fc.string(),
59+
fc.constantFrom(nvidiaContainerCliMarker, libNvidiaMlMarker, missingDeviceDriverMarker),
60+
fc.string(),
61+
(left, marker, right) => {
62+
const message = renderError(
63+
new DockerCommandError({
64+
exitCode: 1,
65+
details: `${left}${marker}${right}`
66+
})
67+
)
68+
69+
expect(message).toContain("--gpu none")
70+
}
71+
),
72+
{ numRuns: 50 }
73+
)
74+
75+
fc.assert(
76+
fc.property(fc.string().filter((details) => !includesMarker(details)), (details) => {
77+
const message = renderError(
78+
new DockerCommandError({
79+
exitCode: 1,
80+
details
81+
})
82+
)
83+
84+
expect(message).not.toContain("--gpu none")
85+
}),
86+
{ numRuns: 50 }
87+
)
88+
})
89+
4490
it("renders actionable recovery for DockerAccessError", () => {
4591
const message = renderError(
4692
new DockerAccessError({

packages/lib/tests/usecases/projects-up.test.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import * as FileSystem from "@effect/platform/FileSystem"
44
import * as Path from "@effect/platform/Path"
55
import { NodeContext } from "@effect/platform-node"
66
import { describe, expect, it } from "@effect/vitest"
7-
import { Effect } from "effect"
7+
import { Effect, Logger } from "effect"
88
import * as Inspectable from "effect/Inspectable"
9+
import * as Option from "effect/Option"
910
import * as Sink from "effect/Sink"
1011
import * as Stream from "effect/Stream"
1112

@@ -112,15 +113,15 @@ const makeFakeExecutor = (
112113
recorded.push({
113114
command: entry.command,
114115
args: entry.args,
115-
cwd: entry.cwd._tag === "Some" ? entry.cwd.value : undefined
116+
cwd: Option.getOrUndefined(entry.cwd)
116117
})
117118
}
118119

119120
const last = flattened[flattened.length - 1]!
120121
const invocation: RecordedCommand = {
121122
command: last.command,
122123
args: last.args,
123-
cwd: last.cwd._tag === "Some" ? last.cwd.value : undefined
124+
cwd: Option.getOrUndefined(last.cwd)
124125
}
125126
const stdoutText = decideStdout(invocation)
126127
const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText))
@@ -263,7 +264,11 @@ describe("runDockerComposeUpWithPortCheck", () => {
263264
gpu: "all"
264265
}
265266
const recorded: Array<RecordedCommand> = []
267+
const logs: Array<string> = []
266268
const executor = makeFakeExecutor(recorded, { failGpuComposeUp: true })
269+
const logger = Logger.make(({ message }) => {
270+
logs.push(String(message))
271+
})
267272

268273
yield* _(
269274
prepareProjectFiles(outDir, root, globalConfig, projectConfig, {
@@ -274,7 +279,8 @@ describe("runDockerComposeUpWithPortCheck", () => {
274279

275280
const started = yield* _(
276281
runDockerComposeUpWithPortCheck(outDir).pipe(
277-
Effect.provideService(CommandExecutor.CommandExecutor, executor)
282+
Effect.provideService(CommandExecutor.CommandExecutor, executor),
283+
Effect.provide(Logger.replace(Logger.defaultLogger, logger))
278284
)
279285
)
280286

@@ -285,7 +291,12 @@ describe("runDockerComposeUpWithPortCheck", () => {
285291

286292
const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json")))
287293
expect(configAfter).toContain('"gpu": "none"')
288-
expect(recorded.filter((entry) => isDockerComposeUpWithBuild(entry)).length).toBeGreaterThan(1)
294+
expect(recorded.filter((entry) => isDockerComposeUpWithBuild(entry)).length).toBe(2)
295+
expect(logs.some((entry) =>
296+
entry.includes("NVIDIA runtime failed") &&
297+
entry.includes("libnvidia-ml.so.1") &&
298+
entry.includes("GPU access disabled")
299+
)).toBe(true)
289300
})
290301
).pipe(Effect.provide(NodeContext.layer)))
291302

0 commit comments

Comments
 (0)