Skip to content

fix(mcp): close the exit stack when an HTTP MCP connect fails (100% CPU spin)#5531

Open
MahmutDehhan wants to merge 33 commits into
odysseus-dev:mainfrom
MahmutDehhan:fix/mcp-exit-stack-basexception
Open

fix(mcp): close the exit stack when an HTTP MCP connect fails (100% CPU spin)#5531
MahmutDehhan wants to merge 33 commits into
odysseus-dev:mainfrom
MahmutDehhan:fix/mcp-exit-stack-basexception

Conversation

@MahmutDehhan

Copy link
Copy Markdown

Summary

_connect_stdio and _connect_sse guard their AsyncExitStack with finally: if not registered: await stack.aclose(). _connect_http does not — and a failed connect there leaks the stack, along with the anyio task group entered inside streamablehttp_client.

The failure chain

The abandoned stack is eventually finalized by the garbage collector, from a different task than the one that entered it. anyio's CancelScope.__exit__ raises:

RuntimeError: Attempted to exit cancel scope in a different task than it was entered in

before reaching self._tasks.remove(self._host_task). The completed host task is never removed, _active stays True, and _deliver_cancellation re-arms get_running_loop().call_soon(...) on every iteration of the event loop, forever.

loop._ready therefore never empties, so _run_once computes a zero timeout and calls selector.select(0). The process spins:

epoll_wait   ~149,000 calls/sec
timeout      0x00000000 on 441,269 / 441,269 calls   (100%)
sys_exit     0x0 — zero fds ready, every single call
hot DSO      68% libpython3.14.so, _PyEval_EvalFrameDefault

On our deployment this pinned a full CPU core continuously for over seven days — across a container recreate and a host reboot — while the container healthcheck reported healthy the entire time.

It re-fires on every startup: our odysseus and its MCP server run in different compose projects, so nothing orders them, the MCP URL does not resolve yet, and session.initialize() raises httpcore.ConnectError [Errno -2].

Why finally, not except Exception

The failure surfaces as CancelledError, which derives from BaseException — so the existing handler below:

except Exception as e:
    logger.error(f"Failed to connect HTTP MCP server {name} ({server_id}): {e}")

never fires. Confirmed in production: that line does not appear once in the entire log history, despite the connect failing at every single boot. Using except Exception here would ship the bug.

Reproduced in the running image against an NXDOMAIN host:

no close / except Exception   ->  RuntimeError      ->  CPU 99%   (spinning)
finally + aclose              ->  clean teardown    ->  CPU  4%   (idle)

After deploying the fix

before after
CPU (sustained) 101% 0.15%
ctxt switches (vol / nonvol) 5,719 / 326,576 — never yields 1,803 / 57 — sleeps normally
cancel-scope RuntimeError 37 in log history 0
the error handler above never fired now logs the failure correctly

The change follows the exact registered + finally idiom the other two transports already use.

pewdiepie-archdaemon and others added 30 commits July 7, 2026 11:32
…n/sync-main-into-dev-20260707

chore: sync tested main into dev
…dev#5290)

chat_stream() references `_explicit_web_intent` in three places
(disabled-tools gating, global-disabled web allowance, and the
per-turn tool filter) but the assignment was dropped during a
branch merge. Every chat request raised

    NameError: name '_explicit_web_intent' is not defined

at routes/chat_routes.py, surfacing to the client as a bare
"Internal Server Error" before any LLM call was made — chat was
fully broken on dev and main.

Restore the original definition, computed from the already-derived
tool intent, immediately before its first use:

    _explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pewdiepie-archdaemon#5205)

build_user_content derived the data-URL subtype from the file extension
only (image_format = ext[1:]). An extensionless upload (e.g. a pasted
screenshot) has ext == "", producing "data:image/;base64,..." with an
empty subtype (invalid per RFC 2046) that vision/audio endpoints reject,
silently dropping the attachment. Fall back to the resolved MIME subtype
when the extension is missing; present extensions are unchanged.
…ysseus-dev#5274)

request_flags derives (agent, vision) and does last.get("role") after only
a truthy check. A client can send a bare-string message element
("messages": ["hi"]), and the vision loop right below already guards each
element with isinstance — so the .get() on a non-dict last element is an
oversight that raises AttributeError on every Copilot-proxied request with
such a body.

Use isinstance(last, dict) to match the loop's own guard.

Fixes odysseus-dev#5273

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…eb-search-explicit-deny

fix(chat): require explicit web search enable
…ysseus-dev#4906)

The self-host troubleshooting cookbook has been implemented in
docs/setup.md under "Common self-host traps" (PR odysseus-dev#4834).

Fixes odysseus-dev#4900

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(reminders): sanitize ntfy Title header to ASCII

The ntfy notification Title header was set directly from the note title.
HTTP headers must be ASCII, so a title containing emoji or other
non-ASCII characters caused httpx to raise UnicodeEncodeError, which
was swallowed by the surrounding try/except — so the reminder silently
failed and no notification was ever sent.

Sanitize the title with encode('ascii', 'replace') before placing it
into the header, replacing unsupported characters with '?'. This is
standard practice for HTTP header values. The note body is unaffected
(it is sent as request content, not a header) and continues to support
full UTF-8.

* fix(reminders): also truncate ntfy title to 200 chars for header safety

* style: compact ntfy header comment

---------

Co-authored-by: Am-GJ <Am-GJ@users.noreply.github.com>
Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
…seus-dev#5264)

The edit/delete/pause/run actions of do_manage_tasks gated ownership with
`if owner and task.owner and task.owner != owner`. The middle term made the
check a no-op whenever task.owner was null/empty — the state a scheduled task
sits in when it was created in no-login mode (or via the localhost middleware
bypass) before the periodic legacy-owner sweep reassigns it to the admin user.
Any authenticated user's agent could then edit, delete, pause, or run another
tenant's owner-less task; edit+run lets an attacker rewrite the task prompt and
execute it in the scheduler's agent context.

The sibling `list` action already scopes with an exact `owner == owner` filter,
so the mutators were strictly more permissive than the reader. Drop the middle
term so the guard fails closed on owner-less rows for authenticated callers,
matching `list` and the calendar/notes/gallery/session null-owner gates. Auth
disabled (owner falsy) and same-owner access are unchanged.
…odysseus-dev#2732)

_store_email_flag and _move_email_message (used by the archive / delete / move /
mark-read endpoints) had an else branch that, when _uid_exists returned False,
ran conn.store(uid, ...) / conn.copy(uid, ...) followed by a folder-wide
conn.expunge(). But imaplib's plain store()/copy() take a message SEQUENCE
NUMBER, not a UID, so the op landed on whichever message occupied sequence
position == the UID value, and the expunge then permanently removed it. A stale
cached UID (or a server whose UID probe misbehaves) therefore deleted an
unrelated email instead of reporting 'not found'.

There is no valid case where treating a UID as a sequence number is correct, so
drop the fallback: when the UID isn't present, return False — callers already
surface 'Email not found'. Only the UID command path remains.

Sibling of odysseus-dev#1874 (which fixes the auto-spam poller's _imap_move in
email_helpers.py); this covers the user-facing endpoints in email_routes.py.
Part of odysseus-dev#2124.
…s-dev#5110)

_scheduled_poll_once selected rows WHERE status='pending' and only wrote
status='sent'/'failed' after the SMTP send and IMAP append completed -
no atomic claim in between. Two overlapping callers (the in-process 30s
poller and an externally cron/systemd-driven 'odysseus-mail
poll-scheduled', or the CLI run manually) can both SELECT the same
pending row before either UPDATEs it, and both send it. _start_poller's
own docstring already names this exact risk ('avoid two copies of
_scheduled_poll_once racing on the same SQLite') but nothing in the code
enforced it - it was advisory only.

Add an atomic per-row claim: UPDATE ... SET status='sending' WHERE
id=? AND status='pending', proceeding only when rowcount == 1. The
loser of the race sees rowcount == 0 and skips the row instead of
sending a duplicate.

Adds a regression test that drives two real threads through the real
_scheduled_poll_once against a shared SQLite file, synchronized with a
barrier and a widened send-path window, and asserts exactly one send
fires. Reverting the fix makes the test fail reliably (5/5 runs); with
the fix it passes reliably (5/5 runs).

Fixes odysseus-dev#5109
…ysseus-dev#5107)

_load() returned whatever json.loads() produced without checking it was a
dict; _update() did the same before assigning data[key] = value. If the
oauth_tokens column ever held a JSON array or primitive (DB corruption,
manual edit, migration drift), _load()'s callers crashed with
AttributeError on .get(), and _update() crashed with TypeError trying to
item-assign into a list/string/int.

Validate the parsed value is a dict in both methods, falling back to {}
otherwise - same recovery behavior already used elsewhere in the codebase
for this exact JSON-blob-is-not-a-dict shape (_parse_tool_args,
_is_sensitive_path's siblings).

Adds 3 regression tests for _load, get_tokens, and _update against a
non-dict oauth_tokens value.

Fixes odysseus-dev#5082
…v#4793)

_sync_blocking (src/caldav_sync.py) and _writeback_blocking
(src/caldav_writeback.py) each open their own caldav.DAVClient via
_build_dav_client, but never close it. The client owns an HTTP session
with a pooled connection; without a close() that connection is held until
process exit.

Previously the fix added explicit client.close() calls before each early
return and at the end of the DB finally block. This still leaked the
client when SessionLocal() raised before the DB try/finally was entered.

Now _sync_blocking wraps the entire post-construction path in an outer
try/finally that calls client.close() unconditionally, covering:
  - AuthorizationError / NotFoundError early return
  - URL-fallback failure early return
  - no-calendars early return
  - normal return after sync
  - SessionLocal() construction failure (new regression coverage)

_writeback_blocking already used a try/finally (unchanged).

- src/caldav_sync.py: replace scattered client.close() calls with a
  single outer try/finally block around the discovery + DB sync path
- tests/test_caldav_client_cleanup.py: add CalendarDeletedEvent to the
  database stub; add regression test for SessionLocal() failure path

Closes odysseus-dev#4593
…ysseus-dev#4796)

* fix(calendar): trust operator CA bundle in CalDAV test_connection

The pre-flight test used httpx with trust_env=False, which ignored
SSL_CERT_FILE/REQUESTS_CA_BUNDLE. Self-signed CalDAV servers that
the real sync accepts (via caldav lib → requests → honors bundle)
were rejected by the test with CERTIFICATE_VERIFY_FAILED.

Build an explicit SSL context that loads the operator's CA bundle
and clears VERIFY_X509_STRICT (which rejects certs without a
keyUsage extension — common in self-signed setups). SSRF guards
(follow_redirects=False, trust_env=False) are preserved.

Fixes odysseus-dev#4795
Fixes odysseus-dev#4779

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(calendar): add regression tests and edge case handling for SSL context

Per review: add route-level regression tests covering SSL_CERT_FILE
precedence, VERIFY_X509_STRICT clearing, missing bundle graceful
fallback, and empty env var handling. Also log a warning when the
configured CA bundle path doesn't exist instead of silently falling
back to system CAs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(calendar): rewrite SSL tests to exercise route handler directly

Addresses review feedback: tests now use FastAPI TestClient to hit the
actual test_connection route, capturing the verify= kwarg passed to
httpx.AsyncClient. This ensures the route's SSL context construction
is covered, not a test-side duplicate.

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

* ci: retrigger CI (redirect hardening test is a CI-env flake, passes locally)

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

* fix(tests): remove module-level sys.modules stubs that leaked into other tests

The collection-time MagicMock stub of `caldav` replaced the real library
for every later test in the same process — test_caldav_redirect_hardening's
DAVClient became a mock that never sent the PROPFIND, failing its
must-reach-the-public-server assertion in CI. conftest already pre-imports
the real sqlalchemy/core.database, and the route's lazy imports are patched
per-request, so the stub block was both harmful and unnecessary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(calendar): verify exact CA bundle precedence

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
…-call (odysseus-dev#5106)

stream_agent_loop's per-tool drain loop had no cleanup path for early
generator close. Starlette throws GeneratorExit into the generator at
whatever await point it's suspended on when the SSE client disconnects
(aclose()) - here that's 'await _progress_q.get()' inside the drain
loop, before the final 'await _tool_task' line ever runs. The task,
which wraps execute_tool_block, was left running unawaited and
uncancelled.

For bash/python tools this orphans the underlying subprocess:
subprocess_tools.py already has correct CancelledError handling that
kills the child process, but only runs if the task is actually
cancelled. A client disconnecting mid long-running command left that
subprocess running server-side for its full duration with nothing
left to reap it.

Wrap the drain loop in try/finally: on early exit, cancel _tool_task
(if not already done) and await it so the existing subprocess-kill
path runs.

Adds a regression test that drives the real stream_agent_loop with a
fake tool handler, closes the generator mid tool-call (mirroring what
Starlette does on disconnect), and asserts the handler observed
cancellation immediately - not merely via asyncio.run()'s own
end-of-run task cleanup, which would mask the bug.

Fixes odysseus-dev#5105
)

* fix(chat): Expand user chat bubble edit textbox width

- Update user chat bubble width from `fit-content` to `85%` to ensure consistency with the AI chat bubble edit textbox width.

* style(chat): Refine user message bubble width logic

- Change general bubble width to `fit-content`
- Set width to 85% specifically for user messages containing a `textarea`
…hes (odysseus-dev#5149)

conn.search() / conn.fetch() operate on volatile positional sequence
numbers that shift whenever messages are deleted or expunged. Three call
sites in the sig-learner (_pull_headers, _fetch_bodies) and morning-brief
email section were storing these as "uid" and reusing them in subsequent
fetches — causing wrong-message returns or NO responses if another client
modified the mailbox concurrently.

Replaced with conn.uid("SEARCH", ...) / conn.uid("FETCH", ...), which use
persistent RFC 3501 UIDs. _scan_one (urgency action) already did this
correctly; these were the remaining callers.

The reproduction window is narrow (requires concurrent deletion between
search and fetch), so the fix is verified by regression tests rather than
manual end-to-end: _SpyImap raises AssertionError if conn.search() or
conn.fetch() are called instead of conn.uid().
…ls (odysseus-dev#5420)

* fix: harden stabilization attachment and agent guards

* fix(uploads): preserve durable references during cleanup

* fix(uploads): close cleanup and compaction races
…ewdiepie-archdaemon#4411) (pewdiepie-archdaemon#5160)

* docs: update static/js/MODULE_SUMMARY.md to reflect current ES6 frontend

Rewrite the stale module summary to match the current no-build,
ES6-module frontend architecture. Adds coverage of app.js orchestration,
the chat/SSE pipeline (chat.js, chatStream.js, chatRenderer.js,
streamingRenderer.js), new subsystems (research/, compare/, document
streaming, cookbook*, skills.js), and removes the obsolete <script> load
order assumptions.

* cleanup: remove dead MEMORY_DOC / memory_doc paths (closes pewdiepie-archdaemon#4411)

Removes the unused MEMORY_DOC constant and the matching DataConfig
memory_doc field / set_data_paths entry. No runtime code imports or
references these paths, so this is a no-behavior-change dead-code
cleanup under the storage-architecture tracker pewdiepie-archdaemon#4377.
* fix(db): restrict data/app.db to 0600

app.db holds bearer-token hashes, bcrypt password hashes, and encrypted
provider keys but was created under the default umask (0644 -> world-readable),
unlike .app_key/vault/integrations which are already 0600 via safe_chmod.

init_db() now chmods the SQLite file to 0600 right after create_all (POSIX
only; no-op on Windows, skipped for Postgres / in-memory). Unconditional and
idempotent, so it also re-locks already-deployed 0644 installs on next
startup. The transient rollback journal inherits 0600 from the parent file at
creation - no sidecar handling needed; -wal/-shm don't exist until WAL is
enabled (odysseus-dev#4409 C4) and inherit the same mode then.

Satisfies Rule B, unblocking odysseus-dev#4413 and the vault/integration secret moves.
Mirrors src/secret_storage.py:43-45.

Verified: security + DB-permission suites pass; 6 pre-existing visual_report
failures (missing markdown/nh3 deps) are unrelated.

Closes odysseus-dev#4407

* fix(db): harden SQLite path parsing and re-lock sidecars

Address review feedback on odysseus-dev#4420.

P2: derive the file to chmod from engine.url (SQLAlchemy's parsed URL)
via _sqlite_db_path(), instead of DATABASE_URL.replace("sqlite:///", "").
A driver-qualified URL (sqlite+pysqlite://) or one carrying query args
(?cache=shared) previously slipped past the prefix check / string slice
and left the DB world-readable; the parsed path resolves correctly and
drops the query.

P3: re-lock stale -wal/-shm/-journal sidecars to 0o600 at startup. The
main file is chmod'd first, so any sidecar SQLite creates afterward
inherits 0o600, but a -wal/-shm left world-readable by an older 0o644
install (once WAL was enabled) could still expose DB pages. Absent
sidecars are the normal case, not an error.

Tests: unit-test _sqlite_db_path across driver/query/memory/postgres URL
forms, and a subprocess test asserting stale 0o644 -wal/-shm are
re-locked on startup.

* fix(db): handle sqlite file URI app db permissions

* fix(db): close remaining SQLite permission bypasses

---------

Co-authored-by: Ethan <23321960+0xLeathery@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
bitboody and others added 3 commits July 13, 2026 08:56
…mporter (odysseus-dev#5261)

* Harden skill importer against SSRF: block private targets + revalidate redirects per hop

The skill importer validated only the initial URL with the lenient SSRF guard
(block_private=False) and then fetched with follow_redirects=True, so a 3xx to
an internal/metadata address (169.254.169.254, 127.0.0.1, RFC-1918) was still
connected to — inconsistent with the hardened services/search/content.py
:_get_public_url path.

Add a _get_checked() helper that follows redirects manually and re-runs the
SSRF guard with block_private=True on every hop, and route all three fetch
sites (skills.sh unwrap, _fetch_bytes, _list_github_dir) through it. GitHub's
own redirects and the final-host _assert_github_url checks are preserved.

Adds hermetic regression tests (IP-literal hosts, faked HTTP layer) and updates
the existing mock signature for the new block_private kwarg.

Defense-in-depth: the endpoint is admin-gated (require_admin) and admins are
trusted per THREAT_MODEL.md, so this is not a cross-boundary vulnerability.

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

* test: enforce follow_redirects=False invariant in mock client

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
_connect_stdio and _connect_sse guard their AsyncExitStack with
`finally: if not registered: await stack.aclose()`. _connect_http does not, and a failed
connect there leaks the stack -- along with the anyio task group entered inside
streamablehttp_client.

The abandoned stack is eventually finalized by the garbage collector, from a DIFFERENT
task than the one that entered it. anyio's CancelScope.__exit__ raises

    RuntimeError: Attempted to exit cancel scope in a different task than it was entered in

BEFORE reaching `self._tasks.remove(self._host_task)`. The completed host task is never
removed, `_active` stays True, and `_deliver_cancellation` re-arms
`get_running_loop().call_soon(...)` on every iteration of the event loop, forever.

`loop._ready` therefore never empties, so `_run_once` computes a zero timeout and calls
`selector.select(0)`. The process spins:

    epoll_wait   ~149,000 calls/sec
    timeout      0x00000000 on 441,269 / 441,269 calls  (100%)
    sys_exit     0x0 -- zero fds ready, every single call
    hot DSO      68% libpython3.14.so, _PyEval_EvalFrameDefault

On our deployment this pinned a full CPU core continuously for over seven days -- across a
container recreate and a host reboot -- while the container's healthcheck reported healthy
the entire time. It re-fires on every startup: our odysseus and its MCP server run in
different compose projects, so nothing orders them, the MCP URL does not resolve yet, and
`session.initialize()` raises `httpcore.ConnectError [Errno -2]`.

Note the fix must be `finally`, not `except Exception`. The failure surfaces as
CancelledError, which derives from BaseException, so the existing
`except Exception as e: logger.error("Failed to connect HTTP MCP server ...")` handler
below never fires -- confirmed in production, where that line does not appear once in the
entire log history despite the connect failing at every boot.

Reproduced in the running image against an NXDOMAIN host:

    no close / except Exception  -> RuntimeError -> CPU 99%  (spinning)
    finally + aclose             -> clean teardown -> CPU  4%  (idle)

After deploying: 101% -> 0.15% CPU sustained; voluntary/nonvoluntary context switches went
from 5,719/326,576 (never yielding) to 1,803/57 (a normally sleeping event loop); the
cancel-scope RuntimeError disappeared from the logs (37 occurrences in the prior history,
0 since); and the previously-unreachable error handler now logs the connect failure
properly.
Copilot AI review requested due to automatic review settings July 14, 2026 16:23
@github-actions

Copy link
Copy Markdown

⚠️ PR description — action needed

The following required sections are missing or incomplete. Please update the PR description to address them:

  • Linked Issue — add a reference like Fixes #NNN, a bare #NNN, or a link to the issue.
  • Type of Change — check at least one box.
  • Checklist — check the duplicate-search box to confirm you searched existing issues and PRs.
  • How to Test — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").

This comment is deleted automatically once all sections are complete.

@github-actions github-actions Bot added the needs work PR description incomplete — please update before review label Jul 14, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs work PR description incomplete — please update before review

Projects

None yet

Development

Successfully merging this pull request may close these issues.