Files
wehub-resource-sync c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

1503 lines
66 KiB
Python

"""Generic subprocess harness: request/response dataclasses, queue event loop,
and a main-process Session that owns a Process + queues.
Stdlib only. Never import cognee from this module.
"""
from __future__ import annotations
import asyncio
import atexit
import concurrent.futures
import ctypes
import ctypes.util
import itertools
import os
import pickle
import queue as std_queue
import signal
import subprocess
import sys
import threading
import time
import traceback
import weakref
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Dict, Optional
SHUTDOWN = "__SUBPROCESS_HARNESS_SHUTDOWN__"
_DEFAULT_SHUTDOWN_TIMEOUT = 10.0
_DEFAULT_INIT_TIMEOUT = 60.0
def _env_float(name: str, default: Optional[float]) -> Optional[float]:
"""Parse a float-or-disabled env var. Empty / unset → ``default``; a
value <= 0 disables the timeout (``None``); any other float is used.
"""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
val = float(raw)
except ValueError:
return default
return None if val <= 0 else val
def _env_int(name: str, default: int) -> int:
"""Graceful ``int`` parse. Returns the default if the env var is missing,
empty, or not a valid integer — we don't want a typo in a deployment's
env vars to crash module import.
"""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
return int(raw)
except ValueError:
return default
# Per-RPC deadline for subprocess calls. Guards against hung native libraries.
# Override with ``SUBPROCESS_CALL_TIMEOUT`` env var (seconds, or <=0 to
# disable entirely — not recommended outside benchmarks).
_DEFAULT_CALL_TIMEOUT: Optional[float] = _env_float("SUBPROCESS_CALL_TIMEOUT", 300.0)
_DEFAULT_MAX_RETRIES = _env_int("SUBPROCESS_MAX_RETRIES", 2)
# Reader-thread poll interval for the response queue and for the
# ``_closed_event`` flag. NOTE: any caller setting an explicit
# ``shutdown_timeout`` should keep it >= this interval — otherwise the
# reader-thread ``join()`` in ``shutdown()`` can time out before the
# reader observes the closed flag, producing spurious "join timed out"
# warnings even on a clean shutdown.
_PROCESS_CHECK_INTERVAL = 1.0
# Per-call timeouts in a row before the retry loop forces a respawn.
# A single per-call timeout under concurrent RPC is just a slow op (the
# session stays open so sibling calls keep working); two in a row almost
# always means the worker is wedged.
_TIMEOUT_BEFORE_RESPAWN = 2
# Number of times the worker retries opening a database that is currently
# lock-held by another (still-shutting-down) worker for the same file, and the
# initial backoff between attempts (seconds, exponential). These exist as a
# backstop for the brief window where one worker is releasing a file lock while
# another opens the same path; see ``kuzu_worker._open_database``. Override with
# ``SUBPROCESS_OPEN_LOCK_RETRIES`` / ``SUBPROCESS_OPEN_LOCK_BACKOFF``.
OPEN_LOCK_RETRIES = _env_int("SUBPROCESS_OPEN_LOCK_RETRIES", 10)
# ``_env_float`` returns the default (0.1) when unset/invalid and ``None`` for an
# explicit ``<= 0``. Treat that explicit ``<= 0`` as ``0.0`` (immediate retries,
# no delay) rather than resetting to the default, so ``SUBPROCESS_OPEN_LOCK_BACKOFF=0``
# is honored.
_OPEN_LOCK_BACKOFF_RAW = _env_float("SUBPROCESS_OPEN_LOCK_BACKOFF", 0.1)
OPEN_LOCK_BACKOFF = 0.0 if _OPEN_LOCK_BACKOFF_RAW is None else _OPEN_LOCK_BACKOFF_RAW
_READY_SENTINEL = "__SUBPROCESS_HARNESS_READY__"
# Universal op code — every worker's DISPATCH includes an entry for it so
# callers can force a ``gc.collect()`` inside the worker (useful before
# reading the subprocess's RSS so the number reflects reachable objects
# only, not uncollected cycles).
OP_GC_COLLECT = 99
class SubprocessTransportError(RuntimeError):
"""Raised when the subprocess transport itself is broken — the worker
exited unexpectedly or the RPC deadline was missed. Distinct from
application errors raised *inside* the worker (those come back via
``Response.error`` / ``Response.exception``) so the session can decide
which kind to retry.
"""
@dataclass
class Request:
op: int
# Per-request id used to correlate responses with waiters in the main
# process. ``0`` is reserved for protocol sentinels (the SHUTDOWN ack
# and the ``_READY_SENTINEL`` response). The session assigns ids from
# ``itertools.count(1)`` in ``_issue`` / ``_issue_async``.
request_id: int = 0
handle_id: Optional[int] = None
args: tuple = ()
kwargs: dict = field(default_factory=dict)
@dataclass
class Response:
# Echoed from the originating Request so the main-process reader thread
# can route this response to the correct pending future. ``0`` for
# protocol sentinels (SHUTDOWN ack, READY).
request_id: int = 0
result: Any = None
new_handle_id: Optional[int] = None
error: Optional[str] = None
exception: Optional[BaseException] = None
@dataclass
class HandleResult:
"""Sentinel returned by a dispatcher that allocated a new worker-side handle."""
value: Any
handle_id: int
@dataclass
class ReplayStep:
"""A setup RPC replayed when the worker is respawned after a crash.
``make_request`` is invoked at replay time (not registration time), so
lambdas that read ``self._handle_id`` naturally pick up values freshly
assigned by earlier steps.
``apply_new_handle`` receives the newly allocated worker-side handle and
returns the *old* handle id it replaces. The session records that
``old → new`` mapping and rewrites pending requests whose ``handle_id``
matches before the retry fires. Return ``None`` for steps that don't
allocate a handle (e.g. ``OP_DB_INIT``, ``OP_LOAD_EXTENSION``).
"""
make_request: "Callable[[], Request]"
apply_new_handle: Optional["Callable[[int], Optional[int]]"] = None
class HandleRegistry:
"""Integer-keyed registry of worker-side native objects."""
def __init__(self) -> None:
self._counter = itertools.count(1)
self._handles: Dict[int, Any] = {}
def register(self, obj: Any) -> int:
hid = next(self._counter)
self._handles[hid] = obj
return hid
def register_at(self, hid: int, obj: Any) -> None:
"""Register ``obj`` at a fixed pre-known handle id.
Used for singleton resources whose id the protocol has reserved
out-of-band (e.g. the LanceDB worker's per-process connection
slot). Keeps callers from reaching into ``self._handles``
directly so any future locking / invariants the registry
adopts apply uniformly.
"""
self._handles[hid] = obj
def get(self, hid: int) -> Any:
return self._handles[hid]
def pop(self, hid: int) -> Any:
return self._handles.pop(hid, None)
# Annotations for the most common SIGKILL/SIGTERM/SIGSEGV/SIGABRT scenarios.
# Keeps the diagnostic message immediately actionable instead of requiring
# the reader to know what each signal usually means in production. Kept
# small on purpose — only the signals we actually want to flag with extra
# context get an entry; everything else falls back to just the signal name.
_SIGNAL_HINTS: Dict[str, str] = {
"SIGKILL": "likely OOM kill or `docker kill`",
"SIGSEGV": "native crash — check faulthandler dump in worker stderr",
"SIGABRT": "abort/assert in native code",
"SIGTERM": "external termination (parent shutdown or platform stop)",
"SIGBUS": "bad memory access in native code",
"SIGFPE": "arithmetic error in native code",
}
def _describe_exitcode(exitcode: Optional[int]) -> str:
"""Render an exitcode for human consumption.
``None`` and non-negative codes pass through unchanged; negative codes
are decoded into the signal that killed the worker (``-9`` → ``SIGKILL``)
with a short hint about the typical cause in production. POSIX exit
semantics: ``multiprocessing.Process.exitcode`` is the negated signal
number when the child died from an uncaught signal.
Note: positive exitcodes are NOT decoded as signals. On Windows a process
"killed" via ``os.kill(pid, sig)`` (``TerminateProcess``) exits with a
positive code, but positive codes are overwhelmingly ordinary exit codes
(e.g. ``1`` = generic error), so decoding them as signals would mislabel
normal exits.
"""
if exitcode is None or exitcode >= 0:
return str(exitcode)
try:
sig = signal.Signals(-exitcode)
except (ValueError, AttributeError):
return str(exitcode)
hint = _SIGNAL_HINTS.get(sig.name)
if hint:
return f"{exitcode} ({sig.name}{hint})"
return f"{exitcode} ({sig.name})"
def set_pdeathsig() -> bool:
"""Linux only: ask the kernel to SIGTERM this process when the parent dies.
Returns ``True`` when the signal was successfully armed, ``False`` on any
other platform or if the prctl call failed. Callers use the return value
to decide whether the portable polling watchdog is still needed.
Resolves libc via ``ctypes.util.find_library`` rather than hard-coding
``libc.so.6`` so musl-based distros (Alpine) and other non-glibc Linuxes
still arm pdeathsig instead of silently falling through to the polling
watchdog — which would re-expose the Docker-PID-1 hang this module was
built to prevent.
No-op on other platforms. Complements ``daemon=True`` which is bypassed on
abnormal parent termination.
"""
if sys.platform != "linux":
return False
try:
PR_SET_PDEATHSIG = 1
# ``find_library`` returns the SONAME of the system libc (e.g.
# ``libc.so.6`` on glibc, ``libc.musl-x86_64.so.1`` on Alpine).
# Passing ``None`` to ``CDLL`` opens the main program's symbol table
# which on Linux includes the dynamic linker's libc symbols — used as
# a last-ditch fallback if ``find_library`` returns nothing (rare).
libc_name = ctypes.util.find_library("c")
libc = ctypes.CDLL(libc_name, use_errno=True) if libc_name else ctypes.CDLL(None)
rc = libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0)
return rc == 0
except Exception:
return False
def start_parent_liveness_watchdog(poll_interval: float = 1.0) -> None:
"""Portable fallback when ``set_pdeathsig`` is unavailable or failed.
Used on macOS, Windows, and on Linux configurations where the prctl call
in ``set_pdeathsig`` did not succeed (no libc found, sandboxed prctl,
etc.). The call site in ``run_worker_loop`` gates this watchdog behind
``set_pdeathsig()`` returning ``False`` so a successfully-armed kernel
signal isn't shadowed by a redundant polling thread.
Starts a daemon thread that polls ``os.getppid()``. When the original
parent dies the child is reparented (typically to launchd/init), so a
ppid change is a reliable signal. The watchdog then terminates the worker
with ``os._exit`` to avoid orphaned DB processes holding file locks.
Only checks for ``current_ppid != original_ppid``. An earlier version
also exited when ``current_ppid == 1`` as a heuristic for "reparented to
init" — but in container deployments the legitimate parent is frequently
pid 1 (cognee running as the container's entrypoint), which made the
watchdog kill workers on their very first poll. The ppid-change check
alone covers reparenting correctly in all cases.
"""
import threading
try:
original_ppid = os.getppid()
except Exception:
return
def _watch() -> None:
while True:
try:
current_ppid = os.getppid()
except Exception:
return
if current_ppid != original_ppid:
# Parent is gone; exit fast without running atexit handlers
# (those may try to use resources owned by the dead parent).
os._exit(0)
try:
time.sleep(poll_interval)
except Exception:
return
t = threading.Thread(target=_watch, name="parent-liveness-watchdog", daemon=True)
t.start()
# Serializes all ``spawn_without_main`` enter/exit transitions. The
# mutation is on a single, process-global object (``sys.modules["__main__"]``),
# so two threads entering concurrently would race: thread B would capture
# thread A's already-cleared ``None`` as "saved", then on exit restore it,
# permanently breaking the main module for the rest of the process. A
# threading lock is sufficient because ``Process.start()`` blocks on the
# child reading its pickled state, so holding this lock for the duration of
# the spawn is fine.
_MAIN_MODULE_MUTATION_LOCK = threading.Lock()
class spawn_without_main:
"""Temporarily hide ``__main__.__spec__`` / ``__main__.__file__`` so that
``multiprocessing``'s spawn bootstrap does not re-execute the parent's main
script in the child. Without this, the child re-imports every top-level
import performed by the main script — which for cognee means a ~200 MB
import tax every time a subprocess starts.
"""
def __enter__(self):
_MAIN_MODULE_MUTATION_LOCK.acquire()
try:
main_mod = sys.modules.get("__main__")
self._main = main_mod
self._saved_spec = getattr(main_mod, "__spec__", None) if main_mod else None
self._saved_file = getattr(main_mod, "__file__", None) if main_mod else None
if main_mod is not None:
try:
main_mod.__spec__ = None
except Exception:
pass
try:
main_mod.__file__ = None
except Exception:
pass
except BaseException:
_MAIN_MODULE_MUTATION_LOCK.release()
raise
return self
def __exit__(self, exc_type, exc_val, exc_tb):
try:
if self._main is None:
return False
try:
self._main.__spec__ = self._saved_spec
except Exception:
pass
try:
self._main.__file__ = self._saved_file
except Exception:
pass
return False
finally:
_MAIN_MODULE_MUTATION_LOCK.release()
Dispatcher = Callable[[HandleRegistry, Request], Any]
def _enable_faulthandler() -> None:
"""Install Python's ``faulthandler`` so a native crash inside the worker
(Kuzu / LanceDB C++) dumps the Python traceback that triggered it to
stderr before the process dies. Without this, segfaults surface to the
parent as a bare ``exitcode=-11`` with no context about which query or
handler was on the stack.
Set ``SUBPROCESS_FAULTHANDLER_DISABLED=1`` to opt out (e.g. when running
under a debugger that wants to handle SIGSEGV itself).
"""
if os.environ.get("SUBPROCESS_FAULTHANDLER_DISABLED") == "1":
return
try:
import faulthandler
# ``sys.__stderr__`` is the real fd 2 the worker inherited from the
# parent. ``sys.stderr`` may have been wrapped by an embedding host;
# the underlying file descriptor is what container log collectors
# capture, so prefer it.
faulthandler.enable(file=sys.__stderr__ or sys.stderr, all_threads=True)
except Exception:
# Best-effort: if faulthandler can't be enabled (no usable stderr,
# etc.) we just lose this diagnostic. Don't crash the worker over a
# debugging aid.
pass
def run_worker_loop(
dispatch: Dict[int, Dispatcher],
req_q,
resp_q,
init: Optional[Callable[[HandleRegistry], None]] = None,
) -> None:
"""Concurrent worker dispatch.
Async handlers (any handler that returns a coroutine) are launched as
``asyncio.create_task`` so multiple can be in flight at once — bounded
by a semaphore (``SUBPROCESS_WORKER_MAX_INFLIGHT``, default 16) to keep
the worker's memory footprint predictable. Sync handlers continue to
run inline on the event loop thread, preserving today's serialization
for adapters whose underlying library is not thread-safe (e.g. Kuzu).
Responses carry the originating ``request_id`` so the main-process
reader thread can route them to the correct waiter; the protocol
sentinels (READY, SHUTDOWN ack) use ``request_id=0``.
"""
_enable_faulthandler()
# pdeathsig is the authoritative parent-death signal on Linux. Only fall
# back to the portable polling watchdog when the kernel hook is
# unavailable (macOS, Windows) or failed to arm — that watchdog has no
# way to distinguish "legitimate parent happens to be pid 1" from
# "reparented to init", so we avoid running it whenever pdeathsig has
# us covered.
if not set_pdeathsig():
start_parent_liveness_watchdog()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
registry = HandleRegistry()
# ``SUBPROCESS_WORKER_MAX_INFLIGHT`` must be > 0. A zero or negative
# value would create a semaphore that deadlocks on every ``async with sem``.
# This is a load-bearing validation: silent degradation (clamping to 1) would
# hide configuration errors. Callers wanting "no cap" should pass a
# sufficiently large value explicitly (e.g., 10000).
max_inflight = _env_int("SUBPROCESS_WORKER_MAX_INFLIGHT", 16)
if max_inflight <= 0:
raise ValueError(
f"SUBPROCESS_WORKER_MAX_INFLIGHT must be > 0, got {max_inflight}. "
"This controls the worker's concurrent task semaphore; zero or negative "
"values would deadlock all async handlers."
)
sem = asyncio.Semaphore(max_inflight)
try:
if init is not None:
init(registry)
resp_q.put(Response(result=_READY_SENTINEL))
except Exception as e:
resp_q.put(Response(error=traceback.format_exc(), exception=_safe_pickle_exception(e)))
return
pending: "set[asyncio.Task]" = set()
def _emit(rid: int, result: Any) -> None:
if isinstance(result, HandleResult):
resp_q.put(
Response(request_id=rid, result=result.value, new_handle_id=result.handle_id)
)
else:
resp_q.put(Response(request_id=rid, result=result))
def _emit_error(rid: int, e: BaseException) -> None:
resp_q.put(
Response(
request_id=rid,
error=traceback.format_exc(),
exception=_safe_pickle_exception(e),
)
)
async def _run_async(coro, rid: int) -> None:
# The semaphore bounds in-flight async ops on the worker. The cap
# is the whole point of running these in a subprocess — we don't
# want to trade main-process memory pressure for unbounded worker
# memory pressure.
async with sem:
try:
_emit(rid, await coro)
except Exception as e:
_emit_error(rid, e)
async def serve() -> None:
while True:
# Block the request fetch on a thread so the event loop stays
# free to drive in-flight async handlers.
msg = await loop.run_in_executor(None, req_q.get)
if msg == SHUTDOWN:
# Drain in-flight tasks before acking so callers that
# already submitted a request before SHUTDOWN see a real
# response rather than a closed-session error.
if pending:
await asyncio.gather(*pending, return_exceptions=True)
resp_q.put(Response()) # request_id=0 ack
return
rid = getattr(msg, "request_id", 0)
handler = dispatch.get(msg.op)
if handler is None:
resp_q.put(Response(request_id=rid, error=f"Unknown op {msg.op!r}"))
continue
try:
result = handler(registry, msg)
except Exception as e:
_emit_error(rid, e)
continue
if asyncio.iscoroutine(result):
t = asyncio.create_task(_run_async(result, rid))
pending.add(t)
t.add_done_callback(pending.discard)
else:
_emit(rid, result)
try:
loop.run_until_complete(serve())
finally:
try:
loop.close()
except Exception:
pass
def _safe_pickle_exception(e: BaseException) -> Optional[BaseException]:
try:
pickle.dumps(e)
return e
except Exception:
return None
def _op_gc_collect(_registry: HandleRegistry, _req: Request):
"""Worker-side handler for ``OP_GC_COLLECT`` — runs Python's garbage
collector inside the worker process. Included in every adapter worker's
DISPATCH via ``DEFAULT_DISPATCH``.
"""
import gc as _gc
return int(_gc.collect())
DEFAULT_DISPATCH: Dict[int, Dispatcher] = {
OP_GC_COLLECT: _op_gc_collect,
}
# Weak registry of all live sessions. Used by ``collect_garbage_in_all_workers``
# so callers (e.g. benchmark scripts that want to read accurate per-child
# RSS) can trigger ``gc.collect()`` in every worker without threading a
# session reference through the call site.
_all_sessions: "weakref.WeakSet[SubprocessSession]" = weakref.WeakSet()
def collect_garbage_in_all_workers(timeout: float = 5.0) -> int:
"""Send ``OP_GC_COLLECT`` to every live session's worker. Returns the
count of sessions that responded successfully. Best-effort: a session
that's mid-shutdown or crashed is skipped silently.
Intended as a preamble to RSS measurement so subprocess memory numbers
reflect reachable objects only.
"""
collected = 0
# Materialize a snapshot before iterating: ``WeakSet`` entries can
# disappear at any time when the last strong reference is dropped, which
# would raise ``RuntimeError`` mid-iteration. The snapshot holds temporary
# strong refs, but the broad try/except below still covers the narrower
# race where a session is mid-shutdown by the time we call into it.
for session in list(_all_sessions):
try:
if session._closed_event.is_set() or not session._proc.is_alive():
continue
# Goes through the normal id-routed call path. With concurrent
# RPC, this no longer blocks other in-flight calls — it just
# runs alongside them. The worker's GC handler is sync and
# quick, so the practical impact on other ops is negligible.
session.call(Request(op=OP_GC_COLLECT), timeout=timeout)
collected += 1
except Exception:
continue
return collected
def _reap_all_sessions_atexit() -> None:
"""Force-reap any still-live subprocess workers at interpreter exit.
Workers are normally reaped via ``SubprocessSession.__del__ -> shutdown
-> _terminate`` during garbage collection. At interpreter shutdown,
however, GC and ``__del__`` ordering is not guaranteed to run — notably
on Windows with ``spawn`` daemon processes — which can leave a worker
alive and block the parent from exiting until an external timeout (e.g.
the 60-minute CI job timeout on ``Custom Graph Delete``). Registering an
``atexit`` reaper makes teardown deterministic: every live session's
worker is force-terminated (bounded ``_terminate`` kill chain) before the
interpreter exits, regardless of GC timing. Best-effort and idempotent —
``_terminate`` is serialized by ``_terminate_lock`` and safe to call on an
already-closed session.
"""
for session in list(_all_sessions):
try:
session._terminate(timeout=2.0)
except Exception:
pass
atexit.register(_reap_all_sessions_atexit)
class SubprocessSession:
"""Main-process owner of a spawned worker process + its queues.
Concrete subclasses / callers are expected to create the Process and
queues, pass them in, then call ``wait_for_ready()`` before issuing calls.
"""
def __init__(
self,
proc,
req_q,
resp_q,
*,
shutdown_timeout: float = _DEFAULT_SHUTDOWN_TIMEOUT,
init_timeout: float = _DEFAULT_INIT_TIMEOUT,
call_timeout: Optional[float] = _DEFAULT_CALL_TIMEOUT,
respawn_factory: Optional["Callable[[], tuple]"] = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
) -> None:
self._proc = proc
self._req_q = req_q
self._resp_q = resp_q
self._last_accessed_at = time.time()
self._shutdown_timeout = shutdown_timeout
self._init_timeout = init_timeout
# None disables the per-call deadline (not recommended in production:
# a hung native library inside the worker will block the main process
# forever). Override per-session if needed.
self._call_timeout = call_timeout
# Retry / respawn wiring. ``respawn_factory`` is a no-arg callable
# returning a fresh ``(proc, req_q, resp_q)`` triple; typically this
# is supplied by the subclass' ``start()`` classmethod. When
# ``max_retries > 0`` the session will tear down a dead worker,
# spawn a new one, replay any registered ``ReplayStep``s, remap
# handle ids, and retry the failed RPC.
self._respawn_factory = respawn_factory
self._max_retries = max(0, int(max_retries)) if respawn_factory else 0
self._replay_steps: "list[ReplayStep]" = []
self._handle_remap: "dict[int, int]" = {}
# Per-request pending registry: request_id -> concurrent.futures.Future.
# The reader thread sets ``set_result(resp)`` on the matching future;
# both sync and async callers wait on the same primitive (sync via
# ``Future.result(timeout)``, async via ``asyncio.wrap_future``).
# ``Future.set_result`` / ``set_exception`` are thread-safe so the
# reader can resolve them without a ``call_soon_threadsafe`` dance.
self._id_counter = itertools.count(1)
self._pending: "dict[int, concurrent.futures.Future]" = {}
self._pending_lock = threading.Lock()
self._reader_thread: Optional[threading.Thread] = None
# "No more RPCs accepted" flag. An Event (vs the previous plain
# bool) lets the reader poll it cheaply at the top of its loop and
# avoids a separate mutex for the closed-state read/write.
self._closed_event = threading.Event()
# Per-call retry state. The session-level deadline (per-call
# timeout) does NOT mark the session closed — sibling calls may
# still succeed — but consecutive timeouts on the same caller
# almost certainly mean the worker is wedged. After
# ``_TIMEOUT_BEFORE_RESPAWN`` timeouts in a row, the retry loop
# forces a respawn instead of issuing the same RPC against the
# same hung worker.
self._consecutive_timeouts = 0
self._consecutive_timeouts_lock = threading.Lock()
self._terminate_lock = threading.Lock()
self._respawn_lock = threading.Lock()
# Set by ``wait_for_ready`` after the worker emits its READY
# sentinel. Used to gate registration in the global ``_all_sessions``
# set: a session that's still waiting for its sentinel must not be
# visible to ``collect_garbage_in_all_workers`` — a concurrent GC
# sweep would consume the sentinel from ``_resp_q`` and cause
# ``wait_for_ready`` to time out and kill a healthy worker.
self._ready = False
@property
def pid(self) -> Optional[int]:
return self._proc.pid
@property
def last_accessed_at(self) -> float:
return self._last_accessed_at
def touch(self) -> None:
self._last_accessed_at = time.time()
# Backward-compat shim: a handful of external call sites (and the old
# internal logic the reader thread now subsumes) read/write ``_closed``
# as a bool. Route both through the underlying ``Event``.
@property
def _closed(self) -> bool:
return self._closed_event.is_set()
@_closed.setter
def _closed(self, value: bool) -> None:
if value:
self._closed_event.set()
else:
self._closed_event.clear()
def _init_diagnostics(self) -> str:
"""Compact ``pid=… exitcode=… alive=…`` summary of the worker process,
included in init-failure messages so callers can tell a silent
``os._exit(0)`` apart from a timeout where the child is still
running.
Negative exit codes are decoded into the killing signal's name
(``exitcode=-9 (SIGKILL — likely OOM/docker kill)``) so the most
common production failure shapes don't require POSIX trivia to
read the log line.
"""
try:
pid = self._proc.pid
except Exception:
pid = None
try:
exitcode = self._proc.exitcode
except Exception:
exitcode = None
try:
alive = self._proc.is_alive()
except Exception:
alive = None
return f"pid={pid} exitcode={_describe_exitcode(exitcode)} alive={alive}"
def _init_failure_message(self, reason: str) -> str:
return f"Subprocess init {reason} after {self._init_timeout}s ({self._init_diagnostics()})"
# Total time we'll wait for the producer-side feeder thread's bytes to
# cross the pipe after the worker exits. Long enough to absorb scheduler
# jitter under load; short enough that an empty queue resolves to the
# "exited before signalling ready" message quickly.
_POST_DEATH_DRAIN_TIMEOUT = 0.5
_POST_DEATH_DRAIN_POLL = 0.02
def _drain_response_after_death(self) -> Optional[Response]:
"""Try to read a Response that the worker queued just before exiting.
See the caller's comment for the race this addresses: the producer's
feeder thread may not have pushed pickled bytes onto the pipe before
the worker exited, so ``get_nowait()`` immediately after observing
``is_alive() == False`` can return ``Empty`` even when data is in
flight. Poll for a short bounded window so legitimate worker-side
errors aren't lost.
``multiprocessing.Queue`` can also raise ``EOFError`` / ``OSError``
when the underlying pipe is closed or corrupted (e.g. the worker
died mid-``put``). We treat those the same as "no message recovered"
and fall through to the caller's diagnostic path — surfacing a raw
pipe error here would mask the much more useful "exited before
signalling ready" message with ``pid=… exitcode=…`` context.
"""
deadline = time.monotonic() + self._POST_DEATH_DRAIN_TIMEOUT
while True:
try:
return self._resp_q.get_nowait()
except std_queue.Empty:
if time.monotonic() >= deadline:
return None
time.sleep(self._POST_DEATH_DRAIN_POLL)
except (EOFError, OSError):
# Pipe closed/corrupted — no salvageable response.
return None
def wait_for_ready(self) -> None:
# Poll the response queue in short slices so we notice if the child
# dies before producing a READY sentinel. A single blocking ``get``
# with the full init timeout hides that case entirely: the caller
# learns nothing beyond "60s elapsed" even when the child exited 50ms
# in. By interleaving ``is_alive()`` checks we can fail fast and
# surface the worker's pid / exitcode in the error message.
deadline = time.monotonic() + self._init_timeout
poll_interval = min(0.5, self._init_timeout) if self._init_timeout > 0 else 0.5
resp = None
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._terminate()
self._closed_event.set()
raise SubprocessTransportError(self._init_failure_message("timed out"))
try:
resp = self._resp_q.get(timeout=min(poll_interval, remaining))
break
except std_queue.Empty:
if not self._proc.is_alive():
# Drain anything the child managed to queue before dying
# (a Response with .error is common when init raised).
#
# ``multiprocessing.Queue`` uses a background feeder
# thread in the producer to push pickled bytes from an
# in-memory buffer onto the pipe — those bytes may not
# have crossed yet when the worker exits, so a single
# ``get_nowait()`` races the flush and would drop the
# error the child intended to send. Poll for a short
# bounded window (sub-second) before giving up. We
# cannot use ``proc.join()`` to force a flush: the
# feeder thread lives in the *worker* process and is
# already gone once the worker has exited.
drained = self._drain_response_after_death()
if drained is not None:
resp = drained
break
self._terminate()
self._closed_event.set()
raise SubprocessTransportError(
self._init_failure_message("exited before signalling ready")
)
if resp.error:
self._terminate()
self._closed_event.set()
raise SubprocessTransportError(
f"Subprocess init failed ({self._init_diagnostics()}):\n{resp.error}"
)
if resp.result != _READY_SENTINEL:
self._terminate()
self._closed_event.set()
raise SubprocessTransportError(
f"Unexpected subprocess startup response "
f"({self._init_diagnostics()}): {resp.result!r}"
)
# Only register in ``_all_sessions`` after the worker is actually
# ready. Doing this in ``__init__`` exposed the session to
# ``collect_garbage_in_all_workers`` before the READY sentinel was
# consumed; a concurrent GC sweep could steal the sentinel from
# ``_resp_q``, causing ``wait_for_ready`` (this method) to time
# out and kill a healthy worker.
self._ready = True
_all_sessions.add(self)
# The reader is the single consumer of ``_resp_q`` from this point
# on. Starting it BEFORE returning is important: if a caller
# invokes ``call_async`` immediately after ``wait_for_ready``, the
# reader must already be draining responses or the future never
# resolves.
self._start_reader_thread()
def _start_reader_thread(self) -> None:
t = threading.Thread(
target=self._reader_loop,
name=f"subproc-session-reader-{self._proc.pid}",
daemon=True,
)
self._reader_thread = t
t.start()
def _reader_loop(self) -> None:
"""Single consumer of ``self._resp_q``. Routes responses to pending
futures by ``request_id`` and fails every waiter on any path that
ends the session.
**Invariant (load-bearing):** the ``finally`` block always runs
``_fail_all_pending``, so worker crashes, transport-level errors,
and explicit shutdown all propagate ``SubprocessTransportError`` to
every pending future. No path that stops the reader can leak
futures.
"""
transport_err: Optional[BaseException] = None
try:
while not self._closed_event.is_set():
try:
resp = self._resp_q.get(timeout=_PROCESS_CHECK_INTERVAL)
except std_queue.Empty:
if not self._proc.is_alive():
transport_err = SubprocessTransportError(
f"Subprocess exited unexpectedly (exit code {self._proc.exitcode})"
)
break
continue
except (EOFError, OSError, BrokenPipeError) as e:
# Queue's underlying pipe died — treat as transport
# failure rather than letting the reader die silently.
transport_err = SubprocessTransportError(
f"Subprocess response queue broken: {e!r}"
)
break
except (pickle.UnpicklingError, EOFError) as e:
# The worker emitted a value we couldn't deserialize
# (corrupted queue payload, partial write, etc.).
# Treat as a transport failure too — every pending
# caller will be failed via ``_fail_all_pending`` in
# the outer ``finally``, and the session is no longer
# trustworthy for further RPCs.
transport_err = SubprocessTransportError(
f"Subprocess response decode failed: {e!r}"
)
break
except Exception as e:
# Defensive: any unexpected exception inside the
# reader must propagate as a transport error so the
# ``finally`` invariant (drain every pending future)
# still applies. Without this, a fresh exception
# type that slipped past the targeted clauses above
# would bubble out and leave callers waiting on
# futures forever.
transport_err = SubprocessTransportError(
f"Subprocess reader internal error: {e!r}"
)
break
rid = getattr(resp, "request_id", 0)
if rid == 0:
# Protocol sentinel (SHUTDOWN ack, an unsolicited
# response, or a response from the legacy
# un-id'd protocol). Drop it.
continue
with self._pending_lock:
fut = self._pending.pop(rid, None)
if fut is None or fut.done():
# Caller already gave up (timeout / cancel) and
# popped its own entry, or a racing failure already
# resolved the future. Dropping the response here
# is correct.
continue
try:
fut.set_result(resp)
except concurrent.futures.InvalidStateError:
# A near-simultaneous ``_fail_all_pending`` may have
# raced our ``pop`` + ``set_result``. Either resolution
# is acceptable — the caller wakes up either way.
pass
finally:
self._closed_event.set()
self._fail_all_pending(
transport_err or SubprocessTransportError("Subprocess session is closed")
)
def _fail_all_pending(self, err: BaseException) -> None:
"""Atomically drain the pending registry and propagate ``err`` to
every future. Runs from the reader's ``finally`` (the load-bearing
path) and also from explicit lifecycle transitions.
The dict swap-with-``{}`` under ``_pending_lock`` is what gives the
"no leaked futures" guarantee — every entry that existed at the
moment the session closed is failed exactly once.
"""
with self._pending_lock:
pending, self._pending = self._pending, {}
for fut in pending.values():
if fut.done():
continue
try:
fut.set_exception(err)
except concurrent.futures.InvalidStateError:
# Raced with a ``set_result`` from the reader for the same
# id. Harmless — the caller already woke.
pass
def add_replay_step(self, step: ReplayStep) -> None:
"""Register a setup RPC to replay after a respawn. Order is preserved.
Typically called by a proxy in its ``__init__`` right after the initial
setup RPC succeeded — so the step captures the same kwargs used the
first time and propagates the new handle id back via
``apply_new_handle``.
"""
self._replay_steps.append(step)
def remove_replay_step(self, step: ReplayStep) -> None:
try:
self._replay_steps.remove(step)
except ValueError:
pass
def _register_pending(self, rid: int, fut: "concurrent.futures.Future") -> None:
"""Atomically insert ``fut`` into the pending registry, refusing
the insert if the session has been closed.
Called by ``_issue`` / ``_issue_async`` immediately after a
lock-free ``_check_alive`` probe; the in-lock re-check below is
the one that closes the TOCTOU window with a racing close.
"""
with self._pending_lock:
if self._closed_event.is_set():
raise SubprocessTransportError("Subprocess session is closed")
self._pending[rid] = fut
def _issue(self, req: Request, timeout) -> Response:
"""Single-shot sync RPC: register a future, send the request, block
on ``future.result(timeout)``. ``try/finally`` pops the registry
entry on every exit path (success, timeout, exception) so the
reader never has to clean up after sync callers.
"""
deadline, eff = self._resolve_deadline(timeout)
# ``_check_alive`` does the ``proc.is_alive()`` syscall and may set
# ``_closed_event`` if the worker exited; running it outside the
# registry lock keeps that syscall off the hot path. The in-lock
# re-check in ``_register_pending`` catches a close racing this
# caller (TOCTOU-safe under ``_pending_lock``).
self._check_alive()
rid = next(self._id_counter)
fut: concurrent.futures.Future = concurrent.futures.Future()
req_to_send = self._apply_remap(replace(req, request_id=rid))
self._register_pending(rid, fut)
try:
self.touch()
self._req_q.put(req_to_send)
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
resp = fut.result(timeout=remaining)
except concurrent.futures.TimeoutError:
raise TimeoutError(f"Subprocess call exceeded {eff}s deadline")
# The worker responded — even an application error proves it
# is still serving. Reset the consecutive-timeout counter
# BEFORE ``_handle_response`` so a worker-side raise doesn't
# leave the counter pointing at a wedged-worker state and
# trigger a spurious respawn on the next per-call timeout.
self._reset_timeout_counter()
return self._handle_response(resp)
finally:
with self._pending_lock:
self._pending.pop(rid, None)
async def _issue_async(self, req: Request, timeout) -> Response:
"""Async counterpart of ``_issue``. Wraps the ``concurrent.futures.Future``
for the current event loop and awaits with ``asyncio.wait_for`` so
per-call deadlines work uniformly across sync / async callers.
"""
deadline, eff = self._resolve_deadline(timeout)
# See ``_issue`` for why ``_check_alive`` runs outside the lock.
self._check_alive()
rid = next(self._id_counter)
fut: concurrent.futures.Future = concurrent.futures.Future()
req_to_send = self._apply_remap(replace(req, request_id=rid))
self._register_pending(rid, fut)
try:
self.touch()
self._req_q.put(req_to_send)
# ``wrap_future`` auto-detects the running loop. Passing
# ``loop=`` explicitly is deprecated (and removed in newer
# Pythons) so we let asyncio infer it from the running
# context here.
aio_fut = asyncio.wrap_future(fut)
timeout_s = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
resp = await asyncio.wait_for(aio_fut, timeout=timeout_s)
except asyncio.TimeoutError:
# Cancel the wrapped concurrent future so a late response
# from the worker for this id is dropped by the reader.
if not fut.done():
fut.cancel()
raise TimeoutError(f"Subprocess call_async exceeded {eff}s deadline")
# Worker responded — reset the streak (see ``_issue`` for why
# this is unconditional, not gated on success vs app error).
self._reset_timeout_counter()
return self._handle_response(resp)
finally:
with self._pending_lock:
self._pending.pop(rid, None)
def _record_timeout(self) -> int:
"""Bump the session-wide consecutive-timeout counter and return the
new value. Reset to zero on any successful call.
"""
with self._consecutive_timeouts_lock:
self._consecutive_timeouts += 1
return self._consecutive_timeouts
def _reset_timeout_counter(self) -> None:
with self._consecutive_timeouts_lock:
self._consecutive_timeouts = 0
def _force_respawn_after_timeouts(self) -> None:
"""Mark the session closed so the next ``_respawn`` actually
respawns instead of early-returning. Called after
``_TIMEOUT_BEFORE_RESPAWN`` consecutive per-call timeouts on the
assumption the worker is wedged.
Without this hook, the retry loop would keep issuing RPCs against
the same hung worker because ``_respawn`` short-circuits when the
process is still ``is_alive()``.
"""
self._closed_event.set()
self._reset_timeout_counter()
def call(self, req: Request, timeout: Optional[float] = ...) -> Response:
"""Blocking synchronous call. Retries transport failures up to
``max_retries`` times (respawning + replaying setup steps between
attempts). Application errors raised inside the worker are NOT
retried — they would fail the same way on any new subprocess.
Per-call timeouts no longer mark the session closed (sibling
in-flight calls keep working), but ``_TIMEOUT_BEFORE_RESPAWN``
consecutive timeouts force a respawn — the worker is almost
certainly wedged at that point and issuing the same RPC against
it would just time out again.
"""
attempts_left = self._max_retries
while True:
try:
return self._issue(req, timeout)
except TimeoutError:
if attempts_left <= 0 or not self._can_respawn():
raise
if self._record_timeout() >= _TIMEOUT_BEFORE_RESPAWN:
self._force_respawn_after_timeouts()
attempts_left -= 1
self._respawn()
except SubprocessTransportError:
if attempts_left <= 0 or not self._can_respawn():
raise
attempts_left -= 1
self._respawn()
async def call_async(self, req: Request, timeout: Optional[float] = ...) -> Response:
"""Async counterpart of ``call`` with identical retry semantics.
Concurrent ``call_async`` invocations no longer queue at a session
lock: per-request ids let the reader thread route responses, and
the per-call ``concurrent.futures.Future`` carries the response or
the close-error to the awaiting coroutine.
"""
loop = asyncio.get_running_loop()
attempts_left = self._max_retries
while True:
try:
return await self._issue_async(req, timeout)
except TimeoutError:
if attempts_left <= 0 or not self._can_respawn():
raise
if self._record_timeout() >= _TIMEOUT_BEFORE_RESPAWN:
self._force_respawn_after_timeouts()
attempts_left -= 1
await loop.run_in_executor(None, self._respawn)
except SubprocessTransportError:
if attempts_left <= 0 or not self._can_respawn():
raise
attempts_left -= 1
await loop.run_in_executor(None, self._respawn)
def _can_respawn(self) -> bool:
return self._respawn_factory is not None
def _apply_remap(self, req: Request) -> Request:
"""After a successful respawn, previously-allocated handle ids are
dead. Any in-flight Request still referring to an old id gets
rewritten to the new id recorded in ``_handle_remap``.
"""
if not self._handle_remap or req.handle_id is None:
return req
new_id = self._handle_remap.get(req.handle_id)
if new_id is None:
return req
return replace(req, handle_id=new_id)
def _resolve_deadline(self, timeout) -> tuple:
"""Collapse (``...``, ``None``, float) into ``(deadline, effective_timeout)``.
``deadline`` is an absolute monotonic-ish timestamp (``None`` = no
deadline). ``effective_timeout`` is the numeric timeout actually in
effect for this call after collapsing the ``...`` sentinel to the
session default; it's used for diagnostic error messages so timeout
errors report the value the caller actually got, not the session
default.
"""
if timeout is ...:
timeout = self._call_timeout
if timeout is None:
return (None, None)
# ``time.monotonic()`` (not ``time.time()``) for deadline math: the
# wall clock can jump backward or forward via NTP corrections, VM
# suspend/resume, or clock-skew fixes, which would either fire
# spurious ``TimeoutError`` (clock jumped forward) or hang past the
# intended deadline (clock jumped backward). Monotonic time only
# ever increases.
return (time.monotonic() + float(timeout), float(timeout))
def _handle_response(self, resp: Response) -> Response:
if resp.exception is not None:
# The worker also formatted the remote traceback into
# ``resp.error``; without preserving it, the local traceback
# only shows the ``call()`` site and the worker-side stack
# frames are lost. ``add_note`` (PEP 678, Python 3.11+) is
# the lightest way to attach the remote stack without
# rewriting the exception type or chaining. Older Pythons
# silently skip this — the caller still gets the original
# exception, just without the remote traceback.
if resp.error and hasattr(resp.exception, "add_note"):
resp.exception.add_note(f"Remote subprocess traceback:\n{resp.error}")
raise resp.exception
if resp.error:
raise RuntimeError(resp.error)
return resp
def _check_alive(self) -> None:
if self._closed_event.is_set():
raise SubprocessTransportError("Subprocess session is closed")
if not self._proc.is_alive():
self._closed_event.set()
raise SubprocessTransportError(
f"Subprocess exited unexpectedly (exit code {self._proc.exitcode})"
)
def _respawn(self) -> None:
"""Tear down the dead worker, spawn a fresh one, and replay the
registered setup steps. Remaps any allocated handle ids so in-flight
requests targeting the old handles are rewritten to the new ones.
Serialized by ``_respawn_lock`` so concurrent retries coalesce onto
one new process rather than spawning N new workers. The old reader
thread is stopped (its ``finally`` block fails any still-pending
futures); the new one is started after replay completes.
"""
if self._respawn_factory is None:
raise SubprocessTransportError(
"Subprocess session has no respawn factory; retry is disabled"
)
with self._respawn_lock:
# Another concurrent retry may have already respawned successfully
# between the moment the current caller's RPC failed and us
# acquiring the lock. If the process is alive again, bail out —
# the caller's outer retry loop will just try the RPC again.
if not self._closed_event.is_set() and self._proc.is_alive():
return
# Stop the old reader if still running; its ``finally`` block
# fails any futures that hadn't been resolved yet. After
# ``_fail_all_pending`` runs from inside the reader, no new
# callers can add to ``_pending`` until we clear the closed
# flag below (``_check_alive`` rejects under ``_pending_lock``).
self._closed_event.set()
if self._reader_thread is not None and self._reader_thread.is_alive():
self._reader_thread.join(timeout=self._shutdown_timeout)
if self._reader_thread.is_alive():
# The old reader is still running. If we proceeded
# and reassigned ``self._resp_q`` below, the old
# reader's next iteration would pull from the NEW
# queue (it dereferences ``self._resp_q`` per loop),
# producing two consumers on the same queue and
# nondeterministic response routing. Bail out
# instead — the session is now bricked and callers
# will see this as a transport error.
raise SubprocessTransportError(
f"Reader thread did not stop within "
f"{self._shutdown_timeout}s; refusing to respawn"
)
self._reader_thread = None
self._terminate()
new_proc, new_req_q, new_resp_q = self._respawn_factory()
self._proc = new_proc
self._req_q = new_req_q
self._resp_q = new_resp_q
# ``_closed_event`` stays set through the entire ready +
# replay sequence. New callers (including non-retry ones)
# see ``_closed_event.is_set()`` in ``_register_pending`` and
# bail with ``SubprocessTransportError`` — they retry via the
# outer loop and end up parked on ``_respawn_lock``. Without
# this, a caller could insert into ``_pending`` after the old
# reader exited but before the new reader started, then
# block on ``fut.result()`` forever.
#
# Replay calls use ``_raw_call_locked`` which bypasses
# ``_pending`` and reads directly from ``_resp_q`` — so
# keeping the close flag set does not block replay itself.
self._ready = False
try:
resp = self._resp_q.get(timeout=self._init_timeout)
except std_queue.Empty:
self._terminate()
raise SubprocessTransportError(
f"Respawn init timed out after {self._init_timeout}s"
)
if resp.error:
self._terminate()
raise SubprocessTransportError(f"Respawn init failed:\n{resp.error}")
if resp.result != _READY_SENTINEL:
self._terminate()
raise SubprocessTransportError(
f"Unexpected respawn startup response: {resp.result!r}"
)
self._ready = True
# Replay setup in registration order BEFORE starting the reader.
# Replay calls read responses directly from ``_resp_q``; running
# them with the reader live would race the reader for those
# messages.
new_remap: "dict[int, int]" = {}
for step in list(self._replay_steps):
req = step.make_request()
# Rewrite any handle_id that was already remapped in this
# replay pass (e.g. OP_DB_INIT after OP_OPEN_DATABASE).
if new_remap and req.handle_id in new_remap:
req = replace(req, handle_id=new_remap[req.handle_id])
resp = self._raw_call_locked(req)
if resp.new_handle_id is not None and step.apply_new_handle is not None:
old_id = step.apply_new_handle(resp.new_handle_id)
if old_id is not None:
new_remap[old_id] = resp.new_handle_id
# Compose this respawn's remap with the accumulated one so that
# in-flight Requests carrying a handle id from *any* previous
# incarnation (not just the immediately preceding one) get
# rewritten to the current generation's id.
#
# Illustration across two respawns:
# prior self._handle_remap = {original -> id_A} (after respawn #1)
# this respawn's new_remap = {id_A -> id_B} (replay promoted id_A to id_B)
# We want the composed remap to route both ``original`` and
# ``id_A`` to ``id_B``. Overwriting with ``new_remap`` alone would
# strand ``original`` — it would keep pointing at the now-dead
# ``id_A``.
composed: "dict[int, int]" = {}
for orig, intermediate in self._handle_remap.items():
composed[orig] = new_remap.get(intermediate, intermediate)
for old, new in new_remap.items():
composed.setdefault(old, new)
self._handle_remap = composed
# Replay finished, remap is final, reader can take over.
# Clear the close flag and start the reader in that order so
# any caller that races past the in-lock check finds a
# running reader.
self._closed_event.clear()
self._start_reader_thread()
def _raw_call_locked(self, req: Request) -> Response:
"""Single-shot RPC for use during ``_respawn`` replay only.
Replay runs while the reader thread is NOT live (we stopped the old
one before reseting state, and we haven't started the new one yet),
so we can read responses directly from ``_resp_q``. The
``request_id`` check below catches a stale response lingering
from the previous worker — that response would never line up with
the freshly-spawned worker's reply and silently corrupt replay.
It's an ``if/raise`` (not ``assert``) so it stays in place under
``python -O`` / ``PYTHONOPTIMIZE``, where ``assert`` is stripped.
"""
rid = next(self._id_counter)
req_to_send = replace(req, request_id=rid)
self.touch()
self._req_q.put(req_to_send)
_deadline, effective_timeout = self._resolve_deadline(...)
# ``effective_timeout is None`` means the caller explicitly
# disabled per-call deadlines (``SUBPROCESS_CALL_TIMEOUT<=0``).
# Honor that here by issuing a blocking ``get()`` rather than
# inventing an arbitrary fallback; otherwise benchmark runs that
# disable the timeout would still see a 60s ceiling on replay.
if effective_timeout is None:
resp = self._resp_q.get()
else:
try:
resp = self._resp_q.get(timeout=effective_timeout)
except std_queue.Empty:
raise TimeoutError(f"Subprocess replay call exceeded {effective_timeout}s deadline")
if resp.request_id != rid:
raise SubprocessTransportError(
f"Replay response id mismatch: expected {rid}, got {resp.request_id!r}"
)
return self._handle_response(resp)
def shutdown(self, timeout: Optional[float] = None) -> None:
"""Tear down the worker process. Always reaches ``_terminate``, even
if the session is already marked closed — a timeout or crash flips
the closed flag without reaping the child, and a second call would
orphan a still-alive process holding file locks.
``timeout`` (or ``self._shutdown_timeout`` when ``None``) is the
*shutdown* budget. The reader thread is signaled to exit, joined,
and its ``finally`` block fails any still-pending futures. Then we
send the graceful SHUTDOWN sentinel (if the worker is still alive)
and force-terminate.
"""
t = timeout if timeout is not None else self._shutdown_timeout
already_closed = self._closed_event.is_set()
# Set closed under the registry lock so any caller about to add a
# new entry sees the closed state and bails before inserting.
with self._pending_lock:
self._closed_event.set()
# Try to stop the reader cleanly. If it joins within ``t`` we
# own ``_resp_q`` exclusively and can drain the SHUTDOWN ack
# ourselves. If it doesn't, the reader is still alive — we let
# ``_terminate`` break the queue pipes below, which forces the
# reader's ``get()`` to raise (EOFError / OSError caught by the
# reader loop's exception clauses) and then we do a second
# join. Importantly: we do NOT reassign ``_resp_q``, so an
# in-flight reader can't end up as a second consumer of a
# fresh queue (contrast ``_respawn``, which DOES reassign and
# therefore raises if the join times out).
reader_owned = False
if self._reader_thread is not None and self._reader_thread.is_alive():
self._reader_thread.join(timeout=t)
reader_owned = not self._reader_thread.is_alive()
else:
reader_owned = True
if not already_closed and self._proc.is_alive():
try:
self._req_q.put(SHUTDOWN)
# Only drain the ack here if the reader is confirmed
# gone. With a still-running reader we'd race it for
# the message; it'll drop the ack (request_id=0) on
# its own.
if reader_owned:
try:
self._resp_q.get(timeout=t)
except std_queue.Empty:
pass
except Exception:
pass
# ``_terminate`` reaps the worker process, which closes the
# multiprocessing queue's underlying pipe; that unblocks any
# reader still parked in ``get()``.
self._terminate(timeout=t)
# Second join opportunity for a reader that was stuck inside
# ``get()`` — the queue pipe just died, so its exception path
# should run and the thread should exit promptly. If it
# doesn't, the thread is a daemon and the process will reap
# it at exit; nothing more we can do without unsafe forcible
# cancellation.
if self._reader_thread is not None and self._reader_thread.is_alive():
self._reader_thread.join(timeout=t)
self._reader_thread = None
# Safety net: if the reader didn't drain pending (e.g. it was
# never started because ``wait_for_ready`` failed, or it was
# still wedged above), do it now.
self._fail_all_pending(SubprocessTransportError("Subprocess session is closed"))
# Break the ref cycle: each ReplayStep's ``make_request`` is a
# closure that captures a proxy object, and the proxy holds
# ``self._session`` = this session. Without clearing the list here,
# evicted sessions stay alive via that cycle until the gc gets
# around to it (and ``__del__`` used to block cycle collection
# pre-PEP-442). Clearing also lets the child's worker-side state —
# held indirectly through the proxies — become collectable sooner.
self._replay_steps.clear()
self._handle_remap.clear()
def _terminate(self, timeout: float = 2.0) -> None:
"""Force-terminate the worker process and wait until it has actually
exited. Idempotent and serialized by ``self._terminate_lock`` so
concurrent ``shutdown`` / ``__del__`` / ``clean`` paths can't race each
other.
The escalating join/terminate/kill chain is followed by a bounded poll
that does not return while ``is_alive()`` is still True. This matters
for the on-disk file lock held by DB workers (Ladybug/LanceDB): the OS
releases a process's file locks only once the process is truly gone, so
a caller that re-opens the same DB path right after ``shutdown()``
returns would hit "Could not set lock on file" if we returned while the
child were still alive. ``join(timeout)`` alone can return early (e.g.
right after ``kill()`` the SIGKILL may not have been delivered yet), so
we poll ``is_alive()`` until the process is reaped, capped so a wedged
kernel state can't hang teardown forever.
"""
with self._terminate_lock:
try:
self._proc.join(timeout=timeout)
if self._proc.is_alive():
self._proc.terminate()
self._proc.join(timeout=timeout)
if self._proc.is_alive():
self._proc.kill()
self._proc.join(timeout=timeout)
# Final wait for true exit: after SIGKILL the process should die
# promptly, but ``join`` above may have timed out before the
# kernel reaped it. Poll up to a generous cap so we don't return
# while the child still holds its file lock.
deadline = time.monotonic() + max(timeout, 5.0)
while self._proc.is_alive() and time.monotonic() < deadline:
self._proc.join(timeout=0.05)
except Exception:
pass
def __del__(self) -> None:
try:
self.shutdown(timeout=2.0)
except Exception:
pass
def get_process_rss_bytes(pid: int) -> int:
"""Return RSS of a process in bytes. Reads /proc on Linux, falls back to
``ps`` elsewhere. Returns 0 if the process is gone or unreachable."""
if pid is None:
return 0
try:
with open(f"/proc/{pid}/statm") as f:
rss_pages = int(f.read().split()[1])
return rss_pages * os.sysconf("SC_PAGESIZE")
except OSError:
pass
try:
out = subprocess.check_output(
["ps", "-o", "rss=", "-p", str(pid)], text=True, stderr=subprocess.DEVNULL
)
return int(out.strip()) * 1024
except Exception:
return 0