From 8c8b94c15620b81606ae714120b253ccbedf7427 Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 28 Jul 2026 10:51:07 +0200 Subject: [PATCH] fix(console): reset console locks after fork --- pyproject.toml | 2 +- src/logurich/__init__.py | 4 +++- src/logurich/console.py | 26 ++++++++++++++++++++++++ tests/test_mp.py | 43 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9774b9f..ca959ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "logurich" -version = "0.9.2" +version = "0.9.3" description = "A Python library combining standard logging and Rich for beautiful logging." authors = [ { name = "PakitoSec", email = "jeromep83@gmail.com" } diff --git a/src/logurich/__init__.py b/src/logurich/__init__.py index 8b1e1a6..4f2467f 100644 --- a/src/logurich/__init__.py +++ b/src/logurich/__init__.py @@ -1,9 +1,10 @@ """Public package exports for logurich.""" -__version__ = "0.9.2" +__version__ = "0.9.3" from .console import ( console, + reset_console_after_fork, rich_configure_console, rich_get_console, rich_set_console, @@ -38,6 +39,7 @@ "global_context_configure", "global_context_set", "console", + "reset_console_after_fork", "rich_configure_console", "rich_get_console", "rich_set_console", diff --git a/src/logurich/console.py b/src/logurich/console.py index f411dcf..b01be19 100644 --- a/src/logurich/console.py +++ b/src/logurich/console.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os +import threading from typing import Any, Optional from rich.console import Console, ConsoleRenderable @@ -135,4 +137,28 @@ def rich_configure_console(*args: Any, **kwargs: Any) -> Console: return _console +def reset_console_after_fork() -> None: + """Rebuild the console synchronisation state in a freshly forked child. + + :class:`~rich.console.Console` serialises writes with a + :class:`threading.RLock`. ``os.fork()`` only clones the calling thread, so a + lock held by another thread at fork time is inherited in a locked state that + nothing will ever release, and the first log emitted by the child blocks + forever. This is a common deadlock with ``multiprocessing`` on the default + ``fork`` start method and with pre-fork worker pools such as Celery's. + + The locks are replaced, never acquired, so the child always starts from a + usable state. This is registered as an ``after_in_child`` fork handler on + platforms that support it, so callers do not have to do anything. + """ + if _console is None: + return + _console._lock = threading.RLock() + _console._record_buffer_lock = threading.RLock() + + +if hasattr(os, "register_at_fork"): # pragma: no cover - platform dependent + os.register_at_fork(after_in_child=reset_console_after_fork) + + console = rich_get_console() diff --git a/tests/test_mp.py b/tests/test_mp.py index 12f83ba..4010026 100644 --- a/tests/test_mp.py +++ b/tests/test_mp.py @@ -1,11 +1,13 @@ import logging import multiprocessing as mp import os +import signal import subprocess import sys import textwrap from pathlib import Path +import pytest from rich.panel import Panel from rich.table import Table @@ -15,6 +17,7 @@ get_log_queue, global_context_configure, init_logger, + rich_get_console, shutdown_logger, ) @@ -197,3 +200,43 @@ def main(): ) assert result.returncode == 0, result.stderr or result.stdout + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="fork() is not available") +def test_console_lock_is_released_in_forked_child(): + """A lock held at fork time must not deadlock the child. + + ``os.fork()`` only clones the calling thread, so an inherited locked + ``RLock`` would never be released and the first log call in the child would + block forever. + """ + console = rich_get_console() + stale_lock = console._lock + stale_lock.acquire() + + read_fd, write_fd = os.pipe() + pid = os.fork() + if pid == 0: + os.close(read_fd) + try: + signal.alarm(10) # never hang the suite if the fix regresses + child_console = rich_get_console() + assert child_console is console + assert child_console._lock is not stale_lock + logging.getLogger("workers.fork").warning("child is alive") + os.write(write_fd, b"1") + except BaseException: + os.write(write_fd, b"0") + finally: + os._exit(0) + + os.close(write_fd) + try: + outcome = os.read(read_fd, 1) + _, status = os.waitpid(pid, 0) + finally: + os.close(read_fd) + stale_lock.release() + + assert status == 0 + assert outcome == b"1", "forked child inherited a locked console" diff --git a/uv.lock b/uv.lock index c8a34bb..dfa8210 100644 --- a/uv.lock +++ b/uv.lock @@ -149,7 +149,7 @@ wheels = [ [[package]] name = "logurich" -version = "0.9.2" +version = "0.9.3" source = { editable = "." } dependencies = [ { name = "rich" },