Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
4 changes: 3 additions & 1 deletion src/logurich/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -38,6 +39,7 @@
"global_context_configure",
"global_context_set",
"console",
"reset_console_after_fork",
"rich_configure_console",
"rich_get_console",
"rich_set_console",
Expand Down
26 changes: 26 additions & 0 deletions src/logurich/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import os
import threading
from typing import Any, Optional

from rich.console import Console, ConsoleRenderable
Expand Down Expand Up @@ -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()
43 changes: 43 additions & 0 deletions tests/test_mp.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -15,6 +17,7 @@
get_log_queue,
global_context_configure,
init_logger,
rich_get_console,
shutdown_logger,
)

Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading