Skip to content

fix(hook): bound event-delivery teardown — flush/close can never hang process exit#78

Merged
bkrabach merged 1 commit into
mainfrom
fix/bounded-event-delivery-teardown
Jul 25, 2026
Merged

fix(hook): bound event-delivery teardown — flush/close can never hang process exit#78
bkrabach merged 1 commit into
mainfrom
fix/bounded-event-delivery-teardown

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

Summary

Fixes a production hang: a wiki-weaver engine subprocess that composes this hook finished its entire pipeline (session:end emitted) and then sat wedged 2.7 hours at 0% CPU on a single CLOSE-WAIT socket to its configured ingestion endpoint (127.0.0.1:8100). The hook's close() was blocking forever in httpx AsyncClient.aclose() with no deadline. A human had to SIGTERM it.

Root Cause

Pre-fix (handlers/logging_handler.py:1023): Unbounded await self._client.aclose() — per-request httpx timeouts do NOT govern close. Also (:1015-1018) unbounded worker-task join after cancel; (:307) create_task with no done-callback.

Fix

Bounded, best-effort teardown with two key guarantees:

  • _CLOSE_HARD_TIMEOUT = 5.0 bounds both teardown steps
  • Worker join via asyncio.wait — deliberately not wait_for (which would reintroduce the hang for cancellation-resistant steps)
  • aclose as a bounded task, cancelled + abandoned on deadline with a WARNING noting events remain durable in the local events.jsonl
  • New _retrieve_task_exception done-callback on worker + aclose tasks
    • Loop-closed races → DEBUG + swallowed
    • Others → WARNING, never unretrieved

Delivery semantics, config surface, and the local JSONL path unchanged — event delivery is documented best-effort and can now never block process exit.

Test Evidence

9 new tests in tests/test_bounded_teardown.py:

  • 8 of 9 FAIL against origin/main (regression tests catching the live bug)
  • Includes an end-to-end hung-socket simulation with a real httpx client against a real accept-then-never-respond asyncio server
  • After fix: module suite 585 passed / 0 failed
  • Repo-level: 753 passed
  • python_check clean
  • Sibling-module failures verified pre-existing (touched trees byte-identical to origin/main)

Files Changed

modules/hook-context-intelligence/handlers/logging_handler.py         |  92 ++++-
modules/hook-context-intelligence/tests/test_bounded_teardown.py      | 374 +++++++++++++++++++++
modules/hook-context-intelligence/tests/test_hot_path.py             |   9 +

Generated with Amplifier

…an never block process exit

A host process (wiki-weaver engine subprocess) finished its entire pipeline
(pipeline:complete -> session:end) and then wedged for 2.7 hours at 0% CPU:
a single CLOSE-WAIT socket to its CI destination was held by httpx
AsyncClient.aclose(), which _DestinationDispatcher.close() awaited with NO
deadline. Related recurring signature: "Task exception was never retrieved
... RuntimeError('Event loop is closed')" from aclose -- teardown tasks
dying unretrieved.

Root cause (logging_handler.py):
- close() line ~1023: `await self._client.aclose()` was unbounded; on a
  half-closed (CLOSE-WAIT) connection it blocks forever.
- close() worker join: `await self._worker_task` after cancel() blocks
  until cancellation completes -- unbounded if the worker misbehaves.
- _ensure_worker(): asyncio.create_task() with no done-callback, so any
  terminal exception was left unretrieved.

Fix (surgical; delivery semantics, config surface, and the local JSONL
write path unchanged):
- New _CLOSE_HARD_TIMEOUT (5s) bounds each previously-unbounded teardown
  step via asyncio.wait (NOT wait_for -- wait_for blocks until the inner
  cancellation completes, reintroducing the hang for a step that resists
  cancellation). On deadline: WARNING + abandon; delivery is documented
  best-effort and must never block exit.
- aclose() runs as a task with a done-callback that retrieves late
  exceptions: the "Event loop is closed" teardown race is logged at DEBUG
  and swallowed; anything else surfaces at WARNING -- never propagated,
  never left unretrieved.
- Worker task gets the same done-callback at creation.
- HTTP client timeouts (connect/read/write/pool) were already explicit and
  are unchanged.

Tests (tests/test_bounded_teardown.py, 9 new -- 8 fail against the old
code): hung aclose bounded; timeout -> WARNING + clean return; aclose
resisting cancellation still bounded; loop-closed race swallowed at DEBUG;
worker-crash exception retrieved; cancelled worker silent; worker swallowing
cancellation bounded + abandoned; end-to-end against a real accept-then-
never-respond server; happy-path close unchanged. Hot-path AST allowlist
extended for the two zero-cost calls (partial, add_done_callback) on the
worker-start branch.

Module suite: 585 passed, 0 failed (was 577 passed + 8 new-test failures
pre-fix). Repo-level suite: 753 passed.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@bkrabach
bkrabach merged commit 89bb6e0 into main Jul 25, 2026
7 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.

2 participants