fix: LS-1 close queued handlers on re-setup; LS-2 register atexit once
LS-1: re-setup closes the QueueListener's wrapped file/console handlers after stopping it (was: relied on GC). LS-2: atexit registration guarded by a module flag so repeated queue=True re-setups don't stack callbacks. JSON formatter caches the rendered traceback on the record (no per-handler re-render); _move dest precondition documented. Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
parent
011588a712
commit
fc0898d70e
@ -46,7 +46,11 @@ class JsonLinesFormatter(logging.Formatter):
|
|||||||
if key not in _RESERVED and key not in _OUTPUT_KEYS and not key.startswith("_"):
|
if key not in _RESERVED and key not in _OUTPUT_KEYS and not key.startswith("_"):
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
payload["exc_info"] = self.formatException(record.exc_info)
|
# cache the rendered traceback on the record (as stdlib Formatter does) so a
|
||||||
|
# second handler/format() of the same record doesn't re-render it
|
||||||
|
if not record.exc_text:
|
||||||
|
record.exc_text = self.formatException(record.exc_info)
|
||||||
|
payload["exc_info"] = record.exc_text
|
||||||
elif record.exc_text:
|
elif record.exc_text:
|
||||||
payload["exc_info"] = record.exc_text
|
payload["exc_info"] = record.exc_text
|
||||||
if record.stack_info:
|
if record.stack_info:
|
||||||
|
|||||||
@ -20,6 +20,11 @@ def _move(source: str, dest: str) -> None:
|
|||||||
different filesystems — exactly the container bind-mount / separate-logs-volume
|
different filesystems — exactly the container bind-mount / separate-logs-volume
|
||||||
case this lib targets. fall back to shutil.move (copy+unlink) so the roll still
|
case this lib targets. fall back to shutil.move (copy+unlink) so the roll still
|
||||||
lands instead of failing every rotation via the handler's silent handleError.
|
lands instead of failing every rotation via the handler's silent handleError.
|
||||||
|
|
||||||
|
precondition: `dest` is a free, non-directory path (all call sites generate a unique
|
||||||
|
timestamped/dated dest). os.replace and shutil.move differ on a dest that already
|
||||||
|
exists as a directory, so this helper is not safe for arbitrary dests — only the
|
||||||
|
rotation paths that guarantee a fresh file dest.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
os.replace(source, dest)
|
os.replace(source, dest)
|
||||||
|
|||||||
@ -22,6 +22,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_MARKER = "_log_setup_owned"
|
_MARKER = "_log_setup_owned"
|
||||||
_listener = None
|
_listener = None
|
||||||
|
_atexit_registered = False
|
||||||
|
|
||||||
|
|
||||||
def _level_value(level: Union[int, str]) -> int:
|
def _level_value(level: Union[int, str]) -> int:
|
||||||
@ -80,6 +81,14 @@ def _clear_owned(root: logging.Logger) -> None:
|
|||||||
global _listener
|
global _listener
|
||||||
if _listener is not None:
|
if _listener is not None:
|
||||||
_listener.stop()
|
_listener.stop()
|
||||||
|
# the listener owns the real file/console handlers (only the QueueHandler is
|
||||||
|
# root-attached + marked); stopping it doesn't close them, so close them here
|
||||||
|
# to avoid relying on GC finalizers across a re-setup
|
||||||
|
for wrapped in getattr(_listener, "handlers", ()):
|
||||||
|
try:
|
||||||
|
wrapped.close()
|
||||||
|
except Exception:
|
||||||
|
log.warning("log_setup: failed to close queued handler %r", wrapped, exc_info=True)
|
||||||
_listener = None
|
_listener = None
|
||||||
for handler in list(root.handlers):
|
for handler in list(root.handlers):
|
||||||
if getattr(handler, _MARKER, False):
|
if getattr(handler, _MARKER, False):
|
||||||
@ -160,7 +169,7 @@ def setup_logging(
|
|||||||
unwritable `log_dir` falls back to console-only with a warning even when `console` is
|
unwritable `log_dir` falls back to console-only with a warning even when `console` is
|
||||||
off, so output is never silently lost; an unknown `output` falls back to text.
|
off, so output is never silently lost; an unknown `output` falls back to text.
|
||||||
"""
|
"""
|
||||||
global _listener
|
global _listener, _atexit_registered
|
||||||
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
root.setLevel(_level_value(level))
|
root.setLevel(_level_value(level))
|
||||||
@ -197,7 +206,11 @@ def setup_logging(
|
|||||||
root.addHandler(qh)
|
root.addHandler(qh)
|
||||||
_listener = logging.handlers.QueueListener(record_queue, *handlers, respect_handler_level=True)
|
_listener = logging.handlers.QueueListener(record_queue, *handlers, respect_handler_level=True)
|
||||||
_listener.start()
|
_listener.start()
|
||||||
atexit.register(_stop_listener)
|
if not _atexit_registered:
|
||||||
|
# register once — atexit doesn't dedupe, so repeated queue re-setups would
|
||||||
|
# otherwise stack identical callbacks (harmless but unbounded)
|
||||||
|
atexit.register(_stop_listener)
|
||||||
|
_atexit_registered = True
|
||||||
else:
|
else:
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
root.addHandler(_tag(handler))
|
root.addHandler(_tag(handler))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user