fix(host_agent): a stalled NFS mount no longer freezes the agent (#5760)
#5760 "One stalled NFS mount freezes the whole agent: statvfs has no deadline, so the host goes silent while it still answers ping". The agent read every mount inline in its only loop; a hard-mounted share whose server stops blocks statvfs in the kernel with no timeout. Each mount is now read in a daemon thread under one 5 s deadline. One that misses it is sent as stalled, and isn't probed again while its last probe is still blocked. A mount that errors (a stale handle) is left out instead of emptying the list. Steward counts them as mounts_stalled (alertable: "an NFS share stopped"), and skips the fullest-disk figure when every mount stalled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,3 +109,45 @@ def test_collect_metadata(tmp_path):
|
||||
assert meta["kernel"] == "6.8.0-45-generic"
|
||||
assert meta["arch"] == "x86_64"
|
||||
assert "Ubuntu 24.04" in meta["distro"]
|
||||
|
||||
|
||||
def test_a_mount_that_does_not_answer_is_reported_stalled_and_not_probed_again():
|
||||
"""#5760: statvfs on a stalled NFS share blocks with no timeout, and read
|
||||
inline that froze the whole agent. The sample goes on without it, and a
|
||||
probe still blocked isn't stacked on by the next sample."""
|
||||
import threading
|
||||
release = threading.Event()
|
||||
calls: list[str] = []
|
||||
|
||||
def _usage(path):
|
||||
calls.append(path)
|
||||
if path == "/nfs/media":
|
||||
release.wait(5)
|
||||
return type("R", (), {"total": 1000, "used": 400, "free": 600})()
|
||||
|
||||
a._stuck_mounts.clear()
|
||||
try:
|
||||
with patch.object(a.shutil, "disk_usage", side_effect=_usage), \
|
||||
patch.object(a.os, "statvfs", side_effect=OSError):
|
||||
first = a.collect_storage(["/", "/nfs/media"], deadline=0.2)
|
||||
second = a.collect_storage(["/", "/nfs/media"], deadline=0.2)
|
||||
assert first == second == [
|
||||
{"mount": "/", "total_bytes": 1000, "used_bytes": 400},
|
||||
{"mount": "/nfs/media", "stalled": True},
|
||||
]
|
||||
assert calls.count("/nfs/media") == 1
|
||||
finally:
|
||||
release.set()
|
||||
a._stuck_mounts.clear()
|
||||
|
||||
|
||||
def test_a_mount_that_errors_is_left_out_not_the_whole_list():
|
||||
def _usage(path):
|
||||
if path == "/nfs/gone":
|
||||
raise OSError(116, "Stale file handle")
|
||||
return type("R", (), {"total": 10, "used": 1, "free": 9})()
|
||||
|
||||
with patch.object(a.shutil, "disk_usage", side_effect=_usage), \
|
||||
patch.object(a.os, "statvfs", side_effect=OSError):
|
||||
out = a.collect_storage(["/", "/nfs/gone"])
|
||||
assert out == [{"mount": "/", "total_bytes": 10, "used_bytes": 1}]
|
||||
|
||||
@@ -93,3 +93,14 @@ def test_a_sensor_with_nothing_wired_to_it_does_not_set_the_host_s_temperature()
|
||||
assert idx[("temp_c_max", "util")] == 60.0
|
||||
assert idx[("temp_c", "util:temp:AUXTIN0")] == 110.0
|
||||
assert idx[("temp_c", "util:temp:PCH_CHIP_CPU_MAX_TEMP")] == 0.0
|
||||
|
||||
|
||||
def test_stalled_mounts_are_counted_and_have_no_figures():
|
||||
"""#5760: a mount the agent couldn't read in time."""
|
||||
sample = {"ts": "2026-10-14T21:00:00+00:00",
|
||||
"storage": [{"mount": "/", "total_bytes": 100, "used_bytes": 50},
|
||||
{"mount": "/nfs/media", "stalled": True}]}
|
||||
idx = _index(_expand_sample_to_metrics(sample, "d3", datetime.now(timezone.utc)))
|
||||
assert idx[("mounts_stalled", "d3")] == 1
|
||||
assert idx[("disk_used_pct_worst", "d3")] == 50.0
|
||||
assert ("disk_used_pct", "d3:/nfs/media") not in idx
|
||||
|
||||
Reference in New Issue
Block a user