fix(frame-timing): keep the GC monitor quiet at interpreter shutdown (#734)

A collection during interpreter shutdown called GcMonitor after the
module's `time` global was torn down, printing "Exception ignored while
calling GC callback ... 'NoneType' object has no attribute
'perf_counter'" at the end of service and test runs.

- GcMonitor binds its clock and sys.is_finalizing at construction and
  does nothing once the interpreter is finalizing.
- install_gc_monitor() unregisters it with atexit; new
  uninstall_gc_monitor().
- DisplayManager.cleanup() (reached from SIGTERM via run()'s finally)
  unregisters it alongside the frame recorder.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 11:51:06 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a5ec645d25
commit 76ad71d1c5
5 changed files with 133 additions and 5 deletions
+38 -3
View File
@@ -121,6 +121,7 @@ three times per threshold, so keep it to diagnostic runs, not soaks.
from __future__ import annotations
import atexit
import copy
import gc
import json
@@ -213,10 +214,19 @@ class GcMonitor:
lock: the render thread and the stats writer only read them.
Install it once per process with :func:`install_gc_monitor`.
Collections still run while the interpreter shuts down, after module
globals such as ``time`` may already be torn down to ``None``. The clock
and ``sys.is_finalizing`` are bound here so the callback never looks a
global up, it does nothing once finalization has begun, and
:func:`install_gc_monitor` unregisters it at exit anyway.
"""
def __init__(self, threshold: float = GC_PAUSE_SECONDS):
def __init__(self, threshold: float = GC_PAUSE_SECONDS,
clock: Callable[[], float] = time.perf_counter):
self.threshold = threshold
self._clock = clock
self._is_finalizing = sys.is_finalizing
self._started: Optional[float] = None
#: Per generation (0, 1, 2), since the monitor was installed.
self.collections = [0, 0, 0]
@@ -232,7 +242,9 @@ class GcMonitor:
self.last_long: Optional[Tuple[float, float]] = None
def __call__(self, phase: str, info: Dict[str, Any]) -> None:
now = time.perf_counter()
if self._is_finalizing():
return
now = self._clock()
if phase == "start":
self._started = now
return
@@ -267,15 +279,38 @@ _gc_monitor_lock = threading.Lock()
def install_gc_monitor() -> GcMonitor:
"""The process's GcMonitor, installed in ``gc.callbacks`` on first call."""
"""The process's GcMonitor, installed in ``gc.callbacks`` on first call.
It is unregistered at exit (:func:`uninstall_gc_monitor`), before the
interpreter tears module globals down.
"""
global _gc_monitor
with _gc_monitor_lock:
if _gc_monitor is None:
_gc_monitor = GcMonitor()
gc.callbacks.append(_gc_monitor)
atexit.register(uninstall_gc_monitor)
return _gc_monitor
def uninstall_gc_monitor() -> None:
"""Take the process's GcMonitor out of ``gc.callbacks``; safe to repeat.
A recorder that still holds the monitor keeps its counters; they just
stop moving. The next :func:`install_gc_monitor` installs a fresh one.
"""
global _gc_monitor
with _gc_monitor_lock:
monitor, _gc_monitor = _gc_monitor, None
if monitor is None:
return
atexit.unregister(uninstall_gc_monitor)
try:
gc.callbacks.remove(monitor)
except ValueError:
pass
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
#: ops is the work noted before it (kind -> bytes) or None.
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
+4 -1
View File
@@ -57,7 +57,8 @@ import freetype
from src.common import snapshot_policy
from src import display_watchdog
from src.common.frame_timing import FrameTimingRecorder, install_gc_monitor
from src.common.frame_timing import (
FrameTimingRecorder, install_gc_monitor, uninstall_gc_monitor)
if TYPE_CHECKING:
from src.common.render_gate import RenderGate
@@ -1407,6 +1408,8 @@ class DisplayManager:
# The stall watchdog would otherwise outlive this manager.
if getattr(self, 'frame_timing', None) is not None:
self.frame_timing.close()
# Installed with the recorder; stop timing collections with it.
uninstall_gc_monitor()
# Reset the singleton state when cleaning up
DisplayManager._instance = None