fix(hardware): nvidia-smi probe backs off instead of giving up for good #62

Merged
julian merged 4 commits from fix/nvidia-smi-backoff into main 2026-09-23 15:12:32 +02:00
Owner

startGpuCollectors gated the NVIDIA probe on nvidiaFailures < 3. The counter only resets on a success, and once it reaches 3 the probe never runs again, so there is no success to reset it: three consecutive failures end NVIDIA sampling until the container is restarted. The comment above it said the opposite ("so a transient error doesn't kill the metrics until restart").

Three ticks is 30 s at the default interval. Anything that takes nvidia-smi away for longer than that is enough: a driver upgrade or a module reload on the host, a GPU that falls off the bus and is rescanned, a nvidia-smi that hangs into the 8 s timeout three times under load.

Found by reading the code, not from a failure in production. It came up while investigating an unattended driver upgrade on epyclab (610.43.02 → 610.57.04) where the kernel module was unloaded for the upgrade. The probe did not fail there — the opsdeck log shows it running every 10 s throughout, with no warning — because of the second point under "Not changed". Had the module actually stayed unloaded for the ~80 s the upgrade took, the metrics would have been gone until the next restart.

Change

nvidiaBackoff(intervalMs) (pure) replaces the bare counter:

  • below NVIDIA_BACKOFF_AFTER (3) consecutive failures: probe every tick, as before
  • from there on: the gap between attempts starts at two ticks and doubles per failed retry, capped at the ticks spanning NVIDIA_RETRY_MAX_MS (60 s), never below two. At the default interval that is 20 s, 40 s, then one attempt per minute. A probe that merely timed out under load is back within a few ticks; a host that keeps failing settles at one attempt per minute. An INTERVAL_MS above 60 s does not turn the backoff into a no-op.
  • the gap is counted in scheduler ticks, not milliseconds: due() is called exactly once per tick and counts down. No clock is involved, so neither an NTP step nor a resume from suspend can move the next attempt, and there is no drift against the setInterval grid. A tick the scheduler skips for overlap is not counted and lengthens that gap by one.
  • first success: counter and backoff reset, per-tick sampling resumes, and nvidia-smi is answering again is logged at info

Missing binary. If ctx.exec throws Deno.errors.NotFound, the container has no nvidia-smi at all. Only the NVIDIA container toolkit puts it there, at container creation, so nothing short of recreating the container changes that: the probe stops for the lifetime of the process (stop()). It logs a warning if an NVIDIA DRM card is present (the "run the container with the NVIDIA runtime" hint), otherwise one debug line. This is every VM and GPU-less host, and they are back to the old cost: one spawn, not one a minute. A driver upgrade does not hit this path — the bind-mounted binary stays and fails with a non-zero exit, which gets the backoff.

Any other failure (non-zero exit, timeout) backs off and keeps retrying. The runtime warning fires once per outage (failures === NVIDIA_BACKOFF_AFTER, NVIDIA card present). Each retry produces two log lines: the unconditional exec line at info that every ctx.exec call emits (server/src/util/exec.ts, kept as the audit trail for host commands), and the failure itself at debug. On a host where nvidia-smi exists but keeps failing that is ~1440 info lines a day, next to the ~8640 a working probe already produces.

Not changed

Stale samples are not cleared while the probe is failing; the last written values stay as they did before. With the backoff that means frozen values for the length of the outage with nothing marking them stale. What the GPU page should show during an outage is a separate decision: #70.

The probe is gated on a one-time DRM snapshot (detectGpus runs once). If the process starts while the nvidia modules are unloaded and the host has another DRM card (the ASPEED BMC on a server board), the probe never runs and the backoff never ticks. That predates this PR and is the other half of the same problem, but fixing it changes behaviour on non-NVIDIA hosts: #69.

Worth knowing, though not something this PR addresses: on a host where the nvidia module is unloaded on purpose, the probe is what loads it again — the container is privileged, and opening /dev/nvidiactl triggers the kernel's char-major-195-* alias. On epyclab the probe at 04:16:43.249 lines up with the kernel loading the old module at 04:16:43.45, two seconds after the upgrade script had unloaded it, which is what broke that upgrade. The script now blocks module autoload and stops the opsdeck container for the duration, so nothing is needed here — but it does mean the container will see exactly the kind of outage this PR is about on every future driver upgrade.

Tests

gpu_test.ts, against nvidiaBackoff directly: per-tick probing until the threshold; the exact probed ticks over 40 all-failing ticks (0, 1, 2, 4, 8, 14, 20, … — gaps 2, 4, then 6 at the cap); an interval above the cap still waiting two ticks; recovery reported once with the count reset; stop() ending probing.

Against startGpuCollectors with a fake ctx (no DRM cards, scripted exec, ticks driven by hand):

  • NotFound on the first tick: exec called once in 50 ticks, a single debug line
  • five non-zero exits, then an answer: probed at 0, 1, 2, 4, 8, 14, 15, …, nvidia-smi is answering again logged once, no warning without an NVIDIA card, nv0 published and samples written
deno test --allow-read packages/modules/hardware/backend/gpu_test.ts   # 15 passed
deno lint / deno fmt --check packages/modules/hardware/backend/        # clean
deno task check:backend                                                # clean
deno task test                                                         # 604 passed, 1 ignored

Not exercised against a real GPU outage.

Review round 1 (ae7f323)

  • #2 monotonic clock: done, performance.now() default (since replaced by tick counting, round 2)
  • #4 window tied to the tick: done, exponential from two ticks, capped at 60 s
  • #3 description claimed retries log only at debug: corrected above, the exec info line stays as the audit trail
  • #1 DRM snapshot gate: #69
  • stale samples nit: #70
  • ok()/fail() mutate-and-return: left as is

Review round 2 (98be265)

  • #1 GPU-less hosts probing forever: NotFound stops the probe for good (stop(), kept separate so the backoff stays error-agnostic); other errors back off
  • #2 gaps one tick longer than documented: backoff counts ticks; deadline in ms removed, and the clock injection with it
  • wiring nit: two startGpuCollectors tests with a fake ctx

🤖 Generated with Claude Code

`startGpuCollectors` gated the NVIDIA probe on `nvidiaFailures < 3`. The counter only resets on a success, and once it reaches 3 the probe never runs again, so there is no success to reset it: three consecutive failures end NVIDIA sampling until the container is restarted. The comment above it said the opposite ("so a transient error doesn't kill the metrics until restart"). Three ticks is 30 s at the default interval. Anything that takes `nvidia-smi` away for longer than that is enough: a driver upgrade or a module reload on the host, a GPU that falls off the bus and is rescanned, a `nvidia-smi` that hangs into the 8 s timeout three times under load. Found by reading the code, not from a failure in production. It came up while investigating an unattended driver upgrade on epyclab (610.43.02 → 610.57.04) where the kernel module was unloaded for the upgrade. The probe did **not** fail there — the opsdeck log shows it running every 10 s throughout, with no warning — because of the second point under "Not changed". Had the module actually stayed unloaded for the ~80 s the upgrade took, the metrics would have been gone until the next restart. ### Change `nvidiaBackoff(intervalMs)` (pure) replaces the bare counter: - below `NVIDIA_BACKOFF_AFTER` (3) consecutive failures: probe every tick, as before - from there on: the gap between attempts starts at two ticks and doubles per failed retry, capped at the ticks spanning `NVIDIA_RETRY_MAX_MS` (60 s), never below two. At the default interval that is 20 s, 40 s, then one attempt per minute. A probe that merely timed out under load is back within a few ticks; a host that keeps failing settles at one attempt per minute. An `INTERVAL_MS` above 60 s does not turn the backoff into a no-op. - the gap is counted in scheduler ticks, not milliseconds: `due()` is called exactly once per tick and counts down. No clock is involved, so neither an NTP step nor a resume from suspend can move the next attempt, and there is no drift against the `setInterval` grid. A tick the scheduler skips for overlap is not counted and lengthens that gap by one. - first success: counter and backoff reset, per-tick sampling resumes, and `nvidia-smi is answering again` is logged at info **Missing binary.** If `ctx.exec` throws `Deno.errors.NotFound`, the container has no `nvidia-smi` at all. Only the NVIDIA container toolkit puts it there, at container creation, so nothing short of recreating the container changes that: the probe stops for the lifetime of the process (`stop()`). It logs a warning if an NVIDIA DRM card is present (the "run the container with the NVIDIA runtime" hint), otherwise one debug line. This is every VM and GPU-less host, and they are back to the old cost: one spawn, not one a minute. A driver upgrade does not hit this path — the bind-mounted binary stays and fails with a non-zero exit, which gets the backoff. Any other failure (non-zero exit, timeout) backs off and keeps retrying. The runtime warning fires once per outage (`failures === NVIDIA_BACKOFF_AFTER`, NVIDIA card present). Each retry produces two log lines: the unconditional `exec` line at info that every `ctx.exec` call emits (`server/src/util/exec.ts`, kept as the audit trail for host commands), and the failure itself at debug. On a host where `nvidia-smi` exists but keeps failing that is ~1440 info lines a day, next to the ~8640 a working probe already produces. ### Not changed Stale samples are not cleared while the probe is failing; the last written values stay as they did before. With the backoff that means frozen values for the length of the outage with nothing marking them stale. What the GPU page should show during an outage is a separate decision: #70. The probe is gated on a one-time DRM snapshot (`detectGpus` runs once). If the process starts while the nvidia modules are unloaded and the host has another DRM card (the ASPEED BMC on a server board), the probe never runs and the backoff never ticks. That predates this PR and is the other half of the same problem, but fixing it changes behaviour on non-NVIDIA hosts: #69. Worth knowing, though not something this PR addresses: on a host where the nvidia module is unloaded on purpose, the probe is what loads it again — the container is privileged, and opening `/dev/nvidiactl` triggers the kernel's `char-major-195-*` alias. On epyclab the probe at 04:16:43.249 lines up with the kernel loading the old module at 04:16:43.45, two seconds after the upgrade script had unloaded it, which is what broke that upgrade. The script now blocks module autoload and stops the opsdeck container for the duration, so nothing is needed here — but it does mean the container will see exactly the kind of outage this PR is about on every future driver upgrade. ### Tests `gpu_test.ts`, against `nvidiaBackoff` directly: per-tick probing until the threshold; the exact probed ticks over 40 all-failing ticks (`0, 1, 2, 4, 8, 14, 20, …` — gaps 2, 4, then 6 at the cap); an interval above the cap still waiting two ticks; recovery reported once with the count reset; `stop()` ending probing. Against `startGpuCollectors` with a fake `ctx` (no DRM cards, scripted `exec`, ticks driven by hand): - `NotFound` on the first tick: `exec` called once in 50 ticks, a single debug line - five non-zero exits, then an answer: probed at `0, 1, 2, 4, 8, 14, 15, …`, `nvidia-smi is answering again` logged once, no warning without an NVIDIA card, `nv0` published and samples written ``` deno test --allow-read packages/modules/hardware/backend/gpu_test.ts # 15 passed deno lint / deno fmt --check packages/modules/hardware/backend/ # clean deno task check:backend # clean deno task test # 604 passed, 1 ignored ``` Not exercised against a real GPU outage. ### Review round 1 (`ae7f323`) - #2 monotonic clock: done, `performance.now()` default (since replaced by tick counting, round 2) - #4 window tied to the tick: done, exponential from two ticks, capped at 60 s - #3 description claimed retries log only at debug: corrected above, the `exec` info line stays as the audit trail - #1 DRM snapshot gate: #69 - stale samples nit: #70 - `ok()`/`fail()` mutate-and-return: left as is ### Review round 2 (`98be265`) - #1 GPU-less hosts probing forever: `NotFound` stops the probe for good (`stop()`, kept separate so the backoff stays error-agnostic); other errors back off - #2 gaps one tick longer than documented: backoff counts ticks; deadline in ms removed, and the clock injection with it - wiring nit: two `startGpuCollectors` tests with a fake `ctx` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(hardware): nvidia-smi probe backs off instead of giving up for good
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m52s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
ae7f3239de
The collector stopped calling nvidia-smi after three consecutive failures
and never tried again, so any GPU outage longer than three ticks (30 s at
the default interval) cost the NVIDIA metrics until the container was
restarted. A driver upgrade or a module reload is enough: nvidia-smi fails
for a minute or two while the kernel module is swapped.

After three consecutive failures the probe now drops to one attempt per
minute and resumes per-tick sampling on the first success. The "nvidia-smi
unavailable" warning still fires once per outage, and the recovery is
logged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
thisilike left a comment

Review of ae7f323. I re-ran everything rather than taking the description's word for it.

Verified

  • The bug is real as described: nvidiaFailures < 3 with a reset only inside the try means the gate closes permanently. The old comment did claim the opposite.
  • deno test --allow-read packages/modules/hardware/backend/gpu_test.ts → 11 passed; deno lint, deno fmt --check, deno task check:backend all clean, run from a clean worktree of the branch.
  • 30 s at the default interval: mod.ts:20, INTERVAL_MS ?? 10_000. Correct.
  • "warns exactly once per outage" holds — failures only resets in ok(), so failures === NVIDIA_BACKOFF_AFTER is reached once per outage.
  • No concurrency hazard around due()/fail(): scheduler.ts:25 skips overlapping ticks.
  • The backoff state machine itself is correct; I traced all three tests by hand and they are not passing by accident.

1. The backoff cannot run on the host state this PR is about (gpu.ts:366)

const wantNvidia = nvidia.due() &&
  (cards.length === 0 || cards.some((c) => c.info.vendor === "nvidia"));

cards is assigned exactly once (gpu.ts:347); detectGpus is never called again, and it reads /sys/class/drm/card*, where an NVIDIA node exists only while nvidia-drm is bound.

So if the process starts while the NVIDIA modules are unloaded and the host has any other DRM card — on a server board that is normally the ASPEED BMC (0x1a03, mapped to "other", bound by ast) — then cards.length !== 0 and nothing in cards is nvidia. wantNvidia is false for the entire lifetime of the process: nvidia-smi is never invoked once, the backoff never ticks, and the GPU metrics never appear. That is the same "gone until restart" outcome, except a restart does not fix it either.

The PR body makes this more reachable, not less: the upgrade script now stops the container for the duration, so whether GPU metrics exist afterwards depends on whether the module is back before the container is. The same hole applies to a compute-only host that never loads nvidia-drm.

This predates the PR. I am raising it here because the backoff is what makes cheap perpetual probing affordable, which removes the reason the DRM snapshot gate existed. Worth either a driver-independent presence check (/sys/bus/pci/devices/*/vendor == 0x10de with PCI class 0x0300) or a periodic re-detect. Either one changes behaviour on non-NVIDIA hosts, so it is a separate decision, but without it the fix only covers outages that start after a successful detection.

2. The retry deadline is wall-clock (gpu.ts:213, gpu.ts:229)

nvidiaBackoff(now: () => number = Date.now) with retryAt = now() + NVIDIA_RETRY_MS. Date.now() is not monotonic. A backward NTP step (container up before the first sync), a host resuming from suspend, or a VM migration pushes retryAt into the future by however far the clock moved, and the probe stays quiet for exactly that long — the failure mode this PR removes, reintroduced through a different door.

performance.now() is monotonic and needs no permission in Deno 2. Since the clock is already injected, only the default changes and no test moves:

export function nvidiaBackoff(now: () => number = () => performance.now()) {

I confirmed the suite, lint, fmt and check:backend stay green with that change.

3. "the retries log at debug like every other failure" is not accurate

execCommand logs unconditionally at info before spawning (exec.ts:27: log.info("exec", { cmd, args })), and the default minimum level is info (logger.ts:13). Every retry emits an INFO line in addition to the debug failure line. On a host without the NVIDIA runtime that is ~1440 info lines a day where the current code emits none. Small next to the ~8640/day a working probe already produces, but the description says something the code does not do.

4. The retry window is unrelated to the tick interval (gpu.ts:204)

The threshold counts failures, the window is a fixed 60 s. The scenario the description names — nvidia-smi hitting the 8 s timeout three times under load — now costs up to 60 s without samples, against 10 s while the count is still below the threshold. Still strictly better than the current "never again", but the constant is arbitrary: Math.max(NVIDIA_RETRY_MS, intervalMs), or an exponential window starting at 2 * intervalMs and capped at 60 s, ties it to the collector it paces. Note also that INTERVAL_MS > 60_000 makes the backoff a no-op, which is harmless but unintended.

Nits

  • ok() and fail() both mutate and return, so if (nvidia.ok()) reads like a query at the call site. The doc comments carry it; not worth churn.
  • Not clearing stale samples is a reasonable scope cut, but during a backed-off outage the GPU page now shows frozen values for minutes with nothing marking them stale. Worth an issue rather than a change here.

Net: the change is correct and the tests are honest. #2 is a one-line fix I would take before merging; #1 is the larger half of the same problem and should at least get an issue.

Review of `ae7f323`. I re-ran everything rather than taking the description's word for it. ## Verified - The bug is real as described: `nvidiaFailures < 3` with a reset only inside the `try` means the gate closes permanently. The old comment did claim the opposite. - `deno test --allow-read packages/modules/hardware/backend/gpu_test.ts` → 11 passed; `deno lint`, `deno fmt --check`, `deno task check:backend` all clean, run from a clean worktree of the branch. - 30 s at the default interval: `mod.ts:20`, `INTERVAL_MS ?? 10_000`. Correct. - "warns exactly once per outage" holds — `failures` only resets in `ok()`, so `failures === NVIDIA_BACKOFF_AFTER` is reached once per outage. - No concurrency hazard around `due()`/`fail()`: `scheduler.ts:25` skips overlapping ticks. - The backoff state machine itself is correct; I traced all three tests by hand and they are not passing by accident. ## 1. The backoff cannot run on the host state this PR is about (`gpu.ts:366`) ```ts const wantNvidia = nvidia.due() && (cards.length === 0 || cards.some((c) => c.info.vendor === "nvidia")); ``` `cards` is assigned exactly once (`gpu.ts:347`); `detectGpus` is never called again, and it reads `/sys/class/drm/card*`, where an NVIDIA node exists only while `nvidia-drm` is bound. So if the process starts while the NVIDIA modules are unloaded **and** the host has any other DRM card — on a server board that is normally the ASPEED BMC (`0x1a03`, mapped to `"other"`, bound by `ast`) — then `cards.length !== 0` and nothing in `cards` is `nvidia`. `wantNvidia` is false for the entire lifetime of the process: nvidia-smi is never invoked once, the backoff never ticks, and the GPU metrics never appear. That is the same "gone until restart" outcome, except a restart does not fix it either. The PR body makes this more reachable, not less: the upgrade script now stops the container for the duration, so whether GPU metrics exist afterwards depends on whether the module is back before the container is. The same hole applies to a compute-only host that never loads `nvidia-drm`. This predates the PR. I am raising it here because the backoff is what makes cheap perpetual probing affordable, which removes the reason the DRM snapshot gate existed. Worth either a driver-independent presence check (`/sys/bus/pci/devices/*/vendor == 0x10de` with PCI class `0x0300`) or a periodic re-detect. Either one changes behaviour on non-NVIDIA hosts, so it is a separate decision, but without it the fix only covers outages that start after a successful detection. ## 2. The retry deadline is wall-clock (`gpu.ts:213`, `gpu.ts:229`) `nvidiaBackoff(now: () => number = Date.now)` with `retryAt = now() + NVIDIA_RETRY_MS`. `Date.now()` is not monotonic. A backward NTP step (container up before the first sync), a host resuming from suspend, or a VM migration pushes `retryAt` into the future by however far the clock moved, and the probe stays quiet for exactly that long — the failure mode this PR removes, reintroduced through a different door. `performance.now()` is monotonic and needs no permission in Deno 2. Since the clock is already injected, only the default changes and no test moves: ```ts export function nvidiaBackoff(now: () => number = () => performance.now()) { ``` I confirmed the suite, lint, fmt and `check:backend` stay green with that change. ## 3. "the retries log at debug like every other failure" is not accurate `execCommand` logs unconditionally at info before spawning (`exec.ts:27`: `log.info("exec", { cmd, args })`), and the default minimum level is `info` (`logger.ts:13`). Every retry emits an INFO line in addition to the debug failure line. On a host without the NVIDIA runtime that is ~1440 info lines a day where the current code emits none. Small next to the ~8640/day a working probe already produces, but the description says something the code does not do. ## 4. The retry window is unrelated to the tick interval (`gpu.ts:204`) The threshold counts failures, the window is a fixed 60 s. The scenario the description names — nvidia-smi hitting the 8 s timeout three times under load — now costs up to 60 s without samples, against 10 s while the count is still below the threshold. Still strictly better than the current "never again", but the constant is arbitrary: `Math.max(NVIDIA_RETRY_MS, intervalMs)`, or an exponential window starting at `2 * intervalMs` and capped at 60 s, ties it to the collector it paces. Note also that `INTERVAL_MS > 60_000` makes the backoff a no-op, which is harmless but unintended. ## Nits - `ok()` and `fail()` both mutate and return, so `if (nvidia.ok())` reads like a query at the call site. The doc comments carry it; not worth churn. - Not clearing stale samples is a reasonable scope cut, but during a backed-off outage the GPU page now shows frozen values for minutes with nothing marking them stale. Worth an issue rather than a change here. Net: the change is correct and the tests are honest. #2 is a one-line fix I would take before merging; #1 is the larger half of the same problem and should at least get an issue.
fix(hardware): nvidia backoff on a monotonic clock, window tied to the tick
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m24s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m11s
98be26526e
Review of ae7f323 (#62) raised two things in nvidiaBackoff:

The retry deadline was Date.now() based. A backward NTP step, a resume
from suspend or a VM migration pushes retryAt into the future by however
far the clock moved, and the probe stays quiet for exactly that long,
which is the failure mode the PR removes through a different door. The
clock now defaults to performance.now(), which is monotonic. The initial
deadline is -Infinity so a clock that starts near zero is due at once.

The retry window was a fixed 60 s, unrelated to the interval it paces.
nvidia-smi timing out three times under load then cost up to 60 s
without samples, against 10 s below the threshold, and an INTERVAL_MS
above 60 s made the backoff a no-op. The window now starts at two ticks
and doubles per failed retry, capped at NVIDIA_RETRY_MAX_MS (60 s) or
two ticks, whichever is longer. At the default interval that is 20 s,
40 s, then one attempt per minute.

Tests cover the doubling, the cap, an interval above the cap, and the
fresh backoff being due at t=0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Author
Owner

@thisilike addressed in 98be265, description updated.

  • #2: clock is performance.now() now; initial deadline -Infinity so a clock starting near 0 is due at once.
  • #4: window starts at two ticks, doubles per failed retry, capped at max(60 s, 2 ticks). Default interval: 20 s, 40 s, then once a minute. INTERVAL_MS > 60_000 no longer makes it a no-op. Tests cover the doubling, the cap and the above-cap interval.
  • #3: description corrected; the exec info line stays since it is the audit trail for host commands.
  • #1: #69. Stale samples nit: #70.

Re-review requested.

@thisilike addressed in `98be265`, description updated. - #2: clock is `performance.now()` now; initial deadline `-Infinity` so a clock starting near 0 is due at once. - #4: window starts at two ticks, doubles per failed retry, capped at `max(60 s, 2 ticks)`. Default interval: 20 s, 40 s, then once a minute. `INTERVAL_MS > 60_000` no longer makes it a no-op. Tests cover the doubling, the cap and the above-cap interval. - #3: description corrected; the `exec` info line stays since it is the audit trail for host commands. - #1: #69. Stale samples nit: #70. Re-review requested.
thisilike requested changes 2026-09-23 14:09:43 +02:00
Dismissed
thisilike left a comment

Re-review at 98be265.

Verified

  • Round 1 points: performance.now() default with a -Infinity initial deadline, window from two ticks doubling to max(60 s, 2 ticks), description corrected, #69 / #70 filed. All as described.
  • gpu_test.ts 13 passed; deno lint, deno fmt --check on hardware/backend/ clean.
  • 2 ** retries overflowing to Infinity after a long outage is harmless: Math.min(cap, Infinity) is the cap.

Blocking

1. Every host without a GPU now runs nvidia-smi once a minute, forever (gpu.ts:380).

wantNvidia is also true when cards.length === 0, which is every VM, most VPSes and any host without /sys/class/drm/card*. That is the most common deployment, not the "NVIDIA card but no runtime" case the description prices in. Before: three exec attempts in the process lifetime. After: 1440 exec INFO lines a day plus 1440 spawns, on hosts that have nothing to probe.

For most of these hosts the failure is permanent. ctx.exec runs inside the container (host.ts:553, plain Deno.Command), and nvidia-smi is only there if the NVIDIA container toolkit injected it when the container was created. If the binary is missing, Deno.Command throws Deno.errors.NotFound, and that cannot change until the container is recreated. A driver upgrade does not cause it either: the bind-mounted binary keeps its inode, so it fails with a non-zero exit (NVML version mismatch or no devices), not with NotFound.

So the distinction the backoff needs is already in the error type. NotFound means give up for the lifetime of the process (log once at debug). Anything else is transient and gets the backoff. This keeps the fix for the outage this PR is about and restores the old cost on GPU-less hosts. Add a test that feeds fail() a NotFound. Or, if the API stays error-agnostic, add a separate stop().

Should fix

2. The real gaps are one tick longer than documented and tested.

fail() anchors retryAt at the time the failed exec returned, but the scheduler fires on a fixed setInterval grid (scheduler.ts:40). The tick that is nominally due runs at start + W + timer lateness, and the deadline is start + exec duration + W. Exec duration (a process spawn, or the 8 s timeout in the "hangs under load" case) is almost always longer than timer lateness, so that tick misses by a few ms and the probe waits one more tick.

I checked this against the real scheduler shape: setInterval at 100 ms, 5 ms of simulated exec cost, the PR's nvidiaBackoff. The probe ran at ticks 1, 2, 3, 6, 11, 20, 36, so the gaps after the threshold were 3, 5, 9, 16 ticks instead of 2, 4, 8, 16. At the default interval that is 30 s, 50 s, then 70 s, not 20 s, 40 s, then 60 s. The fake-clock tests can't see this because fail() and due() read the same instant.

Fix: anchor the deadline to the tick, not the end of the exec. For example, due() stores the now() it read and fail() computes retryAt from that value. Or count skipped ticks instead of milliseconds, which also removes the clock question entirely.

Nits

  • Nothing tests the wiring in startGpuCollectors: due(), ok() and fail() are only covered as a pure unit. A test with a fake ctx.exec that fails N times and then succeeds would have caught #2 and would cover the recovery log line.

Net: the backoff logic is correct and the tests are honest. #1 changes what every GPU-less install pays, so I'd like that fixed before it lands. #2 is small.

Re-review at `98be265`. ## Verified - Round 1 points: `performance.now()` default with a `-Infinity` initial deadline, window from two ticks doubling to `max(60 s, 2 ticks)`, description corrected, #69 / #70 filed. All as described. - `gpu_test.ts` 13 passed; `deno lint`, `deno fmt --check` on `hardware/backend/` clean. - `2 ** retries` overflowing to `Infinity` after a long outage is harmless: `Math.min(cap, Infinity)` is the cap. ## Blocking **1. Every host without a GPU now runs nvidia-smi once a minute, forever (`gpu.ts:380`).** `wantNvidia` is also true when `cards.length === 0`, which is every VM, most VPSes and any host without `/sys/class/drm/card*`. That is the most common deployment, not the "NVIDIA card but no runtime" case the description prices in. Before: three `exec` attempts in the process lifetime. After: 1440 `exec` INFO lines a day plus 1440 spawns, on hosts that have nothing to probe. For most of these hosts the failure is permanent. `ctx.exec` runs inside the container (`host.ts:553`, plain `Deno.Command`), and `nvidia-smi` is only there if the NVIDIA container toolkit injected it when the container was created. If the binary is missing, `Deno.Command` throws `Deno.errors.NotFound`, and that cannot change until the container is recreated. A driver upgrade does not cause it either: the bind-mounted binary keeps its inode, so it fails with a non-zero exit (NVML version mismatch or no devices), not with NotFound. So the distinction the backoff needs is already in the error type. `NotFound` means give up for the lifetime of the process (log once at debug). Anything else is transient and gets the backoff. This keeps the fix for the outage this PR is about and restores the old cost on GPU-less hosts. Add a test that feeds `fail()` a NotFound. Or, if the API stays error-agnostic, add a separate `stop()`. ## Should fix **2. The real gaps are one tick longer than documented and tested.** `fail()` anchors `retryAt` at the time the failed exec *returned*, but the scheduler fires on a fixed `setInterval` grid (`scheduler.ts:40`). The tick that is nominally due runs at `start + W + timer lateness`, and the deadline is `start + exec duration + W`. Exec duration (a process spawn, or the 8 s timeout in the "hangs under load" case) is almost always longer than timer lateness, so that tick misses by a few ms and the probe waits one more tick. I checked this against the real scheduler shape: `setInterval` at 100 ms, 5 ms of simulated exec cost, the PR's `nvidiaBackoff`. The probe ran at ticks `1, 2, 3, 6, 11, 20, 36`, so the gaps after the threshold were **3, 5, 9, 16** ticks instead of 2, 4, 8, 16. At the default interval that is 30 s, 50 s, then 70 s, not 20 s, 40 s, then 60 s. The fake-clock tests can't see this because `fail()` and `due()` read the same instant. Fix: anchor the deadline to the tick, not the end of the exec. For example, `due()` stores the `now()` it read and `fail()` computes `retryAt` from that value. Or count skipped ticks instead of milliseconds, which also removes the clock question entirely. ## Nits - Nothing tests the wiring in `startGpuCollectors`: `due()`, `ok()` and `fail()` are only covered as a pure unit. A test with a fake `ctx.exec` that fails N times and then succeeds would have caught #2 and would cover the recovery log line. Net: the backoff logic is correct and the tests are honest. #1 changes what every GPU-less install pays, so I'd like that fixed before it lands. #2 is small.
fix(hardware): stop probing when nvidia-smi is missing, count backoff in ticks
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m46s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m4s
0e1a7b73d8
A NotFound from ctx.exec means the container has no nvidia-smi binary;
only the NVIDIA container toolkit puts it there at creation, so the probe
stops for the lifetime of the process instead of spawning once a minute on
every GPU-less host. Any other failure still backs off and retries.

The backoff gap is now counted in scheduler ticks. A millisecond deadline
taken when the failed exec returned landed just after the tick it was
meant for and cost an extra tick per retry (3, 5, 9, 16 ticks instead of
2, 4, 8, 16).

Adds wiring tests for startGpuCollectors with a fake ctx.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
thisilike approved these changes 2026-09-23 15:01:33 +02:00
thisilike left a comment

Re-review at 0e1a7b7.

Verified

  • Round 2 #1: ctx.exec is execCommand → plain Deno.Command(...).output() with no catch, so a missing binary reaches the collector as-is. Checked on Deno directly: missing binary → Deno.errors.NotFound; the 8 s timeout resolves with code 143 / SIGTERM (so it hits the out.code !== 0 throw and gets the backoff); a non-executable file → PermissionDenied (backoff, not stop). The image doesn't ship nvidia-smi itself (only the compose comment for the toolkit), so NotFound really does mean "no toolkit injection" for the life of the container.
  • Round 2 #2: tick counting is right. nvidia.due() is the left operand of wantNvidia, so it is evaluated exactly once per tick regardless of the DRM gate. Gap math: skip = min(capTicks, 2·2^retries) − 1 gives 2, 4, 6, 6, … at 10 s, matching 0, 1, 2, 4, 8, 14, 20, …; 2 ** retries going to Infinity still clamps to the cap. capTicks = max(2, ceil(60 s / interval)) rounds up (e.g. 9 ticks = 63 s at 7 s), which is what "ticks spanning" says.
  • Wiring tests are honest: the fake ctx drives the real startGpuCollectors, the last scripted step repeats, and the NotFound case asserts one exec in 50 ticks.
  • gpu_test.ts 15 passed; deno lint, deno fmt --check on hardware/backend/, deno task check:backend clean; deno task test 604 passed, 1 ignored — from a clean worktree of the branch.

Please fix before merging

The backoff-path warning now gives the wrong hint (gpu.ts:455). That branch is only reached when the binary exists, i.e. the toolkit already injected it. So on an NVIDIA host during a driver upgrade — the scenario this PR is for — the third failure logs "run the container with the NVIDIA runtime (--gpus all, …)", which is not the problem. Something like nvidia-smi failing, backing off with the error, and keep hint for the NotFound warning only. The comment above it ("or no NVIDIA runtime") is stale for the same reason.

Small, but it is the exact log line an operator reads during the outage this PR handles, and it points them at the wrong fix. Approving so this doesn't need another round, but please push that change before you merge.

Nit (optional)

  • The nvidiaBackoff doc comment explains why a millisecond deadline was dropped. That is review history; it reads better in the commit message, and the comment can just say the gap is counted in ticks and due() must be called once per tick.

Net: both round-2 points are fixed properly and the tests cover the wiring now. Approved on the condition that the warning hint above is fixed before merge; no re-review needed for that.

Re-review at `0e1a7b7`. ## Verified - Round 2 #1: `ctx.exec` is `execCommand` → plain `Deno.Command(...).output()` with no catch, so a missing binary reaches the collector as-is. Checked on Deno directly: missing binary → `Deno.errors.NotFound`; the 8 s timeout *resolves* with code 143 / `SIGTERM` (so it hits the `out.code !== 0` throw and gets the backoff); a non-executable file → `PermissionDenied` (backoff, not stop). The image doesn't ship `nvidia-smi` itself (only the compose comment for the toolkit), so `NotFound` really does mean "no toolkit injection" for the life of the container. - Round 2 #2: tick counting is right. `nvidia.due()` is the left operand of `wantNvidia`, so it is evaluated exactly once per tick regardless of the DRM gate. Gap math: `skip = min(capTicks, 2·2^retries) − 1` gives 2, 4, 6, 6, … at 10 s, matching `0, 1, 2, 4, 8, 14, 20, …`; `2 ** retries` going to `Infinity` still clamps to the cap. `capTicks = max(2, ceil(60 s / interval))` rounds up (e.g. 9 ticks = 63 s at 7 s), which is what "ticks spanning" says. - Wiring tests are honest: the fake `ctx` drives the real `startGpuCollectors`, the last scripted step repeats, and the NotFound case asserts one `exec` in 50 ticks. - `gpu_test.ts` 15 passed; `deno lint`, `deno fmt --check` on `hardware/backend/`, `deno task check:backend` clean; `deno task test` 604 passed, 1 ignored — from a clean worktree of the branch. ## Please fix before merging **The backoff-path warning now gives the wrong hint (`gpu.ts:455`).** That branch is only reached when the binary *exists*, i.e. the toolkit already injected it. So on an NVIDIA host during a driver upgrade — the scenario this PR is for — the third failure logs "run the container with the NVIDIA runtime (--gpus all, …)", which is not the problem. Something like `nvidia-smi failing, backing off` with the error, and keep `hint` for the `NotFound` warning only. The comment above it ("or no NVIDIA runtime") is stale for the same reason. Small, but it is the exact log line an operator reads during the outage this PR handles, and it points them at the wrong fix. Approving so this doesn't need another round, but please push that change before you merge. ## Nit (optional) - The `nvidiaBackoff` doc comment explains why a millisecond deadline was dropped. That is review history; it reads better in the commit message, and the comment can just say the gap is counted in ticks and `due()` must be called once per tick. Net: both round-2 points are fixed properly and the tests cover the wiring now. Approved on the condition that the warning hint above is fixed before merge; no re-review needed for that.
fix(hardware): backoff warning no longer points at the NVIDIA runtime
All checks were successful
Build and Deploy / verify (pull_request) Successful in 5m32s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
230c538094
The backoff path is only reached when nvidia-smi exists, so the toolkit
already injected it; the runtime hint belongs to the NotFound warning
alone. During a driver upgrade the third failure now logs
"nvidia-smi failing, backing off" with the error.

Also trims the nvidiaBackoff doc comment to what the code does: the gap
is counted in ticks (a millisecond deadline anchored at the end of the
failed exec landed just after its tick and cost an extra tick per retry).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
julian merged commit 323e746a9b into main 2026-09-23 15:12:32 +02:00
julian deleted branch fix/nvidia-smi-backoff 2026-09-23 15:12:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!62
No description provided.