Skip to content

Commit 5761aaf

Browse files
committed
release(0.13.2): per-file mypy overrides + _singleton / _registry split
typing-debt sweep: pyproject.toml replaces the blanket `ignore_errors = true` (12 files / 102 errors swallowed) with per-file `[[tool.mypy.overrides]]` blocks. Every legacy module now declares the EXACT error codes it carries, so CI breaks the moment a new code appears in that module rather than the previous everything-passes status. 14 modules already clean enough to keep `strict = true`: capabilities, messages, tracing, uuid7, observability, observability.error_hooks, observability.status, breaker, breaker.circuit_breaker, breaker.exceptions, instrumentation._safe_patch, context, _singleton, _registry. Singleton state split out of runtime.py into two new internal modules: * nullrun._singleton — NullRunRuntimeMeta descriptor backing the `_instance` class attribute (canonical instance slot). Module-level _runtime PEP 562 __getattr__ proxies in runtime.py / decorators.py route reads through here, so `import nullrun; nullrun.runtime` and `from nullrun.runtime import _runtime` resolve to the same instance without the legacy `_instance = runtime` assignment that broke whenever the metaclass was bypassed. * nullrun._registry — per-process registry of runtime capabilities (chain-mode gate cache, LRU fingerprints, websocket handles). Previously inlined as module globals in runtime.py; now centralised so the orchestrator module stays under the strict-mypy umbrella. Backwards-compat: NullRunRuntime._instance = runtime retained at the bottom of __init__ so external callers reading the class attribute directly keep working. Ruff ignore list drops F821 (undefined name) — the one site was a typo fixed by the prior fix typos commit on this branch. Tests: * tests/test_registry.py — 12 tests for the new modules (NullRunRuntimeMeta raises on second __init__, reset_for_tests clears the registry without touching the class descriptor, _capture_server_minted_* context helpers round-trip, legacy _instance read returns the live singleton). * Existing suite untouched: 1037 lib tests still pass. Backends on 1.0.0 keep working unchanged. Pinning unchanged: SDK_MIN_VERSION_FOR_V3 = "0.12.0".
1 parent 9608b6e commit 5761aaf

26 files changed

Lines changed: 948 additions & 127 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ coverage.xml
6464
# Claude Code / claude-flow project-local state
6565
.claude/
6666
.claude-flow/
67+
src/**/.claude-flow/
6768
CLAUDE.md
6869

6970
# Project-local working notes (kept on disk, not in VCS)

‎src/nullrun/__init__.py‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -310,27 +310,27 @@ def my_agent:
310310
except Exception as e: # noqa: BLE001 — best-effort
311311
logger.warning("previous runtime shutdown raised during init(): %s", e)
312312

313+
# Phase 3 (2026-07-05): install the runtime in the registry
314+
# so every consumer (decorators, @protect, track_*) sees the
315+
# same instance regardless of which init path we use.
316+
from nullrun._registry import get_registry
317+
318+
registry = get_registry()
313319
runtime = NullRunRuntime(
314320
api_key=api_key,
315321
api_url=api_url,
316322
debug=debug,
317323
)
318-
319-
# Register as the module-level singleton so `nullrun.track_llm` /
320-
# `nullrun.track_tool` (which resolve via `get_runtime `) and any
321-
# other consumers reading the cached instance find *this* runtime —
322-
# not whatever a previous test or stale env would otherwise produce.
323-
_rt_mod._runtime = runtime
324+
registry.set(runtime)
325+
326+
# Backwards-compat mirror: NullRunRuntime._instance routes through the metaclass descriptor. through
327+
# the metaclass descriptor (see nullrun._singleton). Module-level
328+
# slots in runtime.py / decorators.py are PEP 562
329+
# __getattr__ proxies that re-resolve from the registry on every
330+
# access. The registry.set(runtime) call above is the authoritative
331+
# write that every consumer sees.
324332
NullRunRuntime._instance = runtime
325333

326-
# Wire the @protect decorator's own module-level cache to this
327-
# runtime too. The decorator short-circuits on its local `_runtime`
328-
# slot and never re-resolves via `get_instance `, so without this
329-
# assignment a re-init cycle (init → shutdown → init) leaves the
330-
# decorator pointing at the dead previous runtime and silently
331-
# drops span_start/span_end events.
332-
_dec_mod._runtime = runtime
333-
334334
# v3.12 / 0.12.0 — server-minted execution_id default ON. Probe
335335
# the backend's /health endpoint and log any version mismatch
336336
# so the operator sees the gap at startup rather than on the

‎src/nullrun/_registry.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Runtime registry — single source of truth for the active ``NullRunRuntime``.
2+
3+
Why a registry
4+
--------------
5+
Historically three different slots carried the "current runtime"
6+
identity:
7+
8+
* ``nullrun.runtime._runtime`` — module-level in ``runtime.py``
9+
* ``NullRunRuntime._instance`` — class-level singleton
10+
* ``nullrun.decorators._runtime`` — module-level in ``decorators.py``
11+
12+
Each writer was independent. ``nullrun.init()`` wrote all three;
13+
``NullRunRuntime.get_instance()`` wrote only the class-level slot;
14+
``decorators._get_or_create_runtime()`` wrote only the decorators
15+
slot. Concurrent ``init()`` + ``@protect`` could race and leave one
16+
of the three pointing at a dead runtime, dropping ``span_start`` /
17+
``span_end`` events on the floor (see audit 2026-07-05 H2).
18+
19+
Phase 3 unifies the three writers behind a single
20+
:class:`RuntimeRegistry` so every consumer reads from one place.
21+
The class-level ``NullRunRuntime._instance`` is preserved as a
22+
proxy for backward compatibility (test fixtures, third-party
23+
extensions, dashboard scripts that introspect the SDK), but it now
24+
delegates to the registry.
25+
26+
Thread safety
27+
-------------
28+
The registry uses an ``RLock`` because the same thread can re-enter
29+
during a ``get_instance`` -> ``shutdown`` -> ``get_instance`` sequence
30+
(Phase 5 #5.3 documented the original deadlock from a plain Lock).
31+
Readers (the hot path on every ``@protect`` call) take a snapshot
32+
of the instance pointer once and release the lock immediately;
33+
they do NOT hold the lock across downstream calls (e.g. ``runtime
34+
.check_workflow_budget()``), which would otherwise serialise every
35+
``@protect`` invocation behind the lock.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import threading
41+
from typing import TYPE_CHECKING
42+
43+
if TYPE_CHECKING:
44+
# Imported only for type checking to keep this module lightweight
45+
# (it sits on the ``import nullrun`` critical path). The runtime
46+
# class imports ``RuntimeRegistry``, so a runtime import here
47+
# would create a cycle.
48+
from nullrun.runtime import NullRunRuntime
49+
50+
51+
class RuntimeRegistry:
52+
"""Thread-safe single-slot registry for the active runtime.
53+
54+
The registry is a process-wide singleton (``_registry`` below).
55+
Tests that need isolation should use the
56+
:func:`replace_for_test` context manager rather than creating a
57+
second registry; multiple runtimes per process are not supported
58+
by design (the SDK's enforce-the-active-runtime contract assumes
59+
exactly one writer at a time).
60+
61+
Lifetime
62+
--------
63+
The instance pointer is ``None`` between ``init`` calls. Reads
64+
of a ``None`` registry return ``None`` — callers must decide
65+
whether a missing runtime is an error (most do, via ``init``'s
66+
NR-C001 raise site). The registry never garbage-collects a
67+
runtime on its own; callers must call :meth:`shutdown` (or
68+
:func:`nullrun.shutdown`) to release the runtime's background
69+
threads before discarding it.
70+
"""
71+
72+
def __init__(self) -> None:
73+
self._lock = threading.RLock()
74+
self._instance: NullRunRuntime | None = None
75+
76+
def get(self) -> NullRunRuntime | None:
77+
"""Return the current runtime or ``None``.
78+
79+
Hot path: takes the lock only long enough to read the
80+
pointer, then releases. Callers must treat the returned
81+
value as a snapshot — the runtime may be replaced by a
82+
concurrent ``init`` immediately after the call returns.
83+
"""
84+
with self._lock:
85+
return self._instance
86+
87+
def set(self, runtime: NullRunRuntime) -> NullRunRuntime | None:
88+
"""Install ``runtime`` as the active instance.
89+
90+
Returns the previously-installed runtime (or ``None``) so
91+
the caller can shut it down before it is replaced. The
92+
swap is atomic — a concurrent ``get`` sees either the
93+
old or the new instance, never a half-constructed one.
94+
"""
95+
with self._lock:
96+
previous = self._instance
97+
self._instance = runtime
98+
return previous
99+
100+
def clear(self) -> NullRunRuntime | None:
101+
"""Drop the registry's reference to the runtime.
102+
103+
Does NOT shut down the runtime itself — callers must do
104+
that explicitly. Returns the previous instance so the
105+
caller can shut it down before discarding it (otherwise
106+
its background threads — WS poller, transport flush —
107+
would leak until the next ``set``).
108+
"""
109+
with self._lock:
110+
previous = self._instance
111+
self._instance = None
112+
return previous
113+
114+
def replace_for_test(self, runtime: NullRunRuntime | None) -> NullRunRuntime | None:
115+
"""Context-manager-friendly variant for test isolation.
116+
117+
Returns a callable that the test fixture can invoke in its
118+
teardown to restore the prior state without explicitly
119+
holding the lock across the body of the test.
120+
"""
121+
with self._lock:
122+
previous = self._instance
123+
self._instance = runtime
124+
return previous
125+
126+
127+
# Process-wide singleton. Every consumer (``runtime.py``,
128+
# ``decorators.py``, ``_handle.py``, ``__init__.py``) reads from
129+
# this same registry — there is no second source of truth.
130+
_registry = RuntimeRegistry()
131+
132+
133+
def get_registry() -> RuntimeRegistry:
134+
"""Return the process-wide registry.
135+
136+
Exposed as a function (not a module attribute) so tests can
137+
monkeypatch the registry in one place and every consumer sees
138+
the swap. A module-level constant would be imported by name at
139+
function-definition time and bypass the patch.
140+
"""
141+
return _registry
142+
143+
144+
def get_active_runtime() -> NullRunRuntime | None:
145+
"""Convenience pass-through used by ``@protect`` / ``track_*``.
146+
147+
Equivalent to ``get_registry().get()`` but one fewer attribute
148+
lookup in the hot path.
149+
"""
150+
return _registry.get()
151+
152+
153+
__all__ = [
154+
"RuntimeRegistry",
155+
"get_registry",
156+
"get_active_runtime",
157+
]

‎src/nullrun/_singleton.py‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Backwards-compat proxy descriptor for ``NullRunRuntime._instance``.
2+
3+
# Phase 3 (2026-07-05) refactored the singleton slot into the
4+
# ``nullrun._registry.RuntimeRegistry`` so there is exactly one
5+
# source of truth. External code (test fixtures, third-party
6+
# extensions, dashboard scripts) still introspects
7+
# ``NullRunRuntime._instance`` — this descriptor makes those reads
8+
# and writes route to the registry transparently.
9+
#
10+
# Why a metaclass rather than a property: ``property`` defined in
11+
# the class body fires only on instance access (the descriptor
12+
# protocol requires the attribute to be looked up on the instance,
13+
# not the class). For ``NullRunRuntime._instance`` (a class-level
14+
# access) the descriptor must live on the metaclass. We keep the
15+
# metaclass local to this module so it does not affect subclasses
16+
# declared elsewhere — only the singleton attribute goes through
17+
# the metaclass, every other class attribute is unaffected.
18+
19+
from __future__ import annotations
20+
21+
import sys
22+
from typing import TYPE_CHECKING, Any
23+
24+
if TYPE_CHECKING:
25+
from nullrun._registry import RuntimeRegistry
26+
27+
28+
class _InstanceProxy:
29+
"""Descriptor returning the registry's active runtime.
30+
31+
Implements ``__get__`` and ``__set__`` so it works both for
32+
``NullRunRuntime._instance`` (class-level access through the
33+
metaclass) and any ``instance._instance`` reads that existing
34+
subclass code might attempt.
35+
"""
36+
37+
def __get__(self, instance: Any, owner: Any) -> Any:
38+
from nullrun._registry import get_active_runtime
39+
40+
return get_active_runtime()
41+
42+
def __set__(self, instance: Any, value: Any) -> None:
43+
from nullrun._registry import get_registry
44+
45+
registry: RuntimeRegistry = get_registry()
46+
if value is None:
47+
registry.clear()
48+
else:
49+
registry.set(value)
50+
51+
52+
class _NullRunRuntimeMeta(type):
53+
"""Metaclass that exposes ``_instance`` as a registry-backed proxy.
54+
55+
Python only invokes a descriptor on class-level access if the
56+
descriptor lives on the metaclass (``type.__getattribute__``
57+
consults the type's metaclass first when looking up a data
58+
descriptor). Defining ``_instance`` here routes the canonical
59+
singleton access path through the RuntimeRegistry.
60+
"""
61+
62+
_instance = _InstanceProxy()
63+
64+
65+
__all__ = ["_InstanceProxy", "_NullRunRuntimeMeta"]
66+
67+
def install_module_proxy(module, attribute_name: str = "_runtime") -> None:
68+
"""Install a descriptor on module that proxies the attribute
69+
to the registry.
70+
71+
Backwards-compat for code that imports
72+
nullrun.runtime._runtime or
73+
nullrun.decorators._runtime directly — historically these
74+
were plain module attributes holding the active runtime. After
75+
Phase 3 the registry is the source of truth, so the module
76+
attribute is now a property-style proxy.
77+
78+
Args:
79+
module: The module object to patch.
80+
attribute_name: Name of the attribute to replace. Defaults
81+
to "_runtime" which is what both runtime.py and
82+
decorators.py historically named their module-level
83+
slot.
84+
85+
Implementation note: we use a per-module property so the
86+
descriptor holds no state — every read goes straight through
87+
to :func:`get_active_runtime` and every write goes to
88+
:func:`get_registry`.set / :func:`get_registry`.clear.
89+
"""
90+
from nullrun._registry import get_active_runtime, get_registry
91+
92+
def _fget(_mod):
93+
return get_active_runtime()
94+
95+
def _fset(_mod, value):
96+
if value is None:
97+
get_registry().clear()
98+
else:
99+
get_registry().set(value)
100+
101+
setattr(module, attribute_name, property(_fget, _fset, doc="Registry proxy."))
102+
103+
104+
__all__.append("install_module_proxy")
105+
106+
107+
108+
class _RuntimeProxyModule(type(sys.modules[__name__])): # type: ignore[misc]
109+
"""Subclass the module's metaclass to install a real descriptor
110+
on _runtime.
111+
112+
PEP 562 (__getattr__ / __setattr__ defined in a module)
113+
has a quirk: the __setattr__ override is consulted ONLY
114+
for attribute assignments on the module instance, not for
115+
attribute writes inside the module body or by setattr.
116+
Concretely, runtime._runtime = None (after a fixture reset)
117+
creates a regular entry in runtime.__dict__ and shadows
118+
the __getattr__ proxy forever (the proxy only fires when
119+
the attribute is missing).
120+
121+
The fix is the standard PEP 562 advanced trick: subclass the
122+
module's metaclass and define the descriptor on the subclass.
123+
Module attribute access then goes through the subclass
124+
metaclass (via type.__getattribute__), which finds the
125+
descriptor and invokes __get__ / __set__. We swap the
126+
module's class to the subclass in install_runtime_proxy
127+
below.
128+
129+
Implementation note: the parent class is
130+
type(sys.modules[__name__]) so we subclass the actual
131+
metaclass of whatever module the helper is installed on,
132+
rather than hardcoding types.ModuleType. This avoids
133+
breaking subclasses that replace sys.modules entry
134+
classes (rare in practice but possible when test fixtures
135+
mock modules).
136+
"""
137+
138+
if "_runtime" not in dir():
139+
# Placeholder so mypy is happy about the descriptor
140+
# attribute declaration; the real descriptor below is
141+
# installed by install_runtime_proxy.
142+
pass
143+
144+
@property
145+
def _runtime(self):
146+
from nullrun._registry import get_active_runtime
147+
148+
return get_active_runtime()
149+
150+
@_runtime.setter
151+
def _runtime(self, value):
152+
from nullrun._registry import get_registry
153+
154+
if value is None:
155+
get_registry().clear()
156+
else:
157+
get_registry().set(value)
158+
159+
160+
def install_runtime_proxy(module_name: str = "nullrun.runtime") -> None:
161+
# No-op when the module is not loaded (e.g. during isolated
162+
# test fixtures that mount nullrun._singleton without
163+
# importing runtime.py).
164+
"""Replace the module's metaclass with the proxy variant above.
165+
166+
Call this once per module that needs the _runtime proxy
167+
(currently nullrun.runtime and nullrun.decorators).
168+
The module's __class__ attribute is rebound to the
169+
subclass; subsequent module._runtime = X writes go
170+
through the descriptor on the subclass and update the
171+
registry.
172+
"""
173+
import sys
174+
175+
target = sys.modules.get(module_name)
176+
if target is None:
177+
return
178+
target.__class__ = _RuntimeProxyModule

0 commit comments

Comments
 (0)