Merge pull request 'Fix race condition in McpClient.start() double initialization' (#10892) from bugfix/mcp-race-condition-start into master
CI / lint (push) Successful in 42s
CI / build (push) Successful in 39s
CI / typecheck (push) Successful in 59s
CI / quality (push) Successful in 1m3s
CI / push-validation (push) Successful in 23s
CI / helm (push) Successful in 54s
CI / security (push) Successful in 1m21s
CI / e2e_tests (push) Successful in 53s
CI / unit_tests (push) Successful in 5m39s
CI / docker (push) Successful in 1m40s
CI / integration_tests (push) Successful in 8m43s
CI / coverage (push) Successful in 8m55s
CI / status-check (push) Successful in 4s
CI / benchmark-regression (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled

This commit was merged in pull request #10892.
This commit is contained in:
2026-06-10 15:32:54 +00:00
committed by Forgejo
7 changed files with 288 additions and 4 deletions
+6 -1
View File
@@ -269,7 +269,12 @@ ensuring data is stored with proper parameter values.
on a completed plan would silently destroy the ``cleveragents/plan-<id>`` git
worktree branch, causing ``plan apply`` to merge zero artifacts. The guard
preserves the branch per spec (§sandbox.cleanup defaults to ``on_apply``).
- **Race condition in ``McpClient.start()`` allows concurrent double initialisation** (#10438):
Added ``_state == McpClientState.STARTING`` check inside the ``threading.RLock`` in
``start()`` so that concurrent callers see the in-progress state and return immediately,
preventing double initialisation of the MCP server connection, resource leaks, and state
corruption. TDD regression test added with BDD scenarios covering concurrent and sequential
start paths.
- **Global CLI options ``--data-dir``, ``--config-path``, and ``-v`` now work correctly**
(#6785): These spec-required flags were absent from ``main_callback()`` in
``src/cleveragents/cli/main.py``, causing any invocation with these flags to crash
+4
View File
@@ -17,6 +17,9 @@
* HAL9000 <HAL9000@cleverthis.com> has contributed CLI rendering improvements and TUI overlay visibility handling for `agents project context set` output.
* Jeffrey Phillips Freeman has contributed the McpClient.start() race condition fix (#10438): added _state == STARTING guard inside threading.RLock in start() and _ensure_started(), ensuring concurrent callers return immediately when initialisation is already in progress.
# Details
Below are some of the specific details of various contributions.
@@ -85,6 +88,7 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed the agent task memory leak fix (#9044): replaced `list.remove` with `set.discard` as the done_callback for asyncio tasks in `Agent._tasks`, preventing unbounded memory growth in long-lived agents and ensuring safe concurrent task removal.
* HAL 9000 has contributed the ACMS context show/clear CLI commands (PR #9675 / issue #9586): implemented `context show <view>` displaying assembled context with per-tier budget utilization summary (hot/warm/cold), and `context clear` with --path, --tag, --tier filtering plus confirmation prompt with --yes bypass. Includes 12 Behave BDD scenarios, 9 Robot Framework integration tests, ASV benchmarks, full type annotations, and _TierServiceProtocol for type safety.
* HAL 9000 <hal9000@cleverthis.com> has contributed cost and session budget tracking and enforcement (issue #8609): implemented `CostTrackingService`, `BudgetExceededError`, three-tier budget hierarchy (plan/session/organization), warning thresholds at 90% utilization, per-provider cost tracking, and comprehensive BDD test suite.
* HAL 9000 has contributed the AutoDebug node state mutation fix (#10496): fixed _analyze_error, _generate_fix, and _validate_fix in src/cleveragents/agents/graphs/auto_debug.py to return partial update dicts per LangGraph s node contract, preventing duplicate state entries and checkpoint inconsistencies.
# Details (PR Contributions)
+49
View File
@@ -0,0 +1,49 @@
"""Counting MCP transport for race-condition testing.
This module provides :class:`CountingMCPTransport`, a test double that wraps a
``MockMCPTransport`` and records how many times ``connect()`` and
``discover_tools()`` (tools/list) are called. It uses thread-safe counters so
that concurrent scenarios can assert the exact call counts expected when fixing
race conditions in ``McpClient.start()`` (issue #10438).
See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/10438
"""
from __future__ import annotations
import threading
from typing import Any
from cleveragents.mcp.adapter import MCPServerConfig, MCPTransport
from features.mocks.mock_mcp_transport import MockMCPTransport
class CountingMCPTransport(MCPTransport):
"""Transport that counts ``connect()`` and ``discover_tools()`` calls.
Wraps an inner transport (typically :class:`MockMCPTransport`) and
increments thread-safe counters for each ``connect()`` call and every
``tools/list`` method call on ``call()``. Used to verify that concurrent
:meth:`~McpClient.start` invocations produce exactly one initialisation
sequence.
"""
def __init__(self, tools: list[dict[str, Any]]) -> None:
self._inner = MockMCPTransport(tools=tools)
self._lock: threading.Lock = threading.Lock()
self.connect_count: int = 0
self.discover_count: int = 0
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
with self._lock:
self.connect_count += 1
return self._inner.connect(config)
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
if method == "tools/list":
with self._lock:
self.discover_count += 1
return self._inner.call(method, params)
def close(self) -> None:
self._inner.close()
@@ -0,0 +1,187 @@
"""Step definitions for TDD issue #10438 — McpClient.start() race condition.
This module provides the Behave step implementations for the TDD bug-capture
test that proves and verifies the fix for bug #10438.
The bug was a race condition in ``McpClient.start()``: the method released the
``threading.RLock`` after checking ``_started`` but before calling
``connect()`` and ``discover_tools()``. Concurrent callers could both pass
the idempotency check and initialise the MCP server connection multiple times,
causing resource leaks and state corruption.
The fix adds ``_state == McpClientState.STARTING`` to the guard inside the
lock so that a second concurrent caller returns immediately without calling
``connect()`` or ``discover_tools()`` again.
Race detection uses a counting transport that records how many times
``connect()`` and ``discover_tools()`` are called. A ``threading.Barrier``
synchronises the threads so they all enter ``start()`` at approximately the
same instant, maximising the chance of the race manifesting.
See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/10438
"""
from __future__ import annotations
import threading
from behave import given, then, when
from behave.runner import Context
from cleveragents.mcp.adapter import MCPServerConfig
from cleveragents.mcp.client import McpClient, McpClientConfig, McpClientState
from features.mocks.counting_mcp_transport import CountingMCPTransport
# ── Given ─────────────────────────────────────────────────────────
@given("an MCP client configured for concurrent start testing")
def step_mcp_client_for_concurrent_start(context: Context) -> None:
"""Create an MCP client with a counting transport for race testing.
The counting transport records how many times ``connect()`` and
``discover_tools()`` are called so that the test can assert exactly
one invocation of each after concurrent ``start()`` calls.
"""
tools = [{"name": "test_tool", "description": "Test tool", "inputSchema": {}}]
transport = CountingMCPTransport(tools=tools)
config = McpClientConfig(
server=MCPServerConfig(name="test-server", transport="stdio", command="echo"),
lazy_start=False,
idle_timeout_seconds=0,
health_check_interval_seconds=0,
)
client = McpClient(config=config, transport=transport)
context.mcp_race_client = client
context.mcp_race_transport = transport
context.mcp_race_thread_errors: list[Exception] = []
context.mcp_race_thread_count = 0
# ── When ──────────────────────────────────────────────────────────
@when("{n:d} threads call start() simultaneously through a barrier")
def step_concurrent_start(context: Context, n: int) -> None:
"""Launch *n* threads that all call ``start()`` simultaneously.
A ``threading.Barrier`` synchronises the threads so they all enter
``start()`` at (approximately) the same instant, maximising the chance
of the race manifesting.
The counting transport records how many times ``connect()`` and
``discover_tools()`` are called.
"""
client: McpClient = context.mcp_race_client
barrier = threading.Barrier(n)
errors: list[Exception] = []
collect_lock = threading.Lock()
def worker() -> None:
"""Thread worker: wait on barrier, then call start()."""
try:
barrier.wait(timeout=10)
client.start()
except Exception as exc:
with collect_lock:
errors.append(exc)
threads = [threading.Thread(target=worker, daemon=True) for _ in range(n)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
# Verify no threads are still alive after join timeout
for t in threads:
assert not t.is_alive(), (
f"Thread {t.name} is still alive after join timeout — "
f"possible deadlock in start()"
)
barrier_errors = [
err for err in errors if isinstance(err, threading.BrokenBarrierError)
]
assert not barrier_errors, (
"Barrier synchronization failed while coordinating concurrent "
f"start() calls. Barrier errors: {barrier_errors}"
)
context.mcp_race_thread_errors = errors
context.mcp_race_thread_count = n
@when("I call start() sequentially {n:d} times")
def step_sequential_start(context: Context, n: int) -> None:
"""Call ``start()`` sequentially *n* times on the same client."""
client: McpClient = context.mcp_race_client
for _ in range(n):
client.start()
context.mcp_race_thread_errors = []
context.mcp_race_thread_count = n
# ── Then ──────────────────────────────────────────────────────────
@then("connect should have been called exactly once")
def step_connect_called_once(context: Context) -> None:
"""Assert that ``connect()`` was called exactly once.
With the bug present (no STARTING state guard), multiple threads would
pass the ``_started`` check and each call ``connect()``, so the count
would be > 1.
With the fix applied, only the first thread calls ``connect()`` and
subsequent concurrent callers see ``_state == STARTING`` and return.
"""
transport: CountingMCPTransport = context.mcp_race_transport
count = transport.connect_count
assert count == 1, (
f"connect() was called {count} time(s), expected exactly 1. "
f"This indicates the race condition in McpClient.start() is present — "
f"multiple threads bypassed the idempotency guard. "
f"Thread errors (if any): {context.mcp_race_thread_errors}"
)
@then("discover_tools should have been called exactly once")
def step_discover_tools_called_once(context: Context) -> None:
"""Assert that ``discover_tools()`` was called exactly once.
With the bug present, multiple threads would call ``discover_tools()``
concurrently, so the count would be > 1.
With the fix applied, only the first thread calls ``discover_tools()``
and subsequent concurrent callers return early.
"""
transport: CountingMCPTransport = context.mcp_race_transport
count = transport.discover_count
assert count == 1, (
f"discover_tools() was called {count} time(s), expected exactly 1. "
f"This indicates the race condition in McpClient.start() is present — "
f"multiple threads bypassed the idempotency guard. "
f"Thread errors (if any): {context.mcp_race_thread_errors}"
)
@then("the MCP client state should be running after concurrent starts")
def step_client_state_running_after_concurrent(context: Context) -> None:
"""Assert the client is in RUNNING state after concurrent start() calls."""
client: McpClient = context.mcp_race_client
actual = client.state
assert actual == McpClientState.RUNNING, (
f"Expected client state to be '{McpClientState.RUNNING}', got '{actual}'. "
f"Thread errors (if any): {context.mcp_race_thread_errors}"
)
@then("all concurrent start threads should have completed without error")
def step_all_threads_no_error(context: Context) -> None:
"""Assert that all concurrent start() threads completed without error."""
errors = context.mcp_race_thread_errors
assert not errors, f"Expected no thread errors but got {len(errors)}: {errors}"
@@ -0,0 +1,39 @@
# This test captures bug #10438 — McpClient.start() race condition allowing
# concurrent double initialisation.
#
# The fix adds a check for ``_state == McpClientState.STARTING`` inside the
# lock so that a second concurrent caller sees the in-progress state and
# returns immediately, preventing double initialisation.
#
# See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/10438
@tdd_issue @tdd_issue_10438
Feature: TDD Issue #10438 — McpClient.start() race condition allows concurrent double initialisation
McpClient.start() previously released the threading.RLock after checking
``_started`` but before calling ``connect()`` and ``discover_tools()``.
Concurrent callers could both pass the idempotency check and initialise
the MCP server connection multiple times, causing resource leaks and state
corruption.
The fix adds ``_state == McpClientState.STARTING`` to the guard inside the
lock so that a second concurrent caller returns immediately.
Scenario: Concurrent start() calls result in exactly one connect() invocation
Given an MCP client configured for concurrent start testing
When 5 threads call start() simultaneously through a barrier
Then connect should have been called exactly once
And discover_tools should have been called exactly once
And the MCP client state should be running after concurrent starts
Scenario: Concurrent start() calls leave the client in RUNNING state
Given an MCP client configured for concurrent start testing
When 10 threads call start() simultaneously through a barrier
Then the MCP client state should be running after concurrent starts
And all concurrent start threads should have completed without error
Scenario: Sequential start() calls remain idempotent after the fix
Given an MCP client configured for concurrent start testing
When I call start() sequentially 3 times
Then connect should have been called exactly once
And discover_tools should have been called exactly once
And the MCP client state should be running after concurrent starts
+1 -1
View File
@@ -6,7 +6,7 @@
#
# See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/10438
@tdd_expected_fail @tdd_issue @tdd_issue_10438
@tdd_issue @tdd_issue_10438
Feature: TDD Issue #10438 — McpClient.start() race condition allows concurrent double initialization
McpClient.start() releases the threading.RLock after setting _state to STARTING
but before calling connect() and discover_tools(). If two threads call start()
+2 -2
View File
@@ -207,7 +207,7 @@ class McpClient:
If the MCP server cannot be reached.
"""
with self._lock:
if self._started:
if self._started or self._state == McpClientState.STARTING:
return
self._state = McpClientState.STARTING
@@ -263,7 +263,7 @@ class McpClient:
If the client is not started and lazy_start is disabled.
"""
with self._lock:
if self._started:
if self._started or self._state == McpClientState.STARTING:
return
if not self._config.lazy_start:
msg = (