diff --git a/src/plugin_system/resource_monitor.py b/src/plugin_system/resource_monitor.py index 1891cde3..bd9a0d18 100644 --- a/src/plugin_system/resource_monitor.py +++ b/src/plugin_system/resource_monitor.py @@ -377,11 +377,14 @@ class PluginResourceMonitor: Caller must hold ``self._lock``. """ - now = time.time() + # Monotonic, not wall clock: these devices have no RTC, so the clock + # jumps by however far off boot-time was the moment NTP first syncs. + # A forward jump would allow an early write, a backward one would + # stall the snapshot well past the interval. + now = time.monotonic() if not force and now - self._metrics_persisted_at.get(plugin_id, 0.0) \ < _METRICS_PERSIST_INTERVAL: return - self._metrics_persisted_at[plugin_id] = now cache_key = self._get_metrics_key(plugin_id) self.cache_manager.set(cache_key, { 'memory_mb': metrics.memory_mb, @@ -395,6 +398,9 @@ class PluginResourceMonitor: else 0.0), 'last_update_time': metrics.last_update_time, }) + # Only after the write lands. Marking it first would mean a failed + # set() bought the next interval's silence without leaving a snapshot. + self._metrics_persisted_at[plugin_id] = now def reset_metrics(self, plugin_id: str) -> None: """Reset metrics for a plugin.""" diff --git a/test/test_resource_monitor.py b/test/test_resource_monitor.py index 9f672764..49ab4bf6 100644 --- a/test/test_resource_monitor.py +++ b/test/test_resource_monitor.py @@ -174,3 +174,20 @@ class TestMetricsPersistenceChurn: writes = [c for c in cache.set.call_args_list if c.args and str(c.args[0]).startswith("plugin_metrics:")] assert len(writes) == 2, "reset should clear the throttle timestamp" + + def test_a_failed_write_does_not_buy_the_next_interval_of_silence(self): + """A set() that raises must not count as having persisted. + + Marking the timestamp before the write would leave no snapshot in the + cache and still suppress the next 30 seconds of attempts. + """ + cache = _cache() + cache.set.side_effect = [OSError("disk full"), None] + mon = PluginResourceMonitor(cache, enable_monitoring=False) + with pytest.raises(OSError): + mon.monitor_call("p", lambda: None) + # the very next call must try again rather than skip the interval + mon.monitor_call("p", lambda: None) + writes = [c for c in cache.set.call_args_list + if c.args and str(c.args[0]).startswith("plugin_metrics:")] + assert len(writes) == 2, "a failed write should be retried, not skipped"