mirror of
https://github.com/crewAIInc/crewAI.git
synced 2025-12-16 04:18:35 +00:00
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>
284 lines
9.4 KiB
Python
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
|