Skip to content

Make the daemon fail loudly and put it under a systemd watchdog - #17

Merged
ricky-chaoju merged 4 commits into
infinirc:mainfrom
Liquescent-Development:fix/daemon-fail-fast-and-watchdog
Sep 5, 2026
Merged

Make the daemon fail loudly and put it under a systemd watchdog#17
ricky-chaoju merged 4 commits into
infinirc:mainfrom
Liquescent-Development:fix/daemon-fail-fast-and-watchdog

Conversation

@richardkiene

@richardkiene richardkiene commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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_v2 puts 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:

  • an unparseable config.json was treated as "everything on auto"
  • a missing or corrupt curve.json fell back to a hardcoded curve
  • an unknown mode string fell back to that same curve
  • a failed nvmlDeviceSetFanSpeed_v2 was ignored, so the unit sat active while writing to stderr every five seconds
  • a daemon hung inside an NVML call was never restarted

None 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-failure brings it back, and StartLimitIntervalSec=60/StartLimitBurst=5 mean that if it keeps dying the unit parks itself in failed where monitoring can see it, instead of restarting forever.

A systemd watchdog. The unit becomes Type=notify with WatchdogSec=30; the daemon sends READY=1 once it's up and WATCHDOG=1 after every 5 s poll. I didn't want to add a libsystemd link dependency for three strings, so src/notify.c is a forty-line sd_notify over an AF_UNIX datagram (handles the @ abstract-namespace form too). The daemon also reads WATCHDOG_USEC and 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 under ProtectSystem=strict — the kernel's read-only check doesn't apply to sockets, and systemd's own systemd-resolved unit 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.json was already re-read each loop; curve.json was cached until SIGHUP, which meant nvfd curve 60 70 silently did nothing until you remembered systemctl 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" (NULL plus config_last_error()), and config_write_gpu refuses 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 in curve_interpolate), at most MAX_CURVE_POINTS, at least one point. Both use JSON_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 curve and nvfd <n> curve check 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 and nvfd status exit 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_interpolate ended up with no callers and is removed. fan_get_count returns -1 on error so fan_reset_to_auto can'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 status shows active (running) only after READY=1, journalctl -u nvfd is 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.c alongside #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.

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 ricky-chaoju left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.c Outdated
Comment on lines +143 to +144
if (end == buf || *end != '\0' ||
speed < FAN_SPEED_MIN || speed > FAN_SPEED_MAX) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/main.c Outdated
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out-of-range manual speeds are now clamped to 30–100 and logged at LOG_WARNING. Missing or noninteger speeds remain fatal.

Comment thread systemd/nvfd.service
Type=simple
Type=notify
ExecStart=/usr/local/bin/nvfd
ExecReload=/bin/kill -HUP $MAINPID

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored ExecReload=/bin/kill -HUP $MAINPID, retaining compatibility with existing reload scripts while SIGHUP remains a logged no-op.

@richardkiene

Copy link
Copy Markdown
Contributor Author

Also moved draw_curve_info() off curve_read(). Missing and invalid curve files are now distinguished within curses, without writing the parse error to stderr or claiming a default fallback.

@ricky-chaoju
ricky-chaoju merged commit b36973f into infinirc:main Sep 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants