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
3 changes: 2 additions & 1 deletion .github/actions/setup-pango-cairo/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ runs:
id: apt-pkgs
shell: bash
run: |
pkgs="libgirepository-1.0-1 libcairo2 gir1.2-pango-1.0 pkg-config libcairo2-dev libgirepository1.0-dev"
pkgs="libgirepository-1.0-1 libcairo2 gir1.2-pango-1.0 pkg-config libcairo2-dev libgirepository1.0-dev python3-dev"
echo "pkgs=$pkgs" >> $GITHUB_OUTPUT
echo "hash=$(echo -n "$pkgs" | sha256sum | cut -d' ' -f1)" >> $GITHUB_OUTPUT
echo "cache_version=1" >> $GITHUB_OUTPUT
Expand All @@ -25,6 +25,7 @@ runs:
if: runner.os == 'Linux'
shell: bash
run: |
sudo mkdir -p /var/cache/apt/archives
sudo chown -R $USER:$USER /var/cache/apt/archives

echo 'Acquire::GzipIndexes "true";' | sudo tee /etc/apt/apt.conf.d/gzip
Expand Down
70 changes: 58 additions & 12 deletions pixel_renderer/processor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import os

from transformers import AutoVideoProcessor, ProcessorMixin

from font_configurator.font_configurator import FontConfigurator
Expand All @@ -21,29 +23,73 @@ def __init__(self,
font = FontConfig.from_dict(font)
self.font = font

# Store fontconfig setup parameters for lazy initialization
# Don't initialize fontconfig here to avoid fork-safety issues
self._font_dir = None
self._fontconfig_path = None
self._initialized_pid = None # Track which process initialized fontconfig

if self.font is not None:
font_dir = font.get_font_dir()
self._font_dir = font.get_font_dir()

def _ensure_fontconfig_initialized(self) -> None:
"""
Lazy initialization of fontconfig for fork-safety.

This method ensures fontconfig is initialized in the current process
before rendering. It's called on-demand, not in __init__, to prevent
fork-safety issues.

**Why this matters:**
- Fontconfig uses internal mutexes (locks) to protect its data structures
- When fork() creates a child process, it copies mutex state but not threads
- If fontconfig was initialized in parent, child inherits broken mutex state
- Child deadlocks when trying to acquire mutexes owned by non-existent threads

**How this fixes it:**
- Parent process: Never initializes fontconfig (stores config only)
- Each child process: Initializes fontconfig independently on first render
- Each process gets clean mutex state with no cross-process dependencies

**When fontconfig gets initialized:**
1. First time render_text() is called (never before)
2. After fork (detects PID changed, re-initializes in new process)

# Configure fontconfig (minimal template)
See: https://github.com/sign/pixel-renderer/issues/13
"""
if self.font is None:
raise ValueError("FontConfig must be provided to render text.")

current_pid = os.getpid()

# Initialize if not in this process yet
# Covers: first time (None) or after fork (different PID)
if self._initialized_pid != current_pid:
# Initialize fontconfig fresh in THIS process
font_configurator = FontConfigurator()
self.fontconfig_path = font_configurator.setup_font(
self._fontconfig_path = font_configurator.setup_font(
mode=FontconfigMode.TEMPLATE_MINIMAL,
font_dir=font_dir,
fontconfig_destination_dir=font_dir,
font_dir=self._font_dir,
fontconfig_destination_dir=self._font_dir,
force_reinitialize=True,
)
# Remember we initialized in this process
self._initialized_pid = current_pid

@property
def fontconfig_path(self):
"""Get fontconfig path, initializing if necessary."""
self._ensure_fontconfig_initialized()
return self._fontconfig_path

def render_text(self, text: str, block_size: int = 16, font_size: int = 12):
if self.font is None:
raise ValueError("FontConfig must be provided to render text.")
# It might not be obvious why we repeat the `render_text` function here instead of using
# the one from `pixel_renderer.renderer`. The reason is that we want to ensure that
# the fontconfig is properly set up before rendering the text. By having this method
# in the processor, we can guarantee that the fontconfig setup is done when the processor
# is initialized. and in the future, that nothing has overwritten the fontconfig setup.
"""Render text to numpy array."""
self._ensure_fontconfig_initialized()
return render_text(text, block_size=block_size, font_size=font_size)

def render_text_image(self, text: str, block_size: int = 16, font_size: int = 12):
"""Render text to PIL Image."""
self._ensure_fontconfig_initialized()
return render_text_image(text, block_size=block_size, font_size=font_size)

def to_dict(self, **unused_kwargs):
Expand Down
163 changes: 163 additions & 0 deletions tests/pixel_renderer/test_no_deadlock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""
Test for fontconfig fork-safety with PyTorch DataLoader.

These tests verify the fix for the deadlock issue described in:
https://github.com/sign/pixel-renderer/issues/13

The issue was that fontconfig (not fork-safe) was initialized in the parent
process, causing mutex deadlocks in forked workers.

The fix implements lazy initialization: fontconfig is only initialized when
first needed in each process, ensuring each fork gets clean state.

## Running the tests

Normal run (completes quickly):
pytest tests/pixel_renderer/test_no_deadlock.py -v
"""

import multiprocessing as mp

import pytest
from torch.utils.data import DataLoader, Dataset

from font_download import FontConfig
from font_download.example_fonts.noto_sans import FONTS_NOTO_SANS_MINIMAL
from pixel_renderer.processor import PixelRendererProcessor


@pytest.fixture
def font_config():
"""Create a FontConfig with actual fonts from example."""
return FontConfig(sources=FONTS_NOTO_SANS_MINIMAL)


@pytest.fixture
def processor(font_config):
"""Create a PixelRendererProcessor with fontconfig initialized."""
return PixelRendererProcessor(font=font_config)


class TextRenderDataset(Dataset):
"""Simple dataset that renders text using PixelRendererProcessor."""

def __init__(self, texts, processor):
self.texts = texts
self.processor = processor

def __len__(self):
return len(self.texts)

def __getitem__(self, idx):
text = self.texts[idx]
# This is where the deadlock occurs in worker processes
rendered = self.processor.render_text(text, block_size=16, font_size=12)
return {
'text': text,
'rendered_shape': rendered.shape,
}


def simulate_first_fork(processor, test_text="Hello from first fork"):
"""
Simulate the first fork operation (like pack_dataset() does).
This uses fontconfig in the forked process.
"""
try:
processor.render_text(test_text, block_size=16, font_size=12)
return True
except Exception: # noqa: BLE001
# Broad exception catch is intentional - we just want to know if rendering succeeded
return False


def test_no_deadlock_with_dataloader_single_fork(processor):
"""
Test that DataLoader with fork works without deadlock (single fork).

With lazy initialization fix, fontconfig is initialized independently in
each worker, so fork-based DataLoader works correctly.
"""
texts = ["Hello World", "Test 1"]
dataset = TextRenderDataset(texts, processor)

# Single fork should work
loader = DataLoader(
dataset,
batch_size=1,
num_workers=1,
multiprocessing_context=None, # Use default fork
timeout=10,
)

results = []
for batch in loader:
results.append(batch['text'][0])

assert len(results) == len(texts)


def test_no_deadlock_with_dataloader_double_fork(processor):
"""
Test that DataLoader with double fork doesn't deadlock.

This test verifies the fix for issue #13 by simulating:
1. First fork (like pack_dataset() from TRL)
2. Second fork (PyTorch DataLoader with num_workers>0)

With lazy initialization, each forked process initializes fontconfig
independently, preventing mutex deadlocks.
"""
texts = ["Hello", "World"]
dataset = TextRenderDataset(texts, processor)

# First fork (simulate pack_dataset)
ctx = mp.get_context('fork')
pool = ctx.Pool(processes=1)
first_result = pool.apply(simulate_first_fork, (processor, "First fork"))
pool.close()
pool.join()

assert first_result is True, "First fork should complete successfully"

# Second fork (DataLoader) - with fix, this should work
loader = DataLoader(
dataset,
batch_size=1,
num_workers=1,
multiprocessing_context=None, # Use fork
timeout=10,
)

results = []
for batch in loader:
results.append(batch['text'][0])

assert len(results) == len(texts)


def test_no_deadlock_with_dataloader_spawn(processor):
"""
Test that DataLoader with spawn works without deadlock.

This test should PASS - spawn creates clean process state and avoids
the fontconfig fork-safety issue.
"""
texts = ["Hello World", "Test 1"]
dataset = TextRenderDataset(texts, processor)

# Spawn should always work
ctx = mp.get_context('spawn')
loader = DataLoader(
dataset,
batch_size=1,
num_workers=1,
multiprocessing_context=ctx,
timeout=10,
)

results = []
for batch in loader:
results.append(batch['text'][0])

assert len(results) == len(texts)
Loading