Files
crewAI/lib/crewai/tests/events/test_signal_manager.py
Devin AI b5cbde7bb6 fix: decouple system signal events from Telemetry
This fixes issue #4041 where system signal events (SigTermEvent, SigIntEvent,
etc.) were disabled when telemetry was disabled.

Changes:
- Create SystemSignalManager class to handle OS signal registration
  independently of telemetry settings
- Move signal registration from Telemetry to SystemSignalManager
- Register signal handlers at system_events.py module import time
- Update Telemetry to subscribe to signal events via event bus instead
  of owning the signal handlers
- Add comprehensive tests for signal events with telemetry disabled
  and signal handler chaining

The signal events now work regardless of CREWAI_DISABLE_TELEMETRY setting,
and properly chain to any handlers registered before CrewAI imports.

Co-Authored-By: João <joao@crewai.com>
2025-12-08 01:34:09 +00:00

284 lines
9.4 KiB
Python

"""Tests for SystemSignalManager and signal event decoupling from telemetry.
These tests verify that:
1. Signal events work when telemetry is disabled
2. Signal handler chaining works correctly
3. SystemSignalManager properly bridges OS signals to the event bus
"""
import os
import signal
import subprocess
import sys
import textwrap
import time
from unittest.mock import patch
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.signal_manager import SystemSignalManager, system_signal_manager
from crewai.events.types.system_events import (
SigTermEvent,
on_signal,
)
class TestSystemSignalManager:
"""Tests for SystemSignalManager class."""
def test_singleton_pattern(self) -> None:
"""Test that SystemSignalManager is a singleton."""
manager1 = SystemSignalManager()
manager2 = SystemSignalManager()
assert manager1 is manager2
def test_global_instance_is_singleton(self) -> None:
"""Test that the global system_signal_manager is the singleton instance."""
manager = SystemSignalManager()
assert manager is system_signal_manager
def test_register_signal_stores_original_handler(self) -> None:
"""Test that register_signal stores the original handler."""
manager = SystemSignalManager()
original = signal.getsignal(signal.SIGUSR1)
try:
manager.register_signal(signal.SIGUSR1, SigTermEvent, shutdown=False)
assert signal.SIGUSR1 in manager._original_handlers
finally:
signal.signal(signal.SIGUSR1, original)
def test_register_signal_emits_event(self) -> None:
"""Test that registered signal handler emits event to event bus."""
import threading
received_events: list[SigTermEvent] = []
condition = threading.Condition()
@crewai_event_bus.on(SigTermEvent)
def handler(source: object, event: SigTermEvent) -> None:
with condition:
received_events.append(event)
condition.notify_all()
manager = SystemSignalManager()
original = signal.getsignal(signal.SIGUSR1)
try:
manager.register_signal(signal.SIGUSR1, SigTermEvent, shutdown=False)
os.kill(os.getpid(), signal.SIGUSR1)
with condition:
condition.wait_for(lambda: len(received_events) >= 1, timeout=5.0)
assert len(received_events) >= 1
assert isinstance(received_events[0], SigTermEvent)
finally:
signal.signal(signal.SIGUSR1, original)
class TestSignalEventsWithTelemetryDisabled:
"""Tests verifying signal events work when telemetry is disabled.
These tests use subprocess to avoid interfering with pytest's signal handling.
"""
@pytest.mark.timeout(30)
def test_on_signal_handler_fires_with_telemetry_disabled(self) -> None:
"""Test that @on_signal handlers fire even when telemetry is disabled.
This is the core fix for issue #4041: signal events should work
regardless of the CREWAI_DISABLE_TELEMETRY setting.
"""
script = textwrap.dedent('''
import os
import sys
import time
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
from crewai.events.types.system_events import SignalEvent, on_signal
@on_signal
def user_signal_handler(source: object, event: SignalEvent) -> None:
print(f"[USER_HANDLER] Received event type={event.type}", flush=True)
print(f"[READY] PID={os.getpid()}", flush=True)
sys.stdout.flush()
while True:
time.sleep(0.1)
''')
env = os.environ.copy()
env["CREWAI_DISABLE_TELEMETRY"] = "true"
proc = subprocess.Popen(
[sys.executable, "-c", script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
text=True,
)
try:
ready_line = proc.stdout.readline()
assert "[READY]" in ready_line, f"Process not ready: {ready_line}"
time.sleep(0.5)
proc.send_signal(signal.SIGTERM)
stdout, stderr = proc.communicate(timeout=10)
full_output = ready_line + stdout
assert "[USER_HANDLER]" in full_output, (
f"User handler did not fire. Output: {full_output}, Stderr: {stderr}"
)
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
@pytest.mark.timeout(30)
def test_sigint_handler_fires_with_telemetry_disabled(self) -> None:
"""Test that SIGINT events work when telemetry is disabled."""
script = textwrap.dedent('''
import os
import sys
import time
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
from crewai.events.types.system_events import SigIntEvent
from crewai.events.event_bus import crewai_event_bus
@crewai_event_bus.on(SigIntEvent)
def sigint_handler(source: object, event: SigIntEvent) -> None:
print(f"[SIGINT_HANDLER] Received SIGINT event", flush=True)
print(f"[READY] PID={os.getpid()}", flush=True)
sys.stdout.flush()
while True:
time.sleep(0.1)
''')
env = os.environ.copy()
env["CREWAI_DISABLE_TELEMETRY"] = "true"
proc = subprocess.Popen(
[sys.executable, "-c", script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
text=True,
)
try:
ready_line = proc.stdout.readline()
assert "[READY]" in ready_line, f"Process not ready: {ready_line}"
time.sleep(0.5)
proc.send_signal(signal.SIGINT)
stdout, stderr = proc.communicate(timeout=10)
full_output = ready_line + stdout
assert "[SIGINT_HANDLER]" in full_output, (
f"SIGINT handler did not fire. Output: {full_output}, Stderr: {stderr}"
)
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
class TestSignalHandlerChaining:
"""Tests verifying signal handler chaining works correctly.
These tests verify that when user code registers a signal handler before
CrewAI, the CrewAI handler properly chains to the original handler.
"""
@pytest.mark.timeout(30)
def test_baseline_handler_called_after_crewai_handler(self) -> None:
"""Test that baseline OS handler is called after CrewAI emits the event.
This tests the scenario where user code registers a signal handler
before CrewAI imports. The CrewAI handler should emit the event and
then call the original handler.
"""
script = textwrap.dedent('''
import os
import signal
import sys
import time
from typing import Any
def baseline_handler(signum: int, frame: Any) -> None:
print("[BASELINE_HANDLER] Signal received", flush=True)
signal.signal(signal.SIGTERM, baseline_handler)
from crewai.events.types.system_events import SignalEvent, on_signal
@on_signal
def user_signal_handler(source: object, event: SignalEvent) -> None:
print(f"[USER_HANDLER] Received event type={event.type}", flush=True)
print(f"[READY] PID={os.getpid()}", flush=True)
sys.stdout.flush()
while True:
time.sleep(0.1)
''')
proc = subprocess.Popen(
[sys.executable, "-c", script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
ready_line = proc.stdout.readline()
assert "[READY]" in ready_line, f"Process not ready: {ready_line}"
time.sleep(0.5)
proc.send_signal(signal.SIGTERM)
time.sleep(1.0)
proc.send_signal(signal.SIGKILL)
stdout, stderr = proc.communicate(timeout=5)
full_output = ready_line + stdout
assert "[USER_HANDLER]" in full_output, (
f"User handler did not fire. Output: {full_output}, Stderr: {stderr}"
)
assert "[BASELINE_HANDLER]" in full_output, (
f"Baseline handler did not fire. Output: {full_output}, Stderr: {stderr}"
)
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
class TestTelemetrySignalIntegration:
"""Tests for Telemetry's integration with the signal event system."""
def test_telemetry_registers_shutdown_handlers_on_event_bus(self) -> None:
"""Test that Telemetry registers shutdown handlers on the event bus."""
with patch("crewai.telemetry.telemetry.TracerProvider"):
with patch("crewai.telemetry.telemetry.BatchSpanProcessor"):
with patch("crewai.telemetry.telemetry.SafeOTLPSpanExporter"):
with patch.dict(os.environ, {"CREWAI_DISABLE_TELEMETRY": "false"}):
from crewai.telemetry.telemetry import Telemetry
Telemetry._instance = None
telemetry = Telemetry()
assert telemetry.ready is True