Skip to content

Commit b16ec74

Browse files
authored
fix(mcp): isolate pending capture tasks (#815)
* fix(mcp): isolate pending capture tasks * test(mcp): await background capture futures * address mcp pending task review feedback * fix(mcp): reset pending task state after fork
1 parent 48e23bc commit b16ec74

9 files changed

Lines changed: 268 additions & 68 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Isolate MCP pending capture tasks by owner and loop

‎posthog/mcp/__init__.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,12 @@ async def capture(self, event: str, properties: Optional[dict] = None) -> None:
125125
await coro
126126

127127
async def flush(self) -> None:
128-
"""Await in-flight auto-captured events scheduled on the current event loop.
128+
"""Await this server's in-flight auto-captures on the current event loop.
129129
Call this before ``posthog.shutdown()`` on exit so trailing tool-call events
130130
aren't dropped. (Then call ``posthog.flush()``/``shutdown()`` to send them.)"""
131-
await drain_pending()
131+
data = get_server_tracking_data(self._key)
132+
if data is not None:
133+
await drain_pending(data)
132134

133135

134136
class _NoopAnalytics(McpAnalytics):
@@ -138,6 +140,10 @@ def __init__(self) -> None: # noqa: D401 - graceful degradation handle
138140
async def capture(self, event: str, properties: Optional[dict] = None) -> None:
139141
return None
140142

143+
async def flush(self) -> None:
144+
# There is no tracking key to look up or pending work to drain.
145+
return None
146+
141147

142148
def _resolve_client(posthog_client: Optional[Client]) -> Optional[Client]:
143149
if posthog_client is not None:

‎posthog/mcp/_instrumentation.py‎

Lines changed: 67 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import os
1515
import threading
1616
from datetime import datetime, timezone
17-
from typing import Any, Dict, List, Optional, Set
17+
from typing import Any, Dict, List, Optional
1818

1919
from ._capture import capture_event
2020
from ._event_types import MCPAnalyticsEventType
@@ -26,10 +26,10 @@
2626
from .session import resolve_session_id
2727
from .session_token import SessionTokenPayload, decode_session_id
2828

29-
# Keep strong refs to in-flight capture tasks/futures so they aren't GC'd mid-flight,
30-
# and so the asyncio ones can be awaited via drain_pending() before shutdown. Holds
31-
# asyncio.Task (running-loop path) or concurrent.futures.Future (sync background-loop path).
32-
_BACKGROUND_TASKS: Set[Any] = set()
29+
# Keep strong refs to in-flight capture tasks/futures and their lifecycle owners so
30+
# they aren't GC'd mid-flight and lifecycle drains can select only their own work.
31+
_BACKGROUND_TASKS: Dict[Any, Any] = {}
32+
_tasks_lock = threading.Lock()
3333

3434
# A single daemon event loop for hosts with no running loop (sync dispatchers
3535
# like PostHogMCP). Created lazily and reused, so we never leak a loop per call.
@@ -44,8 +44,9 @@ def _reinit_background_loop_after_fork() -> None:
4444
been held by a vanished thread. Replace the state without acquiring the old
4545
lock or trying to close the inherited loop, which can no longer be driven.
4646
"""
47-
global _BACKGROUND_TASKS, _bg_loop, _bg_loop_lock
48-
_BACKGROUND_TASKS = set()
47+
global _BACKGROUND_TASKS, _tasks_lock, _bg_loop, _bg_loop_lock
48+
_BACKGROUND_TASKS = {}
49+
_tasks_lock = threading.Lock()
4950
_bg_loop = None
5051
_bg_loop_lock = threading.Lock()
5152

@@ -67,60 +68,74 @@ def _get_background_loop() -> asyncio.AbstractEventLoop:
6768
return _bg_loop
6869

6970

71+
def _track_task(task: Any, owner: Any) -> None:
72+
with _tasks_lock:
73+
_BACKGROUND_TASKS[task] = owner
74+
task.add_done_callback(_on_task_done)
75+
76+
7077
def _on_task_done(task: Any) -> None:
71-
_BACKGROUND_TASKS.discard(task)
78+
with _tasks_lock:
79+
_BACKGROUND_TASKS.pop(task, None)
7280
try:
7381
if not task.cancelled() and task.exception() is not None:
7482
log(f"background capture task failed: {task.exception()}")
7583
except Exception: # noqa: BLE001 - never let bookkeeping raise
7684
pass
7785

7886

79-
def fire_and_forget(coro: Optional[Any]) -> None:
80-
"""Schedule a capture coroutine without blocking the tool path. No-ops if the
81-
coroutine is ``None`` (no sink). Runs on the current loop when there is one,
82-
otherwise on a shared daemon loop (sync hosts) — never creates a throwaway loop."""
87+
def fire_and_forget(
88+
coro: Optional[Any], owner: Any, *, background: bool = False
89+
) -> None:
90+
"""Schedule capture work and associate it with its lifecycle owner.
91+
92+
Async instrumentation uses its current loop. Sync-only owners can request the
93+
shared background loop so their synchronous lifecycle methods can safely drain
94+
captures even when invoked by a host that also has a running event loop.
95+
"""
8396
if coro is None:
8497
return
8598
try:
86-
asyncio.get_running_loop()
99+
running_loop = asyncio.get_running_loop()
87100
except RuntimeError:
88-
# No running loop (sync host) — schedule on the shared background loop.
89-
future = asyncio.run_coroutine_threadsafe(coro, _get_background_loop())
90-
_BACKGROUND_TASKS.add(future)
91-
future.add_done_callback(_on_task_done)
92-
return
93-
task = asyncio.ensure_future(coro)
94-
_BACKGROUND_TASKS.add(task)
95-
task.add_done_callback(_on_task_done)
101+
running_loop = None
96102

103+
if background or running_loop is None:
104+
loop = _get_background_loop()
105+
future = asyncio.run_coroutine_threadsafe(coro, loop)
106+
_track_task(future, owner)
107+
return
97108

98-
async def drain_pending() -> None:
99-
"""Await in-flight capture work before ``posthog.shutdown()`` instead of racing a
100-
sleep. Covers both paths: ``asyncio.Task`` (running-loop hosts) and the
101-
``concurrent.futures.Future`` scheduled on the background loop (sync hosts like
102-
PostHogMCP) — the latter wrapped so it can be awaited on the current loop."""
103-
awaitables: List[Any] = []
104-
for t in list(_BACKGROUND_TASKS):
105-
if isinstance(t, asyncio.Task):
106-
if not t.done():
107-
awaitables.append(t)
108-
elif isinstance(t, concurrent.futures.Future):
109-
if not t.done():
110-
awaitables.append(asyncio.wrap_future(t))
111-
if awaitables:
112-
await asyncio.gather(*awaitables, return_exceptions=True)
113-
114-
115-
def drain_pending_sync(timeout: Optional[float] = None) -> None:
116-
"""Block until background-loop captures finish. For sync hosts (PostHogMCP) that
117-
can't await :func:`drain_pending` — call it before ``flush()``/``shutdown()`` so
118-
trailing events aren't still in flight when the client tears down."""
119-
futures = [
120-
t
121-
for t in list(_BACKGROUND_TASKS)
122-
if isinstance(t, concurrent.futures.Future) and not t.done()
123-
]
109+
task = running_loop.create_task(coro)
110+
_track_task(task, owner)
111+
112+
113+
async def drain_pending(owner: Any) -> None:
114+
"""Await this owner's in-flight captures bound to the current event loop."""
115+
loop = asyncio.get_running_loop()
116+
with _tasks_lock:
117+
tasks = [
118+
task
119+
for task, task_owner in _BACKGROUND_TASKS.items()
120+
if task_owner is owner
121+
and isinstance(task, asyncio.Task)
122+
and task.get_loop() is loop
123+
and not task.done()
124+
]
125+
if tasks:
126+
await asyncio.gather(*tasks, return_exceptions=True)
127+
128+
129+
def drain_pending_sync(owner: Any, timeout: Optional[float] = None) -> None:
130+
"""Block until this owner's shared-background-loop captures finish."""
131+
with _tasks_lock:
132+
futures = [
133+
task
134+
for task, task_owner in _BACKGROUND_TASKS.items()
135+
if task_owner is owner
136+
and isinstance(task, concurrent.futures.Future)
137+
and not task.done()
138+
]
124139
if futures:
125140
concurrent.futures.wait(futures, timeout=timeout)
126141

@@ -188,7 +203,7 @@ async def _maybe_emit_initialize(
188203
await _apply_event_properties(
189204
data, event, {"method": "initialize", "params": {}}, extra
190205
)
191-
fire_and_forget(capture_event(data, event))
206+
fire_and_forget(capture_event(data, event), data)
192207

193208

194209
async def _apply_event_properties(
@@ -251,7 +266,7 @@ async def prepare_request(
251266
session_id = await resolve_session_id(data, mcp_session_id, token=token)
252267
identify_event = await handle_identify(data, session_id, request, extra)
253268
if identify_event:
254-
fire_and_forget(capture_event(data, identify_event))
269+
fire_and_forget(capture_event(data, identify_event), data)
255270
await _maybe_emit_initialize(
256271
data, session_id, client_name, client_version, extra, protocol_version
257272
)
@@ -306,7 +321,7 @@ async def record_tool_call(
306321
if props is not None:
307322
event["properties"] = props
308323

309-
fire_and_forget(capture_event(data, event))
324+
fire_and_forget(capture_event(data, event), data)
310325
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
311326
log(f"record_tool_call failed (event dropped, tool unaffected): {err}")
312327

@@ -389,7 +404,7 @@ async def record_missing_capability(
389404
event["user_intent"] = context.strip()
390405
event["user_intent_source"] = "context_parameter"
391406
await _apply_event_properties(data, event, request, extra)
392-
fire_and_forget(capture_event(data, event))
407+
fire_and_forget(capture_event(data, event), data)
393408
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
394409
log(f"record_missing_capability failed (event dropped): {err}")
395410

@@ -426,6 +441,6 @@ async def record_tools_list(
426441
if error is not None:
427442
event["error"] = capture_exception(error)
428443
await _apply_event_properties(data, event, request, extra)
429-
fire_and_forget(capture_event(data, event))
444+
fire_and_forget(capture_event(data, event), data)
430445
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
431446
log(f"record_tools_list failed (event dropped): {err}")

‎posthog/mcp/posthog_mcp.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ def __init__(
6464

6565
def flush(self, timeout_seconds: Optional[float] = 10) -> None:
6666
"""Drain in-flight MCP captures scheduled on the background loop, then flush
67-
the underlying client. The capture methods are fire-and-forget on a sync host,
68-
so without this drain a trailing event could still be in flight at flush time."""
69-
drain_pending_sync(timeout=timeout_seconds)
67+
the underlying client. The capture methods are fire-and-forget, so without
68+
this drain a trailing event could still be in flight at flush time."""
69+
drain_pending_sync(self, timeout=timeout_seconds)
7070
return super().flush(timeout_seconds=timeout_seconds)
7171

7272
def shutdown(self) -> None:
7373
"""Drain in-flight MCP captures, then shut the underlying client down."""
74-
drain_pending_sync()
74+
drain_pending_sync(self)
7575
return super().shutdown()
7676

7777
# --- capture methods -----------------------------------------------------
@@ -300,7 +300,10 @@ def _emit(self, event: Dict[str, Any]) -> None:
300300
options = McpCaptureOptions(
301301
enable_exception_autocapture=self._mcp_exception_autocapture
302302
)
303-
fire_and_forget(self._mcp_sink.capture(event, options))
303+
# PostHogMCP exposes synchronous lifecycle methods, so always use the shared
304+
# background loop even when capture is called by an async host. This keeps
305+
# flush()/shutdown() able to drain without blocking their own event loop's tasks.
306+
fire_and_forget(self._mcp_sink.capture(event, options), self, background=True)
304307

305308
def _inject_context(self, tool: Any, description: Optional[str]) -> Any:
306309
if isinstance(tool, dict):

‎posthog/test/mcp/_helpers.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import asyncio
8+
import concurrent.futures
89

910

1011
class FakeClient:
@@ -32,9 +33,15 @@ async def flush_background():
3233
"""Let fire-and-forget capture tasks run to completion."""
3334
import posthog.mcp._instrumentation as instr
3435

36+
loop = asyncio.get_running_loop()
3537
for _ in range(10):
3638
await asyncio.sleep(0)
37-
pending = [t for t in list(instr._BACKGROUND_TASKS) if not t.done()]
39+
pending = []
40+
for task in list(instr._BACKGROUND_TASKS):
41+
if isinstance(task, asyncio.Task) and task.get_loop() is loop:
42+
pending.append(task)
43+
elif isinstance(task, concurrent.futures.Future):
44+
pending.append(asyncio.wrap_future(task))
3845
if pending:
3946
await asyncio.gather(*pending, return_exceptions=True)
4047
await asyncio.sleep(0)

‎posthog/test/mcp/test_fastmcp.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""End-to-end tests for the FastMCP adapter (Milestone 2)."""
22

3+
import asyncio
4+
35
import pytest
46

57
import mcp.types as mcp_types
@@ -101,6 +103,27 @@ def spy_add(a: int, b: int) -> int:
101103
assert "context" not in props["$mcp_parameters"]["request"]["params"]["arguments"]
102104

103105

106+
async def test_analytics_flush_drains_its_own_captures():
107+
async def slow_before_send(event):
108+
await asyncio.sleep(0.05)
109+
return event
110+
111+
server = make_server()
112+
client = FakeClient()
113+
analytics = instrument(
114+
server, client, MCPAnalyticsOptions(before_send=slow_before_send)
115+
)
116+
117+
await server._tool_manager.call_tool(
118+
"add", {"a": 2, "b": 3, "context": "summing two numbers"}
119+
)
120+
assert _events(client, "$mcp_tool_call") == []
121+
122+
await analytics.flush()
123+
124+
assert len(_events(client, "$mcp_tool_call")) == 1
125+
126+
104127
async def test_initialize_emitted_once_per_session():
105128
server = make_server()
106129
client = FakeClient()
@@ -179,5 +202,6 @@ async def test_instrument_is_idempotent():
179202

180203
async def test_unsupported_server_returns_noop_handle():
181204
handle = instrument(object(), FakeClient())
182-
# graceful no-op: capture does nothing and does not raise
205+
# graceful no-op: capture and flush do nothing and do not raise
183206
await handle.capture("anything")
207+
await handle.flush()

‎posthog/test/mcp/test_instrumentation_fork.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ async def pending_parent_capture():
2222
while not finish_parent_capture.is_set():
2323
await asyncio.sleep(0.01)
2424

25-
instrumentation.fire_and_forget(pending_parent_capture())
25+
owner = object()
26+
instrumentation.fire_and_forget(pending_parent_capture(), owner)
2627
assert parent_capture_started.wait(timeout=2)
2728
parent_loop = instrumentation._bg_loop
2829
assert parent_loop is not None
2930
assert instrumentation._BACKGROUND_TASKS
3031

3132
read_fd, write_fd = os.pipe()
3233
instrumentation._bg_loop_lock.acquire()
34+
instrumentation._tasks_lock.acquire()
3335
try:
3436
with warnings.catch_warnings():
3537
warnings.simplefilter("ignore", DeprecationWarning)
@@ -44,8 +46,8 @@ async def pending_parent_capture():
4446
async def child_capture():
4547
child_capture_completed.append(True)
4648

47-
instrumentation.fire_and_forget(child_capture())
48-
instrumentation.drain_pending_sync(timeout=2)
49+
instrumentation.fire_and_forget(child_capture(), owner)
50+
instrumentation.drain_pending_sync(owner, timeout=2)
4951
new_loop_created = instrumentation._bg_loop is not parent_loop
5052

5153
if (
@@ -73,9 +75,10 @@ async def child_capture():
7375
os.close(read_fd)
7476
_, status = os.waitpid(pid, 0)
7577
finally:
78+
instrumentation._tasks_lock.release()
7679
instrumentation._bg_loop_lock.release()
7780
finish_parent_capture.set()
78-
instrumentation.drain_pending_sync(timeout=2)
81+
instrumentation.drain_pending_sync(owner, timeout=2)
7982

8083
assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, result
8184
assert result == "ok"

0 commit comments

Comments
 (0)