diff --git a/decnet/logging/inode_aware_handler.py b/decnet/logging/inode_aware_handler.py index 9b03b63..3a7aad7 100644 --- a/decnet/logging/inode_aware_handler.py +++ b/decnet/logging/inode_aware_handler.py @@ -48,5 +48,13 @@ class InodeAwareRotatingFileHandler(logging.handlers.RotatingFileHandler): self.close() except Exception: # nosec B110 pass - self.stream = self._open() + try: + self.stream = self._open() + except OSError: + # A logging handler MUST NOT crash its caller. If we can't + # reopen (e.g. file is root-owned after `sudo decnet deploy` + # and the current process is non-root), defer to the stdlib + # error path, which just prints a traceback to stderr. + self.handleError(record) + return super().emit(record) diff --git a/scripts/profile/view.sh b/scripts/profile/view.sh index 15d8caa..6d69d0b 100755 --- a/scripts/profile/view.sh +++ b/scripts/profile/view.sh @@ -34,7 +34,9 @@ case "${1:-}" in cprofile) TARGET="$(pick_newest '*.prof')" ;; memray) TARGET="$(pick_newest 'memray-*.bin')" ;; pyspy) TARGET="$(pick_newest 'pyspy-*.svg')" ;; - pyinstrument) TARGET="$(pick_newest '*.html')" ;; + pyinstrument) TARGET="$(find "${DIR}" -maxdepth 1 -type f -name '*.html' \ + ! -name 'memray-*' -printf '%T@ %p\n' 2>/dev/null \ + | sort -n | tail -n 1 | cut -d' ' -f2-)" ;; *) TARGET="$1" ;; esac diff --git a/tests/test_inode_aware_handler.py b/tests/test_inode_aware_handler.py index b066888..e04e632 100644 --- a/tests/test_inode_aware_handler.py +++ b/tests/test_inode_aware_handler.py @@ -78,6 +78,26 @@ def test_no_reopen_when_file_is_stable(tmp_path, monkeypatch): assert path.read_text().splitlines() == ["one", "two"] +def test_emit_does_not_raise_when_reopen_fails(tmp_path, monkeypatch): + """A failed reopen must not propagate — it would crash the caller + (observed in the collector worker when decnet.system.log was root-owned + and the collector ran non-root).""" + path = tmp_path / "app.log" + h = _make_handler(path) + h.emit(_record("first")) + os.remove(path) # force reopen on next emit + + def boom(*_a, **_kw): + raise PermissionError(13, "Permission denied") + monkeypatch.setattr(h, "_open", boom) + + # Swallow the stderr traceback stdlib prints via handleError. + monkeypatch.setattr(h, "handleError", lambda _r: None) + + # Must not raise. + h.emit(_record("second")) + + def test_rotation_by_size_still_works(tmp_path): """maxBytes-triggered rotation must still function on top of the inode check.""" path = tmp_path / "app.log"