Files
DECNET/tests/logging/test_inode_aware_handler.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

113 lines
3.4 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Tests for InodeAwareRotatingFileHandler.
Simulates the two scenarios that break plain RotatingFileHandler:
1. External `rm` of the log file
2. External rename (logrotate-style rotation)
In both cases, the next log record must end up in a recreated file on
disk, not the orphaned inode held by the old file descriptor.
"""
import logging
import os
import pytest
from decnet.logging.inode_aware_handler import InodeAwareRotatingFileHandler
def _make_handler(path) -> logging.Handler:
h = InodeAwareRotatingFileHandler(str(path), maxBytes=10_000_000, backupCount=1)
h.setFormatter(logging.Formatter("%(message)s"))
return h
def _record(msg: str) -> logging.LogRecord:
return logging.LogRecord("t", logging.INFO, __file__, 1, msg, None, None)
def test_writes_land_in_file(tmp_path):
path = tmp_path / "app.log"
h = _make_handler(path)
h.emit(_record("hello"))
h.close()
assert path.read_text().strip() == "hello"
def test_reopens_after_unlink(tmp_path):
path = tmp_path / "app.log"
h = _make_handler(path)
h.emit(_record("first"))
os.remove(path) # simulate `rm decnet.system.log`
assert not path.exists()
h.emit(_record("second"))
h.close()
assert path.exists()
assert path.read_text().strip() == "second"
def test_reopens_after_rename(tmp_path):
"""logrotate rename-and-create: the old path is renamed, then we expect
writes to go to a freshly created file at the original path."""
path = tmp_path / "app.log"
h = _make_handler(path)
h.emit(_record("pre-rotation"))
rotated = tmp_path / "app.log.1"
os.rename(path, rotated) # simulate logrotate move
h.emit(_record("post-rotation"))
h.close()
assert rotated.read_text().strip() == "pre-rotation"
assert path.read_text().strip() == "post-rotation"
def test_no_reopen_when_file_is_stable(tmp_path, monkeypatch):
"""Ensure we don't thrash: back-to-back emits must share one FD."""
path = tmp_path / "app.log"
h = _make_handler(path)
h.emit(_record("one"))
fd_before = h.stream.fileno()
h.emit(_record("two"))
fd_after = h.stream.fileno()
assert fd_before == fd_after
h.close()
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"
h = InodeAwareRotatingFileHandler(str(path), maxBytes=50, backupCount=1)
h.setFormatter(logging.Formatter("%(message)s"))
for i in range(20):
h.emit(_record(f"line-{i:03d}-xxxxxxxxxxxxxxx"))
h.close()
assert path.exists()
assert (tmp_path / "app.log.1").exists()