Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
244 lines
9.6 KiB
Python
244 lines
9.6 KiB
Python
"""Phase 07.2-06 R4 / A4 / D7.2-14 regression fence — no bare sync calls
|
|
to known-blocking functions inside `async def` in daemon-side modules.
|
|
|
|
Mechanism: parse target Python files with ast.parse, walk AsyncFunctionDef
|
|
nodes, check every Call node against BLOCKING_NAMES, exempt calls inside
|
|
`await asyncio.to_thread(...)` argument position. Fail the test on any
|
|
unallowed bare-sync.
|
|
|
|
Allowlist sites must be listed in 07.2-BLOCKING-CALLS-AUDIT.md as
|
|
`safe-fast` with measurement evidence. Format: (file, async_fn, callee).
|
|
|
|
Background: prevents the daemon-CPU-saturation regression that caused
|
|
the 71-min 99-363% CPU run on 2026-04-27. The smoking-gun call was
|
|
`retrieve.build_runtime_graph(store)` at daemon.py:653 inside
|
|
`_hippea_cascade_loop` (an asyncio task) — Plan 07.2-03 wrapped it at
|
|
daemon.py:785 (`await asyncio.to_thread(retrieve.build_runtime_graph,
|
|
store)`). This fence catches re-introduction.
|
|
|
|
Per advisor (2026-04-27): ship `BLOCKING_NAMES = {"build_runtime_graph"}`
|
|
populated from Day 1, NOT empty. The fence has teeth. Adding further
|
|
entries requires both (a) audit-doc classification as `wrapped` and
|
|
(b) measurement evidence > 50 ms.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
SRC = Path(__file__).resolve().parent.parent / "src" / "iai_mcp"
|
|
|
|
# Modules transitively reachable from the 6 daemon asyncio tasks
|
|
# (audit scope per D7.2-13). Update this tuple when a daemon-task
|
|
# touches a new module.
|
|
DAEMON_REACHABLE: tuple[str, ...] = (
|
|
"daemon.py",
|
|
"dream.py",
|
|
"identity_audit.py",
|
|
"hippea_cascade.py",
|
|
"socket_server.py",
|
|
"concurrency.py",
|
|
"insight.py",
|
|
# D7.3-26: maintenance.py is the home of optimize_lance_storage,
|
|
# a sync helper that does 30+ s of LanceDB file I/O. The fence walks files
|
|
# transitively reachable from daemon tasks; daemon.main() and
|
|
# identity_audit.continuous_audit both invoke optimize_lance_storage, so
|
|
# the helper itself is in scope. The helper is sync def (correctly --
|
|
# callers wrap in asyncio.to_thread); listing it here keeps any accidental
|
|
# async-side helper inside maintenance.py covered too.
|
|
"maintenance.py",
|
|
)
|
|
|
|
# Functions that are SYNCHRONOUS AND HEAVY. Every Call to one of these
|
|
# inside `async def` MUST be inside `await asyncio.to_thread(...)`.
|
|
#
|
|
# Day-1 entry: build_runtime_graph (the smoking gun — 8-13 s NetworkX
|
|
# traversal that drove the 2026-04-27 71-min CPU saturation). Audit
|
|
# task 1 surfaced no further async-side bare-sync sites that warrant
|
|
# fence enforcement; future entries are added AS-MEASURED via
|
|
# 07.2-BLOCKING-CALLS-AUDIT.md walks. Each entry requires:
|
|
# - audit-doc row classifying it `wrapped`
|
|
# - empirical measurement > 50 ms in the worst case
|
|
# - confirmation it is a SYNC `def` (not `async def` — those are
|
|
# enforced by mypy/pyright type checks, not this fence)
|
|
BLOCKING_NAMES: frozenset[str] = frozenset({
|
|
"build_runtime_graph",
|
|
# D7.3-26: optimize_lance_storage runs `tbl.optimize(
|
|
# cleanup_older_than=...)` against records/edges/events Lance tables.
|
|
# Offline benchmark on 2026-04-27 measured 33.7 s for records.lance alone
|
|
# (10,841 versions / 3.66 GB -> 595 MB; rows preserved). Both production
|
|
# call sites (daemon.main() startup, identity_audit.continuous_audit
|
|
# periodic body) wrap this helper in `await asyncio.to_thread(...)` per
|
|
# the R4 discipline; the fence catches future re-introduction
|
|
# of a bare sync call.
|
|
"optimize_lance_storage",
|
|
})
|
|
|
|
# Allowlisted bare-sync sites with measurement evidence in audit doc.
|
|
# Format: (file_basename, function_name, callee_name).
|
|
#
|
|
# Currently empty: the audit doc's `safe-fast` rows are ALL for
|
|
# `write_event`, which is not in BLOCKING_NAMES (and so the fence
|
|
# never sees them). The `sigma.compute_and_emit` chain is sync def,
|
|
# also not seen by the fence (Note A in audit doc).
|
|
#
|
|
# A future ALLOWLIST entry would be required ONLY when a callee in
|
|
# BLOCKING_NAMES has a verified `safe-fast` site (< 50 ms measured)
|
|
# inside an `async def` body. That requirement would also bring the
|
|
# auditor's eye onto the row in 07.2-BLOCKING-CALLS-AUDIT.md.
|
|
ALLOWLIST: frozenset[tuple[str, str, str]] = frozenset()
|
|
|
|
|
|
def _callable_name(func: ast.expr) -> str | None:
|
|
"""Extract the terminal callable name from a Call.func node.
|
|
|
|
Handles `foo()`, `mod.foo()`, and `mod.sub.foo()` — returning
|
|
the terminal name in the chain. Returns None for complex
|
|
expressions (lambdas, subscripts) that won't match the blocking
|
|
list anyway.
|
|
"""
|
|
if isinstance(func, ast.Name):
|
|
return func.id
|
|
if isinstance(func, ast.Attribute):
|
|
return func.attr
|
|
return None
|
|
|
|
|
|
class BareBlockingCallFinder(ast.NodeVisitor):
|
|
"""Walk the AST and record any blocking-named Call nodes that
|
|
are NOT inside the args of `await asyncio.to_thread(...)`."""
|
|
|
|
def __init__(self, file_basename: str) -> None:
|
|
self.file = file_basename
|
|
self.violations: list[tuple[str, str, str, int]] = []
|
|
self._in_async_depth = 0
|
|
self._async_fn_stack: list[str] = []
|
|
self._in_to_thread_args = False
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self._in_async_depth += 1
|
|
self._async_fn_stack.append(node.name)
|
|
self.generic_visit(node)
|
|
self._async_fn_stack.pop()
|
|
self._in_async_depth -= 1
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
# A nested sync `def` inside an `async def` is NOT bound by
|
|
# the rule — the sync inner function runs in whatever thread
|
|
# the caller picks.
|
|
prev = self._in_async_depth
|
|
self._in_async_depth = 0
|
|
self.generic_visit(node)
|
|
self._in_async_depth = prev
|
|
|
|
def visit_Await(self, node: ast.Await) -> None:
|
|
# Detect `await asyncio.to_thread(fn, args, kwargs)` and
|
|
# exempt the args list from the blocking check.
|
|
if (
|
|
isinstance(node.value, ast.Call)
|
|
and isinstance(node.value.func, ast.Attribute)
|
|
and node.value.func.attr == "to_thread"
|
|
):
|
|
prev = self._in_to_thread_args
|
|
self._in_to_thread_args = True
|
|
for arg in node.value.args:
|
|
self.visit(arg)
|
|
for kw in node.value.keywords:
|
|
self.visit(kw)
|
|
self._in_to_thread_args = prev
|
|
return
|
|
self.generic_visit(node)
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
name = _callable_name(node.func)
|
|
current_fn = (
|
|
self._async_fn_stack[-1] if self._async_fn_stack else "?"
|
|
)
|
|
if (
|
|
self._in_async_depth > 0
|
|
and not self._in_to_thread_args
|
|
and name is not None
|
|
and name in BLOCKING_NAMES
|
|
and (self.file, current_fn, name) not in ALLOWLIST
|
|
):
|
|
self.violations.append((
|
|
self.file,
|
|
current_fn,
|
|
name,
|
|
node.lineno,
|
|
))
|
|
self.generic_visit(node)
|
|
|
|
|
|
def test_no_bare_blocking_call_in_async_def() -> None:
|
|
"""R4 regression fence: no bare sync calls to BLOCKING_NAMES
|
|
inside `async def` in daemon-side modules."""
|
|
all_violations: list[tuple[str, str, str, int]] = []
|
|
for filename in DAEMON_REACHABLE:
|
|
path = SRC / filename
|
|
if not path.exists():
|
|
continue
|
|
tree = ast.parse(path.read_text(), filename=filename)
|
|
finder = BareBlockingCallFinder(filename)
|
|
finder.visit(tree)
|
|
all_violations.extend(finder.violations)
|
|
assert not all_violations, (
|
|
"R4 regression fence: bare sync calls to blocking functions "
|
|
"inside `async def`. Each violation must be either (a) wrapped "
|
|
"in `await asyncio.to_thread(...)` or (b) added to ALLOWLIST "
|
|
"with a row in 07.2-BLOCKING-CALLS-AUDIT.md classifying it as "
|
|
"`safe-fast` with measurement evidence. Violations:\n"
|
|
+ "\n".join(
|
|
f" {f}:{ln} async def {fn} -> {callee}()"
|
|
for f, fn, callee, ln in all_violations
|
|
)
|
|
)
|
|
|
|
|
|
def test_blocking_names_set_is_non_empty() -> None:
|
|
"""Ship-discipline check: the fence must have teeth.
|
|
|
|
Per advisor: don't ship the fence with empty BLOCKING_NAMES —
|
|
that's a fence with no enforcement surface. Day-1 minimum is the
|
|
smoking gun.
|
|
"""
|
|
assert "build_runtime_graph" in BLOCKING_NAMES, (
|
|
"BLOCKING_NAMES must contain 'build_runtime_graph' (the "
|
|
"smoking-gun call that drove the 2026-04-27 71-min CPU "
|
|
"saturation). Fence is useless without this. Plan 07.2-03 "
|
|
"wrapped this site; fence catches future re-introduction."
|
|
)
|
|
|
|
|
|
def test_ast_walker_correctly_identifies_to_thread_exemption() -> None:
|
|
"""Self-check: confirm the visitor correctly distinguishes
|
|
wrapped from bare-sync calls inside the same async function body.
|
|
"""
|
|
snippet = '''
|
|
import asyncio
|
|
from iai_mcp import retrieve
|
|
|
|
async def good_path(store):
|
|
# Wrapped — fence MUST exempt this.
|
|
g, a, r = await asyncio.to_thread(retrieve.build_runtime_graph, store)
|
|
return g
|
|
|
|
async def bad_path(store):
|
|
# Bare-sync — fence MUST flag this.
|
|
g, a, r = retrieve.build_runtime_graph(store)
|
|
return g
|
|
'''
|
|
tree = ast.parse(snippet)
|
|
finder = BareBlockingCallFinder("synthetic_snippet.py")
|
|
finder.visit(tree)
|
|
# Only the bad_path call should be flagged.
|
|
violations = list(finder.violations)
|
|
assert len(violations) == 1, (
|
|
f"Expected exactly 1 violation (in bad_path); got "
|
|
f"{len(violations)}: {violations}"
|
|
)
|
|
f, fn, callee, ln = violations[0]
|
|
assert fn == "bad_path", f"Expected violation in bad_path; got {fn}"
|
|
assert callee == "build_runtime_graph", (
|
|
f"Expected violation on build_runtime_graph; got {callee}"
|
|
)
|