Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
244
tests/test_no_bare_sync_in_async.py
Normal file
244
tests/test_no_bare_sync_in_async.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""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}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue