Skip to content

Signal

signal

Signal stopper for graceful evolution termination on Unix signals.

This module provides a SignalStopper that handles Unix signals (SIGINT, SIGTERM) for graceful shutdown of evolution runs. Supports both async and sync contexts with proper signal handler management.

ATTRIBUTE DESCRIPTION
SignalStopper

Stop evolution on Unix signals (SIGINT, SIGTERM).

TYPE: class

Examples:

Using as a context manager (recommended):

from gepa_adk.adapters.stoppers import SignalStopper

async with SignalStopper() as stopper:
    config = EvolutionConfig(stop_callbacks=[stopper])
    result = await engine.run(config)
    # Ctrl+C will gracefully stop evolution

Manual setup and cleanup:

stopper = SignalStopper()
stopper.setup()
try:
    # Run evolution
    pass
finally:
    stopper.cleanup()
Notes

This stopper requires careful integration with asyncio's event loop for proper signal handling in async contexts. Installing and removing handlers is best-effort: a signal the platform or loop rejects is skipped rather than raised.

See Also

SignalStopper

Stop evolution on Unix signals (SIGINT, SIGTERM).

Handles Ctrl+C (SIGINT) and termination signals gracefully, allowing the current iteration to complete before returning results. Supports both async contexts (using asyncio signal handling) and sync contexts (using traditional signal handlers).

ATTRIBUTE DESCRIPTION
signals

Signals to handle.

TYPE: tuple[Signals, ...]

Examples:

Using as async context manager:

from gepa_adk.adapters.stoppers import SignalStopper

async with SignalStopper() as stopper:
    # stopper.setup() called automatically
    config = EvolutionConfig(stop_callbacks=[stopper])
    result = await engine.run(config)
    # stopper.cleanup() called automatically

Using with custom signals:

import signal

stopper = SignalStopper(signals=[signal.SIGINT])
stopper.setup()
try:
    # Only SIGINT will trigger stop
    pass
finally:
    stopper.cleanup()
Notes

Always call cleanup() after evolution completes to restore original signal handlers. The context manager pattern handles this automatically.

Source code in src/gepa_adk/adapters/stoppers/signal.py
class SignalStopper:
    """Stop evolution on Unix signals (SIGINT, SIGTERM).

    Handles Ctrl+C (SIGINT) and termination signals gracefully, allowing the
    current iteration to complete before returning results. Supports both
    async contexts (using asyncio signal handling) and sync contexts (using
    traditional signal handlers).

    Attributes:
        signals (tuple[signal.Signals, ...]): Signals to handle.

    Examples:
        Using as async context manager:

        ```python
        from gepa_adk.adapters.stoppers import SignalStopper

        async with SignalStopper() as stopper:
            # stopper.setup() called automatically
            config = EvolutionConfig(stop_callbacks=[stopper])
            result = await engine.run(config)
            # stopper.cleanup() called automatically
        ```

        Using with custom signals:

        ```python
        import signal

        stopper = SignalStopper(signals=[signal.SIGINT])
        stopper.setup()
        try:
            # Only SIGINT will trigger stop
            pass
        finally:
            stopper.cleanup()
        ```

    Notes:
        Always call cleanup() after evolution completes to restore original
        signal handlers. The context manager pattern handles this automatically.
    """

    def __init__(self, signals: Sequence[signal.Signals] | None = None) -> None:
        """Initialize signal stopper with signals to handle.

        Args:
            signals: Signals to handle. Defaults to [SIGINT, SIGTERM] which
                covers Ctrl+C and system termination requests.

        Examples:
            ```python
            # Default signals (SIGINT, SIGTERM)
            stopper = SignalStopper()

            # Custom signals
            stopper = SignalStopper(signals=[signal.SIGINT])
            ```

        Notes:
            Call setup() before evolution starts and cleanup() after
            evolution completes to properly manage signal handlers.
        """
        if signals is None:
            self.signals: tuple[signal.Signals, ...] = (
                signal.SIGINT,
                signal.SIGTERM,
            )
        else:
            self.signals = tuple(signals)
        self._stop_requested: bool = False
        self._original_handlers: dict[signal.Signals, _SignalHandler] = {}
        self._loop: asyncio.AbstractEventLoop | None = None

    def setup(self) -> None:
        """Install signal handlers.

        Must be called before evolution starts. Inside a running event loop it
        registers each signal with ``loop.add_signal_handler``; otherwise it
        installs ``signal.signal`` handlers and remembers the previous ones
        for cleanup().

        Examples:
            ```python
            stopper = SignalStopper()
            stopper.setup()
            try:
                # Run evolution
                pass
            finally:
                stopper.cleanup()
            ```

        Notes:
            On platforms where certain signals are unavailable (like Windows
            for SIGTERM), those signals are silently skipped.
        """
        try:
            self._loop = asyncio.get_running_loop()
            for sig in self.signals:
                # Signal not available on platform
                with contextlib.suppress(OSError, ValueError, NotImplementedError):
                    self._loop.add_signal_handler(sig, self._handle_signal)
        except RuntimeError:
            # Not in async context, use traditional signal handling
            for sig in self.signals:
                # Signal not available on platform
                with contextlib.suppress(OSError, ValueError):
                    self._original_handlers[sig] = signal.signal(
                        sig, self._sync_handler
                    )

    def _handle_signal(self) -> None:
        """Handle signal in async context."""
        self._stop_requested = True

    def _sync_handler(
        self,
        signum: int,
        frame: FrameType | None,
    ) -> None:
        """Record a stop request from a signal delivered outside an event loop.

        Args:
            signum (int): The signal number (unused; any registered signal stops).
            frame (FrameType | None): The interrupted stack frame (unused).
        """
        self._stop_requested = True

    def __call__(self, state: StopperState) -> bool:
        """Report whether a stop signal has arrived since setup().

        Args:
            state (StopperState): Current evolution state snapshot (not used,
                but required by StopperProtocol).

        Returns:
            True if a signal was received, False otherwise.

        Examples:
            ```python
            stopper = SignalStopper()
            stopper.setup()

            state = StopperState(
                iteration=5,
                best_score=0.8,
                stagnation_counter=0,
                total_evaluations=25,
                candidates_count=1,
                elapsed_seconds=30.0,
            )

            # Before signal
            stopper(state)  # False

            # After Ctrl+C
            # stopper(state)  # True
            ```

        Notes:
            Often called after each iteration. Returns True as soon as
            a signal is received.
        """
        return self._stop_requested

    def cleanup(self) -> None:
        """Restore original signal handlers.

        Should be called after evolution completes to restore the signal
        handlers that were in place before setup() was called.

        Examples:
            ```python
            stopper = SignalStopper()
            stopper.setup()
            try:
                # Run evolution
                pass
            finally:
                stopper.cleanup()  # Restore original handlers
            ```

        Notes:
            On platforms where certain signals are unavailable, those
            signals are silently skipped during cleanup. Afterwards the
            stopper holds no loop or saved handlers, so setup() can run again.
        """
        if self._loop is not None:
            for sig in self.signals:
                # Some signals may not be removable or supported by the event
                # loop on all platforms; cleanup is best-effort.
                with contextlib.suppress(OSError, ValueError, NotImplementedError):
                    self._loop.remove_signal_handler(sig)
        else:
            for sig, handler in self._original_handlers.items():
                # Some signals or states may prevent restoring previous
                # handlers; cleanup is best-effort.
                with contextlib.suppress(OSError, ValueError):
                    signal.signal(sig, handler)
        self._original_handlers.clear()
        self._loop = None

    async def __aenter__(self) -> SignalStopper:
        """Enter async context and install signal handlers.

        Returns:
            Self for use as stopper in evolution config.

        Examples:
            ```python
            async with SignalStopper() as stopper:
                config = EvolutionConfig(stop_callbacks=[stopper])
                result = await engine.run(config)
            ```

        Notes:
            On entry, signal handlers are installed automatically.
        """
        self.setup()
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object,
    ) -> None:
        """Exit async context and restore signal handlers.

        Args:
            exc_type: Exception type if an exception was raised.
            exc_val: Exception value if an exception was raised.
            exc_tb: Exception traceback if an exception was raised.

        Notes:
            Restores the original signal handlers automatically on exit,
            even when an exception is raised.
        """
        self.cleanup()

    def __enter__(self) -> SignalStopper:
        """Enter sync context and install signal handlers.

        Returns:
            Self for use as stopper in evolution config.

        Examples:
            ```python
            with SignalStopper() as stopper:
                config = EvolutionConfig(stop_callbacks=[stopper])
                result = engine.run(config)  # sync run
            ```

        Notes:
            On entry, signal handlers are installed automatically.
        """
        self.setup()
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object,
    ) -> None:
        """Exit sync context and restore signal handlers.

        Args:
            exc_type: Exception type if an exception was raised.
            exc_val: Exception value if an exception was raised.
            exc_tb: Exception traceback if an exception was raised.

        Notes:
            Restores the original signal handlers automatically on exit,
            even when an exception is raised.
        """
        self.cleanup()

__init__

__init__(signals: Sequence[Signals] | None = None) -> None

Initialize signal stopper with signals to handle.

PARAMETER DESCRIPTION
signals

Signals to handle. Defaults to [SIGINT, SIGTERM] which covers Ctrl+C and system termination requests.

TYPE: Sequence[Signals] | None DEFAULT: None

Examples:

# Default signals (SIGINT, SIGTERM)
stopper = SignalStopper()

# Custom signals
stopper = SignalStopper(signals=[signal.SIGINT])
Notes

Call setup() before evolution starts and cleanup() after evolution completes to properly manage signal handlers.

Source code in src/gepa_adk/adapters/stoppers/signal.py
def __init__(self, signals: Sequence[signal.Signals] | None = None) -> None:
    """Initialize signal stopper with signals to handle.

    Args:
        signals: Signals to handle. Defaults to [SIGINT, SIGTERM] which
            covers Ctrl+C and system termination requests.

    Examples:
        ```python
        # Default signals (SIGINT, SIGTERM)
        stopper = SignalStopper()

        # Custom signals
        stopper = SignalStopper(signals=[signal.SIGINT])
        ```

    Notes:
        Call setup() before evolution starts and cleanup() after
        evolution completes to properly manage signal handlers.
    """
    if signals is None:
        self.signals: tuple[signal.Signals, ...] = (
            signal.SIGINT,
            signal.SIGTERM,
        )
    else:
        self.signals = tuple(signals)
    self._stop_requested: bool = False
    self._original_handlers: dict[signal.Signals, _SignalHandler] = {}
    self._loop: asyncio.AbstractEventLoop | None = None

setup

setup() -> None

Install signal handlers.

Must be called before evolution starts. Inside a running event loop it registers each signal with loop.add_signal_handler; otherwise it installs signal.signal handlers and remembers the previous ones for cleanup().

Examples:

stopper = SignalStopper()
stopper.setup()
try:
    # Run evolution
    pass
finally:
    stopper.cleanup()
Notes

On platforms where certain signals are unavailable (like Windows for SIGTERM), those signals are silently skipped.

Source code in src/gepa_adk/adapters/stoppers/signal.py
def setup(self) -> None:
    """Install signal handlers.

    Must be called before evolution starts. Inside a running event loop it
    registers each signal with ``loop.add_signal_handler``; otherwise it
    installs ``signal.signal`` handlers and remembers the previous ones
    for cleanup().

    Examples:
        ```python
        stopper = SignalStopper()
        stopper.setup()
        try:
            # Run evolution
            pass
        finally:
            stopper.cleanup()
        ```

    Notes:
        On platforms where certain signals are unavailable (like Windows
        for SIGTERM), those signals are silently skipped.
    """
    try:
        self._loop = asyncio.get_running_loop()
        for sig in self.signals:
            # Signal not available on platform
            with contextlib.suppress(OSError, ValueError, NotImplementedError):
                self._loop.add_signal_handler(sig, self._handle_signal)
    except RuntimeError:
        # Not in async context, use traditional signal handling
        for sig in self.signals:
            # Signal not available on platform
            with contextlib.suppress(OSError, ValueError):
                self._original_handlers[sig] = signal.signal(
                    sig, self._sync_handler
                )

__call__

__call__(state: StopperState) -> bool

Report whether a stop signal has arrived since setup().

PARAMETER DESCRIPTION
state

Current evolution state snapshot (not used, but required by StopperProtocol).

TYPE: StopperState

RETURNS DESCRIPTION
bool

True if a signal was received, False otherwise.

Examples:

stopper = SignalStopper()
stopper.setup()

state = StopperState(
    iteration=5,
    best_score=0.8,
    stagnation_counter=0,
    total_evaluations=25,
    candidates_count=1,
    elapsed_seconds=30.0,
)

# Before signal
stopper(state)  # False

# After Ctrl+C
# stopper(state)  # True
Notes

Often called after each iteration. Returns True as soon as a signal is received.

Source code in src/gepa_adk/adapters/stoppers/signal.py
def __call__(self, state: StopperState) -> bool:
    """Report whether a stop signal has arrived since setup().

    Args:
        state (StopperState): Current evolution state snapshot (not used,
            but required by StopperProtocol).

    Returns:
        True if a signal was received, False otherwise.

    Examples:
        ```python
        stopper = SignalStopper()
        stopper.setup()

        state = StopperState(
            iteration=5,
            best_score=0.8,
            stagnation_counter=0,
            total_evaluations=25,
            candidates_count=1,
            elapsed_seconds=30.0,
        )

        # Before signal
        stopper(state)  # False

        # After Ctrl+C
        # stopper(state)  # True
        ```

    Notes:
        Often called after each iteration. Returns True as soon as
        a signal is received.
    """
    return self._stop_requested

cleanup

cleanup() -> None

Restore original signal handlers.

Should be called after evolution completes to restore the signal handlers that were in place before setup() was called.

Examples:

stopper = SignalStopper()
stopper.setup()
try:
    # Run evolution
    pass
finally:
    stopper.cleanup()  # Restore original handlers
Notes

On platforms where certain signals are unavailable, those signals are silently skipped during cleanup. Afterwards the stopper holds no loop or saved handlers, so setup() can run again.

Source code in src/gepa_adk/adapters/stoppers/signal.py
def cleanup(self) -> None:
    """Restore original signal handlers.

    Should be called after evolution completes to restore the signal
    handlers that were in place before setup() was called.

    Examples:
        ```python
        stopper = SignalStopper()
        stopper.setup()
        try:
            # Run evolution
            pass
        finally:
            stopper.cleanup()  # Restore original handlers
        ```

    Notes:
        On platforms where certain signals are unavailable, those
        signals are silently skipped during cleanup. Afterwards the
        stopper holds no loop or saved handlers, so setup() can run again.
    """
    if self._loop is not None:
        for sig in self.signals:
            # Some signals may not be removable or supported by the event
            # loop on all platforms; cleanup is best-effort.
            with contextlib.suppress(OSError, ValueError, NotImplementedError):
                self._loop.remove_signal_handler(sig)
    else:
        for sig, handler in self._original_handlers.items():
            # Some signals or states may prevent restoring previous
            # handlers; cleanup is best-effort.
            with contextlib.suppress(OSError, ValueError):
                signal.signal(sig, handler)
    self._original_handlers.clear()
    self._loop = None

__aenter__ async

__aenter__() -> SignalStopper

Enter async context and install signal handlers.

RETURNS DESCRIPTION
SignalStopper

Self for use as stopper in evolution config.

Examples:

async with SignalStopper() as stopper:
    config = EvolutionConfig(stop_callbacks=[stopper])
    result = await engine.run(config)
Notes

On entry, signal handlers are installed automatically.

Source code in src/gepa_adk/adapters/stoppers/signal.py
async def __aenter__(self) -> SignalStopper:
    """Enter async context and install signal handlers.

    Returns:
        Self for use as stopper in evolution config.

    Examples:
        ```python
        async with SignalStopper() as stopper:
            config = EvolutionConfig(stop_callbacks=[stopper])
            result = await engine.run(config)
        ```

    Notes:
        On entry, signal handlers are installed automatically.
    """
    self.setup()
    return self

__aexit__ async

__aexit__(
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: object,
) -> None

Exit async context and restore signal handlers.

PARAMETER DESCRIPTION
exc_type

Exception type if an exception was raised.

TYPE: type[BaseException] | None

exc_val

Exception value if an exception was raised.

TYPE: BaseException | None

exc_tb

Exception traceback if an exception was raised.

TYPE: object

Notes

Restores the original signal handlers automatically on exit, even when an exception is raised.

Source code in src/gepa_adk/adapters/stoppers/signal.py
async def __aexit__(
    self,
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: object,
) -> None:
    """Exit async context and restore signal handlers.

    Args:
        exc_type: Exception type if an exception was raised.
        exc_val: Exception value if an exception was raised.
        exc_tb: Exception traceback if an exception was raised.

    Notes:
        Restores the original signal handlers automatically on exit,
        even when an exception is raised.
    """
    self.cleanup()

__enter__

__enter__() -> SignalStopper

Enter sync context and install signal handlers.

RETURNS DESCRIPTION
SignalStopper

Self for use as stopper in evolution config.

Examples:

with SignalStopper() as stopper:
    config = EvolutionConfig(stop_callbacks=[stopper])
    result = engine.run(config)  # sync run
Notes

On entry, signal handlers are installed automatically.

Source code in src/gepa_adk/adapters/stoppers/signal.py
def __enter__(self) -> SignalStopper:
    """Enter sync context and install signal handlers.

    Returns:
        Self for use as stopper in evolution config.

    Examples:
        ```python
        with SignalStopper() as stopper:
            config = EvolutionConfig(stop_callbacks=[stopper])
            result = engine.run(config)  # sync run
        ```

    Notes:
        On entry, signal handlers are installed automatically.
    """
    self.setup()
    return self

__exit__

__exit__(
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: object,
) -> None

Exit sync context and restore signal handlers.

PARAMETER DESCRIPTION
exc_type

Exception type if an exception was raised.

TYPE: type[BaseException] | None

exc_val

Exception value if an exception was raised.

TYPE: BaseException | None

exc_tb

Exception traceback if an exception was raised.

TYPE: object

Notes

Restores the original signal handlers automatically on exit, even when an exception is raised.

Source code in src/gepa_adk/adapters/stoppers/signal.py
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: object,
) -> None:
    """Exit sync context and restore signal handlers.

    Args:
        exc_type: Exception type if an exception was raised.
        exc_val: Exception value if an exception was raised.
        exc_tb: Exception traceback if an exception was raised.

    Notes:
        Restores the original signal handlers automatically on exit,
        even when an exception is raised.
    """
    self.cleanup()