Make the daemon fail loudly and put it under a systemd watchdog - #17
Conversation
The daemon used to swallow every error: an unparseable config was treated as "all GPUs auto", a missing or corrupt curve fell back to a hardcoded default, an unknown mode fell back to that default too, and a failed nvmlDeviceSetFanSpeed_v2 was ignored so the service sat "active" while logging to stderr every 5 s. A daemon that hung inside NVML was never restarted, leaving the fans pinned at the last speed it set. Daemon: - Every error is fatal. The reason goes to syslog, the fans are reset to auto, and the process exits non-zero so Restart=on-failure takes over. This covers: unreadable config or curve file, manual mode without a valid speed, unknown mode, temperature read failure, and any fan command NVML rejects. - The curve file is re-read every poll, like config.json already was, so `nvfd curve <temp> <speed>` takes effect within 5 s. SIGHUP is kept as a logged no-op so a stray HUP does not kill the daemon; ExecReload is gone and the README updated. - sd_notify READY=1 / WATCHDOG=1 / STOPPING=1 via a 40-line AF_UNIX datagram shim (src/notify.c), no libsystemd dependency. The unit is Type=notify with WatchdogSec=30 against a 5 s poll; the daemon honours WATCHDOG_USEC and refuses to start if it is under twice the poll. - The first poll hands every GPU not in manual/curve mode back to the driver, so an instance that died uncleanly cannot leave those fans pinned. A GPU that cannot be reset on that startup sweep is logged and skipped rather than fatal: this instance never touched it, and a card without controllable fans must not stop the others being managed. - StartLimitIntervalSec=60 / StartLimitBurst=5 so persistent failure parks the unit in "failed" instead of restarting forever. Readers: - config_read() distinguishes "no file yet" (empty object) from "file exists but is unusable" (NULL + config_last_error()). config_write_gpu refuses to overwrite a config it could not parse. Both files are loaded with JSON_REJECT_DUPLICATES. - curve_load() validates the file: object at top level, integer keys 0-100 with a leading digit, integer values 0-100 (checked as json_int_t before narrowing), no duplicate temperatures (which divided by zero in curve_interpolate), at most MAX_CURVE_POINTS, at least one point. curve_require() is the shared "load or explain why not"; curve_read() stays as a wrapper for the TUI. - curve_edit/curve_reset return status instead of void; curve_edit refuses to overwrite an invalid file. - Legacy plain-text migration refuses anything but auto, curve, or a speed in FAN_SPEED_MIN..MAX instead of writing a config the daemon would reject on every start, and a migration failure is fatal. - fan_get_count returns -1 on NVML error, so fan_reset_to_auto cannot reset zero fans and report success. CLI / TUI: - `nvfd curve` and `nvfd <n> curve` verify the curve loads before switching a GPU into curve mode. The TUI does the same before writing "curve" and exits with the reason rather than falling back to a built-in curve the daemon does not have; curve_default_interpolate has no callers left and is removed. - Exit status is non-zero when a command fails. - The TUI and `nvfd status` exit with the parse error instead of showing a blank config; the curve editor refuses to open on an invalid file rather than overwriting it from the default on save, and the dashboard reports that error after leaving curses. - display_fan_curve distinguishes missing (suggest reset) from invalid (print the error, do not suggest overwriting it); display_list_gpus says "fan count unavailable" instead of "-1 fans".
ricky-chaoju
left a comment
There was a problem hiding this comment.
Thanks for the write-up, it made this a lot easier to follow. I ran the branch on a 5090 under systemd 255 with the real device nodes and it behaves as you describe: the unit comes up as Type=notify, stays running well past WatchdogSec=30 with no restarts, a bad curve file takes it to failed after five tries, the WatchdogSec guard trips with a clear message, and SIGHUP is the quiet no-op you intended. No need to split it, it hangs together.
I've left the rest inline. The migration one I'd like fixed before this goes in; the other two are take-or-leave. One more that's outside the diff: draw_curve_info (src/dashboard.c:341) is the only caller you didn't move off curve_read(), so an invalid curve file now prints the parse error to stderr with curses up, and it still says "(no curve file - using default)" though that fallback is gone.
| if (end == buf || *end != '\0' || | ||
| speed < FAN_SPEED_MIN || speed > FAN_SPEED_MAX) { |
There was a problem hiding this comment.
This makes a migration failure fatal for every command, and main() bails before dispatch, so a v1.x box whose /etc/infinirc_gpu_fan_control.conf holds something outside this set can't run nvfd at all any more. I built both binaries and compared: for 20, 0, abc and 60%, main migrates and carries on, while this branch returns 1 from list, auto, status, curve reset and 0 auto alike. The legacy file is never removed and config.json never gets written, so it repeats on every run and the only way out is deleting the file by hand.
Since fan_set_speed clamps anything below 30 anyway, could this clamp into range and warn instead of refusing?
There was a problem hiding this comment.
Updated migration so legacy values no longer block every command. Numeric values are clamped to 30–100; numeric prefixes such as 60% preserve legacy behavior with a warning; nonnumeric values migrate to auto with a warning. Added regression coverage for these cases.
| gpu, NVFD_CONFIG_FILE); | ||
| json_int_t value = json_integer_value(speed); | ||
| if (value < FAN_SPEED_MIN || value > FAN_SPEED_MAX) | ||
| daemon_dief("GPU %u: manual speed %lld is outside %d-%d", |
There was a problem hiding this comment.
Same thought here: fan_set_speed clamps below 30 a moment later, so the daemon is dying over a value it would have accepted anyway. Clamping and logging would keep the fail-loud behaviour for the cases that really are broken.
There was a problem hiding this comment.
Out-of-range manual speeds are now clamped to 30–100 and logged at LOG_WARNING. Missing or noninteger speeds remain fatal.
| Type=simple | ||
| Type=notify | ||
| ExecStart=/usr/local/bin/nvfd | ||
| ExecReload=/bin/kill -HUP $MAINPID |
There was a problem hiding this comment.
Would you mind keeping this line? systemctl reload nvfd now fails outright with "Job type reload is not applicable". The READMEs are updated so nobody new will be caught out, but anyone with an existing script calling it gets a hard error, and SIGHUP is already a harmless logged no-op, so it costs nothing to leave in.
There was a problem hiding this comment.
Restored ExecReload=/bin/kill -HUP $MAINPID, retaining compatibility with existing reload scripts while SIGHUP remains a logged no-op.
|
Also moved |
This is the big one, so I'll try to explain the thinking rather than just list the diff.
The problem I wanted to solve
I'm using nvfd in curve mode on a workstation card whose stock fan curve stalls at ~60% and lets the die throttle. The thing that worried me about running any fan daemon in curve mode is what happens when the daemon isn't there:
nvmlDeviceSetFanSpeed_v2puts the card in manual fan policy, and if the process dies while the GPU is idle the fan is frozen at the 30% floor when the next job lands. So I looked at how the daemon handles errors, and found it mostly doesn't:config.jsonwas treated as "everything on auto"curve.jsonfell back to a hardcoded curvemodestring fell back to that same curvenvmlDeviceSetFanSpeed_v2was ignored, so the unit satactivewhile writing to stderr every five secondsNone of those is visible from
systemctl status. The daemon looks healthy right up until you notice the GPU is hot.What this changes
The daemon treats every error as fatal. Unreadable config or curve, manual mode without a sane speed, unknown mode, a temperature read failing, a fan command NVML rejects — each one logs the reason to syslog, resets the fans to auto so nothing stays pinned, and exits non-zero.
Restart=on-failurebrings it back, andStartLimitIntervalSec=60/StartLimitBurst=5mean that if it keeps dying the unit parks itself infailedwhere monitoring can see it, instead of restarting forever.A systemd watchdog. The unit becomes
Type=notifywithWatchdogSec=30; the daemon sendsREADY=1once it's up andWATCHDOG=1after every 5 s poll. I didn't want to add a libsystemd link dependency for three strings, sosrc/notify.cis a forty-linesd_notifyover anAF_UNIXdatagram (handles the@abstract-namespace form too). The daemon also readsWATCHDOG_USECand refuses to start if someone's drop-in sets the watchdog shorter than twice the poll interval, since that would just get it killed. This works underProtectSystem=strict— the kernel's read-only check doesn't apply to sockets, and systemd's ownsystemd-resolvedunit combines the two.Startup sweep. On its first poll the daemon hands every GPU that isn't configured for manual or curve mode back to the driver, so if a previous instance died without cleaning up, those fans aren't left where it put them. If a GPU can't be reset on that sweep (a card without controllable fans, say) it's logged and skipped rather than fatal — this instance never touched it, and it shouldn't stop the others being managed.
Curve re-read every poll.
config.jsonwas already re-read each loop;curve.jsonwas cached until SIGHUP, which meantnvfd curve 60 70silently did nothing until you rememberedsystemctl reload. Now both are read every 5 s. SIGHUP stays as a logged no-op so a stray HUP doesn't kill the process;ExecReload=and the README lines about it are gone.Readers that say what's wrong.
config_read()now distinguishes "no file yet" (an empty object, fine) from "file exists but is unusable" (NULLplusconfig_last_error()), andconfig_write_gpurefuses to overwrite a file it couldn't parse.curve_load()validates the curve properly: top-level object, integer keys 0–100 with a leading digit, integer values 0–100, no duplicate temperatures ("30"and"030"both parse to 30 and divided by zero incurve_interpolate), at mostMAX_CURVE_POINTS, at least one point. Both useJSON_REJECT_DUPLICATES. The legacy plain-text migration only writes speeds the daemon will accept, and refuses otherwise instead of guessing.CLI and TUI follow suit.
nvfd curveandnvfd <n> curvecheck the curve loads before switching a GPU into curve mode, so the daemon is never handed a config it'll die on; the dashboard does the same before writing "curve". Exit status is non-zero when a command fails. The TUI andnvfd statusexit with the parse error instead of rendering a blank config, and the curve editor won't open on an invalid file rather than overwriting it from the default on save.curve_default_interpolateended up with no callers and is removed.fan_get_countreturns -1 on error sofan_reset_to_autocan't "reset" zero fans and report success.Testing
Running on an RTX 6000 Ada (Ubuntu 22.04.5, systemd 249, driver 595.84) in curve mode.
systemctl statusshowsactive (running)only afterREADY=1,journalctl -u nvfdis quiet, and the fan tracks curve edits within one poll. I bisected the hardening directives separately — see #14, which is independent of this one but you'll want both for the unit to actually start.This touches
src/fan.calongside #15; they merge cleanly in either order (I have them together on my fork's main). It's a lot of change for one PR and I'm happy to split it if you'd prefer to take the watchdog and the reader validation separately — say the word.