Skip to content

fix: stop PulseAgent blocking the hypercode-core event loop - #311

Merged
welshDog merged 1 commit into
mainfrom
fix/pulse-agent-sync-requests-on-loop
Jul 10, 2026
Merged

fix: stop PulseAgent blocking the hypercode-core event loop#311
welshDog merged 1 commit into
mainfrom
fix/pulse-agent-sync-requests-on-loop

Conversation

@welshDog

@welshDog welshDog commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Third and last of today's sync-I/O-on-an-async-loop fixes (see #309, #310). This is the only one in a container that's actually running.

What's wrong

PulseAgent.process() (backend/app/agents/pulse.py:20) is a coroutine on hypercode-core's event loop, and it queried Prometheus with synchronous requests.get(timeout=5). A stall there delays every request the core API is serving, not just the pulse call.

pulse.py was the only user of requests anywhere in backend/app.

The part that isn't obvious

The textbook rewrite — async with httpx.AsyncClient() as c: await c.get(...) inside process()measured worse than the bug.

Constructing an AsyncClient builds an SSL context and loads the CA bundle: hundreds of milliseconds of synchronous work, executed on the loop, on every call.

Worst event-loop stall per call, measured inside the running container with a 50ms ticker task:

Approach Worst loop stall
requests.get (the bug) 53 ms
AsyncClient built per call 284 ms ← worse than the bug
shared AsyncClient, built once 8 ms

So the client is built once at module import. That detail is the fix; awaiting alone would have made things worse.

Verification

Driven against the shipped module in the rebuilt container — importing app.agents.pulse and exercising its real _http client, not a copy of the code:

run 1: call= 221ms  worst loop stall= 125.6ms   <- one-time transport warmup
run 2: call= 122ms  worst loop stall=  10.0ms
run 3: call=  30ms  worst loop stall=   1.0ms
run 4: call=  61ms  worst loop stall=   6.0ms
status=200, 14 Prometheus targets parsed

Steady state is 1–10ms. The first call in a fresh process costs ~126ms of one-time transport warmup, then settles — that happens once per process, not per call.

hypercode-core rebuilt, healthy, 0 restarts, /health{"status":"ok","version":"2.4.2"}.

httpx.Response exposes .status_code, .json(), and .text identically to requests, so the parsing below the call is untouched.

Not verified

brain.think() below the fetch is unchanged and was not exercised — it calls the LLM.

Context

Found by an AST sweep for sync I/O lexically inside async def bodies, after a manual audit missed it. The sweep also flagged agents/hypervisor-agent/hypervisor_agent.py:450,495 and services/agent-spawner/spawner.py:117,187 — both real, neither deployed (no containers), both deliberately left alone here.

Worth recording the sweep's blind spot: it only catches calls written lexically inside a coroutine. It would not have caught the broski-pets-bridge bug in #310, where the sync work sits in a helper def that an async def calls. A clean AST run means clean of the lexical form, not clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved agent responsiveness by switching one external data fetch to a non-blocking async request.
    • Reduced the chance of slowdowns while retrieving service status data.

PulseAgent.process() is a coroutine running on hypercode-core's event
loop, and it called requests.get() -- the sync api -- to query
Prometheus with a 5s timeout. A stall there delays every request the
core API is serving, not just the pulse call.

Replace with an awaited httpx.AsyncClient. The client is built ONCE at
module import, not per call, and that detail is the whole fix.

Constructing an AsyncClient builds an SSL context and loads the CA
bundle: hundreds of milliseconds of synchronous work. The obvious
rewrite -- `async with httpx.AsyncClient() as c` inside process() --
measured WORSE than the bug it replaced. Worst event-loop stall per
call, measured inside the running container with a 50ms ticker:

  requests.get (sync)            53 ms
  AsyncClient built per call    284 ms   <- worse than the bug
  shared AsyncClient (shipped)    8 ms

Verified against the shipped module (import app.agents.pulse; drive its
real _http client): steady-state stall 1-10ms across runs 2-4, status
200, 14 Prometheus targets parsed. First call in a fresh process costs
~126ms of one-time transport warmup, then settles.

httpx.Response exposes .status_code/.json()/.text identically to
requests, so the parsing below the call is untouched. pulse.py was the
only user of `requests` in backend/app.

Not exercised: brain.think() below the fetch. That path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

PulseAgent's Prometheus data fetch in pulse.py is migrated from a blocking requests.get() call to a non-blocking, awaited httpx.AsyncClient.get() call, using a shared module-level client initialised at import time with a 5-second timeout.

Changes

Pulse Agent Async Migration

Layer / File(s) Summary
Async client setup and usage
backend/app/agents/pulse.py
Replaces requests import with httpx, adds a module-level httpx.AsyncClient (_http) with a 5-second timeout, and updates PulseAgent.process() to await _http.get(...) for the Prometheus "up" query instead of using a blocking requests.get(...) call.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PulseAgent
  participant AsyncHttpClient
  participant Prometheus

  PulseAgent->>AsyncHttpClient: await _http.get(prometheus up query)
  AsyncHttpClient->>Prometheus: GET request
  Prometheus-->>AsyncHttpClient: JSON response
  AsyncHttpClient-->>PulseAgent: response.json()
Loading

Poem

A hop, a skip, no more blocking here,
httpx whispers fast and clear.
Async client waits at the door,
Prometheus data flows once more —
This bunny cheers with twitching nose! 🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: making PulseAgent stop blocking the event loop.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pulse-agent-sync-requests-on-loop

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.23.0)
backend/app/agents/pulse.py

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/agents/pulse.py`:
- Line 14: The shared module-level AsyncClient in pulse.py is never closed, so
its connection pool can leak on shutdown. Add a dedicated close helper for the
_http client (for example, a close_http_client function near the existing client
setup) that awaits _http.aclose(), and make sure the application shutdown path
or lifespan handler invokes it. Keep the client reuse pattern, but wire its
lifecycle to startup/shutdown so the singleton client is disposed cleanly.
- Line 14: The module-level `_http` `httpx.AsyncClient` in `pulse.py` is created
once but never closed, so its connection pool can remain open for the process
lifetime. Update the app shutdown/lifespan teardown path to call
`_http.aclose()` explicitly, or wire `_http` into the existing lifespan cleanup
alongside the other app resources so it is always closed when the app stops.
- Around line 2-15: The pulse agent switched from requests to a shared
httpx.AsyncClient, so the existing tests that patch pulse_mod.requests.get will
fail because requests no longer exists and the new path uses _http.get. Update
the unit tests in test_agent_pulse.py to mock pulse_mod._http.get (or otherwise
inject a client) so they intercept the awaited HTTP call in pulse.process/brain
usage and avoid AttributeError during collection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ac4939b7-b64e-407f-92f2-c6689793e7b2

📥 Commits

Reviewing files that changed from the base of the PR and between 798a565 and f635dbc.

📒 Files selected for processing (1)
  • backend/app/agents/pulse.py

Comment on lines +2 to +15
import httpx
from app.agents.brain import brain

logger = logging.getLogger(__name__)

# Built once, at import. Constructing an AsyncClient builds an SSL context and
# loads the CA bundle — hundreds of ms of *synchronous* work. Doing that inside
# process() stalls the loop harder than the sync requests.get it replaced.
# Measured in-container, worst loop stall per call:
# requests.get (sync) 53 ms
# AsyncClient built per call 284 ms <- worse than the bug
# shared AsyncClient (this) 8 ms
_http = httpx.AsyncClient(timeout=5.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Unit tests will break: they still mock pulse_mod.requests.get.

The removal of import requests (replaced by import httpx) means pulse_mod.requests no longer exists. All three tests in backend/tests/unit/test_agent_pulse.py call monkeypatch.setattr(pulse_mod.requests, "get", fake_get), which will raise AttributeError at collection time. Additionally, even if patched, the mock would not intercept the new await _http.get(...) path.

The tests must be migrated to mock _http.get (or inject a client) instead.

🔧 Example test migration pattern
 # In test_agent_pulse.py

-    def fake_get(url: str, params: dict, timeout: int):
-        return _Response(200, {...})
-
-    monkeypatch.setattr(pulse_mod.requests, "get", fake_get)
+    async def fake_get(url, params=None):
+        return _Response(200, {...})
+
+    monkeypatch.setattr(pulse_mod._http, "get", fake_get)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/agents/pulse.py` around lines 2 - 15, The pulse agent switched
from requests to a shared httpx.AsyncClient, so the existing tests that patch
pulse_mod.requests.get will fail because requests no longer exists and the new
path uses _http.get. Update the unit tests in test_agent_pulse.py to mock
pulse_mod._http.get (or otherwise inject a client) so they intercept the awaited
HTTP call in pulse.process/brain usage and avoid AttributeError during
collection.

# requests.get (sync) 53 ms
# AsyncClient built per call 284 ms <- worse than the bug
# shared AsyncClient (this) 8 ms
_http = httpx.AsyncClient(timeout=5.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Module-level AsyncClient is never closed — connection pool leaks on shutdown.

The shared _http client is intentionally long-lived, but there is no aclose() call anywhere. On process restart or graceful shutdown, the underlying connection pool and SSL context are not disposed, which can leak sockets and file descriptors.

Consider wiring an aclose() into the application's lifespan/shutdown handler, or use a pattern that ties the client lifecycle to the app:

♻️ Suggested lifecycle management
 _http = httpx.AsyncClient(timeout=5.0)
+
+async def close_http_client() -> None:
+    """Call during application shutdown."""
+    await _http.aclose()

Then invoke close_http_client() from the app's shutdown signal handler (e.g., FastAPI lifespan or equivalent).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_http = httpx.AsyncClient(timeout=5.0)
_http = httpx.AsyncClient(timeout=5.0)
async def close_http_client() -> None:
"""Call during application shutdown."""
await _http.aclose()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/agents/pulse.py` at line 14, The shared module-level AsyncClient
in pulse.py is never closed, so its connection pool can leak on shutdown. Add a
dedicated close helper for the _http client (for example, a close_http_client
function near the existing client setup) that awaits _http.aclose(), and make
sure the application shutdown path or lifespan handler invokes it. Keep the
client reuse pattern, but wire its lifecycle to startup/shutdown so the
singleton client is disposed cleanly.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is it safe to create httpx.AsyncClient at module import time (outside an event loop) and use it later inside an async context in httpx 0.28.1?

💡 Result:

It is generally unsafe and not recommended to create an httpx.AsyncClient at module import time. While initializing the AsyncClient object itself does not immediately require an active event loop, the client's internal mechanisms (such as connection pooling and transport initialization) are designed to operate within an asynchronous context. Creating it at the module level introduces several risks: 1. Lifecycle Management: The AsyncClient is intended to be used as a context manager (using async with) or explicitly closed using await client.aclose() to ensure resources like connection pools are properly released [1][2]. Module-level objects persist for the lifetime of the application, making it difficult to ensure they are shut down gracefully, which can lead to resource leaks and warnings about unclosed clients [3]. 2. Event Loop Binding: While newer versions of httpx have improved compatibility, many asynchronous libraries in Python bind resources to the event loop that is active when the resource is first used or initialized. Creating the client outside of an async context may lead to issues if the client inadvertently attempts to bind to an event loop that is not yet started or is not the intended loop for its operations. 3. Testing and Best Practices: Best practice dictates that the AsyncClient should be instantiated within the lifecycle of your application's event loop—typically within an async function, a startup/shutdown handler (if using a framework like FastAPI), or a fixture (if using pytest-asyncio). This ensures the client is tied to a valid event loop and is correctly closed after use [3][2]. In summary, to avoid unexpected runtime errors, resource leakage, and event loop incompatibility, you should initialize your httpx.AsyncClient inside an async function or within an appropriate lifecycle hook, rather than at the module level [3][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file and locate any AsyncClient lifecycle handling.
git ls-files 'backend/app/agents/pulse.py' 'backend/app/**' | sed -n '1,20p'
printf '\n--- pulse.py ---\n'
cat -n backend/app/agents/pulse.py

printf '\n--- AsyncClient references ---\n'
rg -n "AsyncClient|aclose\(|async with .*httpx|httpx" backend/app -S

Repository: welshDog/HyperCode-V2.4

Length of output: 10815


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- references to backend/app/agents/pulse.py symbols ---'
rg -n "\bpulse\b|\b_http\b|PulseAgent" backend/app -S

printf '\n%s\n' '--- main.py shutdown section ---'
cat -n backend/app/main.py | sed -n '180,250p'

printf '\n%s\n' '--- pulse.py surrounding context ---'
cat -n backend/app/agents/pulse.py

Repository: welshDog/HyperCode-V2.4

Length of output: 8713


Close the shared AsyncClient on shutdown. The module-level client is fine to construct, but it is never aclose()d, so the connection pool stays open for the process lifetime. Wire it into app lifespan teardown or close it explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/agents/pulse.py` at line 14, The module-level `_http`
`httpx.AsyncClient` in `pulse.py` is created once but never closed, so its
connection pool can remain open for the process lifetime. Update the app
shutdown/lifespan teardown path to call `_http.aclose()` explicitly, or wire
`_http` into the existing lifespan cleanup alongside the other app resources so
it is always closed when the app stops.

@welshDog
welshDog merged commit 6088059 into main Jul 10, 2026
2 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant