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
30 changes: 9 additions & 21 deletions raven/cli/_exit.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,23 @@
"""Hard-exit past CPython interpreter finalization for native-unsafe runtimes.
"""Hard-exit past CPython interpreter finalization.

Building the agent loop opens a lancedb-backed store, and lancedb starts a
process-global ``LanceDBBackgroundEventLoop`` (Rust/tokio) daemon thread with
no public shutdown hook. Finalizing the interpreter while that native runtime
is still live segfaults (``Py_FinalizeEx``; SIGSEGV, exit 139), masking the
command's real exit code. Every raven command that builds the loop starts that
thread, so the guard lives once at the CLI exit chokepoint
(:func:`raven.cli.commands.run`): when the hazard is live, flush and
``os._exit`` past finalization.
Finalizing the interpreter while native state is still live can segfault
(``Py_FinalizeEx``; SIGSEGV, exit 139) and mask the real exit code. The only
caller today is the pytest session hook (``tests/conftest.py``), where a fully
green run was observed exiting 139 on Linux.

CliRunner invokes the Typer ``app`` object directly (in-process), never the
console-script ``run`` wrapper, so test hosts keep normal exit semantics.
This used to also gate ``raven.cli.commands.run`` on a live lancedb background
thread. That gate is gone: nothing under ``raven/`` imports lancedb -- memory
talks to everos over HTTP and everos runs out-of-process -- so the probe could
not fire in any configuration.
"""

from __future__ import annotations

import os
import sys
import threading
from typing import NoReturn


def lancedb_finalization_hazard() -> bool:
"""Whether lancedb's Rust/tokio background thread is live in this process.

Merely importing lancedb is safe — the thread only exists once a connection
is opened — so key on the live thread, not the imported module.
"""
return any(t.name == "LanceDBBackgroundEventLoop" for t in threading.enumerate())


def flush_and_hard_exit(code: int) -> NoReturn:
"""Flush stdio + loguru sinks, then ``os._exit`` past interpreter finalization."""
try:
Expand Down
4 changes: 0 additions & 4 deletions raven/cli/agent_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,10 +462,6 @@ async def run_once():
)

asyncio.run(run_once())
# Native runtimes loaded by the agent loop (lancedb's Rust/tokio
# thread, torch) segfault during interpreter finalization. The exit
# chokepoint in raven.cli.commands.run hard-exits past finalization
# when that hazard is live, so this path just returns normally.
else:
# Interactive mode — user turns run through spine (submit -> lane ->
# hub -> CliOutlet); cron/sentinel nudges go via the spine hub
Expand Down
18 changes: 1 addition & 17 deletions raven/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,7 @@ def main(


def run() -> None:
"""Console-script entry point.

Runs the Typer app, then hard-exits past CPython interpreter finalization
when a native runtime that segfaults at finalization is live (lancedb's
Rust/tokio background thread — see :mod:`raven.cli._exit`). Any command that
builds the agent loop starts that thread, so guarding here covers them all
at once. CliRunner invokes ``app`` directly and never reaches this wrapper,
so in-process test hosts keep normal exit semantics.
"""
from raven.cli._exit import flush_and_hard_exit, lancedb_finalization_hazard
"""Console-script entry point."""
from raven.config.loader import ConfigReadError

try:
Expand All @@ -170,13 +161,6 @@ def run() -> None:

Console(stderr=True).print(f"[red]✗[/red] {exc}")
raise SystemExit(1) from exc
except SystemExit as exc:
code = exc.code
if not isinstance(code, int):
code = 0 if code is None else 1
if lancedb_finalization_hazard():
flush_and_hard_exit(code)
raise


if __name__ == "__main__":
Expand Down
40 changes: 4 additions & 36 deletions tests/test_cli_exit.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,16 @@
"""Tests for the hard-exit guard in ``raven.cli._exit``.
"""Tests for the hard-exit helper in ``raven.cli._exit``.

The guard exists because native runtimes loaded by the agent loop segfault
during interpreter finalization; ``raven.cli.commands.run`` and the pytest
session both route their exit through it. These pin the guard's own behaviour
Its only caller is the pytest session hook in ``tests/conftest.py``, which needs
it because a fully green run was observed exiting 139 on Linux when the
interpreter finalized with native state live. These pin the helper's behaviour
in-process, so the coverage does not depend on provoking a real native crash.

Scope note, so these tests are not mistaken for proof the guard is load-bearing:
``flush_and_hard_exit`` is live (the pytest session calls it on CI), but the
``lancedb_finalization_hazard`` gate in ``commands.py`` is currently dormant --
the memory plugin talks to everos over HTTP and opens no local lancedb
connection, so the probe returns False in every configuration today.
"""

from __future__ import annotations

import subprocess
import sys
import textwrap
import threading

import pytest

from raven.cli._exit import lancedb_finalization_hazard

_LANCEDB_THREAD = "LanceDBBackgroundEventLoop"


def test_hazard_false_without_the_lancedb_thread() -> None:
if any(t.name == _LANCEDB_THREAD for t in threading.enumerate()):
pytest.skip("a lancedb connection is already open in this process")
assert lancedb_finalization_hazard() is False


def test_hazard_true_while_a_thread_of_that_name_is_alive() -> None:
"""The probe keys on the thread name, not on lancedb being imported."""
stop = threading.Event()
worker = threading.Thread(target=stop.wait, name=_LANCEDB_THREAD, daemon=True)
worker.start()
try:
assert lancedb_finalization_hazard() is True
finally:
stop.set()
worker.join(timeout=5)
assert lancedb_finalization_hazard() is False


def _hard_exit_child(code: int, *, prints: str = "") -> subprocess.CompletedProcess:
Expand Down
Loading