Skip to content

fix(gateway): route the hook event bridge through the leak-safe threadsafe helper - #85583

Open
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/gateway-event-callback-threadsafe-bridge-26584
Open

fix(gateway): route the hook event bridge through the leak-safe threadsafe helper#85583
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/gateway-event-callback-threadsafe-bridge-26584

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

TurnRunner._event_callback_sync (gateway/run.py) is the sync → async bridge that carries lifecycle hook events from the agent thread onto the gateway's turn loop. It called asyncio.run_coroutine_threadsafe directly inside a swallowing except Exception:

def _event_callback_sync(self, event_type: str, context: dict) -> None:
    ctx = self._ctx
    try:
        asyncio.run_coroutine_threadsafe(
            ctx._hooks_ref.emit(event_type, context),
            ctx._loop_for_step,
        )
    except Exception as _e:
        logger.debug("event_callback hook error: %s", _e)

When the loop is closing or gone, run_coroutine_threadsafe raises inside loop.call_soon_threadsafe before the loop takes ownership of the coroutine. Catching the exception is not enough: the hooks.emit(...) coroutine is left in CORO_CREATED, so the hook silently never fires and its frame leaks with RuntimeWarning: coroutine ... was never awaited. This PR routes the bridge through agent.async_utils.safe_schedule_threadsafe, which closes the coroutine on both failure branches.

This is finishing a sweep that already merged, not a new opinion. Commit 4e89c53082bfix(async): close unscheduled coroutines in all threadsafe bridges (#26584), 23 files, 2026-05-15 — introduced safe_schedule_threadsafe and converted every threadsafe bridge in the repo. This bridge is not a site that sweep missed; it did not exist yet. git log -S'_hooks_ref.emit(event_type, context)' -- gateway/run.py pins its introduction to e76e7b50730 (feat(hooks): session:compress event_callback for MemPalace sync), committed 2026-06-16 — a month after the sweep — and git merge-base --is-ancestor 4e89c53082b e76e7b50730 confirms the ordering. The invariant was already repo-wide; this one bridge regressed it on arrival.

The contrast is in-file and one commit wide. _step_callback_sync, twelve lines above, emits onto the same ctx._loop_for_step via the same ctx._hooks_ref.emit(...) and already goes through the helper with logger= / log_message=. git blame attributes every line of both method bodies to the same commit, 1a3a9de630a (refactor(gateway): extract run_sync onto TurnRunner). This change gives the event bridge the sibling's shape, with an event-specific log_message so a scheduling failure stays attributable.

Why this is a live path, not a paper one. Unlike step_callback, which is attached only when hooks are loaded —

agent.step_callback = ctx._step_callback_sync if ctx._hooks_ref.loaded_hooks else None   # gated
agent.event_callback = ctx._event_callback_sync                                          # unconditional

— the event bridge is wired unconditionally, so it is on the production turn path for every gateway turn. Its real emitters are agent/conversation_compression.py and agent/codex_runtime.py, both firing session:compress. So a user-authored compress hook (the introducing commit's own use case is memory sync) is dropped exactly when the gateway is shutting down — the moment persisting that state matters most.

Sibling sweep. This is the last raw asyncio.run_coroutine_threadsafe left in the Python core (cli.py, hermes_cli/, gateway/, tui_gateway/, utils.py, hermes_state*.py). The only other *_threadsafe call in gateway/run.py is loop.call_soon_threadsafe(shutdown_handler, None), which passes a plain callable and constructs no coroutine, so it is outside this invariant. Nothing else to widen to.

Rival disclosure, stated as a checked-N rather than an absence claim: I checked the diffs of 569 open PRs touching gateway/run.py, enumerated in both created-asc and created-desc order, and grepped each one's patch for _event_callback_sync / _step_callback_sync / agent.event_callback; 15 carried one of those symbols and every one of them is a hunk-header context anchor or an edit to the callback wiring block, not to this method body (7 PRs whose patch was too large for the API were checked with gh pr diff). The by-file census is a floor, not a ceiling — dense slices return exactly the API's cap of rows — so I am not claiming "no rivals", only that none of the 569 diffs I read modifies this method.

Related Issue

No filed issue. This completes the merged sweep in #26584 (4e89c53082b) on a bridge introduced after it; the ancestry is proved above rather than asserted.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/run.pyTurnRunner._event_callback_sync now schedules through safe_schedule_threadsafe(..., logger=logger, log_message="event_callback hook scheduling error") instead of a raw asyncio.run_coroutine_threadsafe in a swallowing except Exception. The returned future is deliberately left unconsumed, matching _step_callback_sync.
  • tests/gateway/test_event_callback_bridge.py (new) — 5 tests: two regression tests for the failure branches, and three invariant guards.

Three commits, each independently meaningful and green on its own:

  1. fix(gateway): the production change.
  2. test(gateway): the loop-closed branch.
  3. test(gateway): the loop-missing branch plus the happy-path/contract guards.

How to Test

uv run --with pytest --with pytest-asyncio python3 -m pytest tests/gateway/test_event_callback_bridge.py -v

Fail-before / pass-after, verified explicitly by restoring gateway/run.py from clean origin/main with the new tests in place:

test on clean origin/main with this PR
test_closed_step_loop_closes_the_event_coroutine FAILCORO_CREATED != CORO_CLOSED pass
test_missing_step_loop_closes_the_event_coroutine FAILCORO_CREATED != CORO_CLOSED pass
test_live_step_loop_still_delivers_the_event pass (invariant guard) pass
test_bridge_stays_non_raising_and_returns_none[closed] pass (invariant guard) pass
test_bridge_stays_non_raising_and_returns_none[missing] pass (invariant guard) pass

The origin/main run also emits RuntimeWarning: coroutine '_RecordingHooks.emit' was never awaited at teardown — the user-visible symptom — which disappears with the fix.

The two green-before tests are there on purpose: they prove this is a pure hardening of the failure branches (a live loop still receives and runs the hook coroutine; the bridge stays non-raising and still returns None), not a behaviour change.

Adjacent suites run green: tests/gateway/test_turn_context.py, tests/gateway/test_session_title_rename_lane.py, tests/gateway/test_skip_context_files_wiring.py, tests/gateway/test_fallback_chain_reload.py, tests/agent/test_async_utils.py, tests/gateway/test_shutdown_flush.py, tests/gateway/test_13121_shutdown_inflight_transcript_flush.py, tests/gateway/test_53175_cleanup_off_loop.py, tests/gateway/test_gateway_shutdown.py.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Contract Protected

Invariant: a coroutine handed to a threadsafe bridge is always disposed of — scheduled onto the loop, or closed. It is never left in CORO_CREATED.

Known-bad inputs (both now covered): ctx._loop_for_step is a closed loop; ctx._loop_for_step is None.

Future-input coverage: the invariant is enforced by the shared helper rather than by this call site, so a later refactor of the emit payload, the event name, or the hook registry cannot silently reintroduce the leak.

Negative case: test_live_step_loop_still_delivers_the_event fails if the change ever suppresses delivery on a healthy loop, so the guard cannot be satisfied by simply not emitting.

…dsafe helper

`TurnRunner._event_callback_sync` is the sync -> async bridge that carries
lifecycle hook events from the agent thread onto the gateway's turn loop. It
called `asyncio.run_coroutine_threadsafe` directly inside a swallowing
`except Exception`, so when the loop was closing or gone the call raised
before the loop ever took ownership of `hooks.emit(...)` -- the event was
silently dropped and the coroutine was left created-but-never-awaited, leaking
its frame and emitting `RuntimeWarning: coroutine ... was never awaited`.

That is precisely the failure `agent.async_utils.safe_schedule_threadsafe`
was added to absorb: commit 4e89c53, `fix(async): close unscheduled
coroutines in all threadsafe bridges (NousResearch#26584)`, converted every threadsafe
bridge in the repo on 2026-05-15. This bridge is not a site that sweep missed
-- it did not exist yet. `git log -S'_hooks_ref.emit(event_type, context)'`
pins its introduction to e76e7b5 (`feat(hooks): session:compress
event_callback for MemPalace sync`), which landed on 2026-06-16, a month after
the sweep; `git merge-base --is-ancestor 4e89c53 e76e7b5` confirms the
ordering. So the invariant was already established repo-wide and this one
bridge regressed it on arrival.

The contrast is in-file and one commit wide: `_step_callback_sync`, twelve
lines above, emits onto the same `ctx._loop_for_step` via the same
`ctx._hooks_ref.emit(...)` and already goes through the helper with
`logger=`/`log_message=`. `git blame` attributes every line of both method
bodies to the same commit, 1a3a9de. This change gives the event bridge the
same shape, with an event-specific `log_message` so a scheduling failure is
still attributable.

Why it matters at runtime rather than only on paper: unlike `step_callback`,
which is attached only when hooks are loaded, `agent.event_callback =
ctx._event_callback_sync` is wired unconditionally, so this bridge is on the
production turn path for every gateway turn. Its real emitters are
`agent/conversation_compression.py` and `agent/codex_runtime.py`, both firing
`session:compress` -- so a user-authored compress hook (the introducing
commit's own use case is memory sync) is dropped exactly when the gateway is
shutting down, which is when persisting that state matters most.

No behaviour change on the happy path: the helper returns the same future,
which this bridge deliberately does not consume, matching the sibling.
…coroutine

Regression coverage for the shutdown race on `TurnRunner._event_callback_sync`:
a `session:compress` event fired from the agent thread while the gateway is
tearing its turn loop down.

The assertion is deliberately on the coroutine's *state*, not on the absence of
an exception. `asyncio.run_coroutine_threadsafe` raises inside
`loop.call_soon_threadsafe` before the loop adopts the coroutine, so a bridge
that only catches the exception still leaves `hooks.emit(...)` in
`CORO_CREATED` -- the hook silently unfired and its frame leaked. The test also
asserts no `coroutine ... was never awaited` RuntimeWarning is recorded, which
is the symptom that surfaces in a user's log.

Verified fail-before/pass-after against unmodified origin/main: the state
assertion fails with `CORO_CREATED != CORO_CLOSED` and pytest reports the
RuntimeWarning at teardown.

The harness fakes only the hook registry and the loop: `_RecordingHooks.emit`
is a real coroutine function, so the bridge is handed a genuine coroutine
object exactly as production does, and the runner is built through the same
`TurnContext`/`TurnRunner` seam the existing tests/gateway/test_turn_context.py
uses. No network, no gateway boot.
…py path

`safe_schedule_threadsafe` distinguishes two failure branches and the previous
commit only covered one. `ctx._loop_for_step` can be `None` outright -- it is
the `TurnContext` default -- and that path never reaches asyncio at all: the
helper closes the coroutine and returns immediately, where the bare bridge
dereferenced `None.call_soon_threadsafe` and leaked the coroutine on the way
out through the swallowing `except`. Reds independently on unmodified
origin/main with `CORO_CREATED != CORO_CLOSED`.

Two invariant guards accompany it, both of which pass on origin/main by
construction -- they exist to prove the change is a pure hardening of the
failure branches rather than a behaviour change:

  * a live loop still receives and *runs* the emitted coroutine, so hook
    delivery on the normal turn path is unaffected; and
  * the bridge remains non-raising and still returns `None` on both failure
    branches, i.e. the future is deliberately left unconsumed, matching the
    sibling `_step_callback_sync`.
Copilot AI lite review requested due to automatic review settings August 13, 2026 19:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a shutdown-race leak in the gateway’s sync→async hook event bridge by routing TurnRunner._event_callback_sync through the existing leak-safe scheduling helper (agent.async_utils.safe_schedule_threadsafe). This aligns the event bridge with the repo-wide invariant established in the earlier threadsafe-bridge sweep and matches the sibling _step_callback_sync pattern in gateway/run.py.

Changes:

  • Update TurnRunner._event_callback_sync to schedule hook coroutines via safe_schedule_threadsafe(...) (closing unscheduled coroutines on failure) instead of calling asyncio.run_coroutine_threadsafe inside a swallowing try/except.
  • Add a focused gateway test suite that asserts the coroutine is closed (not leaked) when the loop is closed or missing, and preserves the non-raising/returns-None contract plus the happy-path delivery.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
gateway/run.py Switch event hook scheduling to safe_schedule_threadsafe to prevent leaked, never-awaited coroutines during loop shutdown/unavailability.
tests/gateway/test_event_callback_bridge.py New regression + invariant tests covering closed-loop and missing-loop failure branches and guarding happy-path behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 13, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(gateway): route the hook event bridge through the leak-safe threadsafe helper

  1. Good fix, verify the in-coroutine error path — the old except Exception only wrapped run_coroutine_threadsafe (scheduling); the returned future was discarded, so any exception raised inside the scheduled emit() coroutine was already silently swallowed. Confirm safe_schedule_threadsafe consumes the future and logs in-loop exceptions, so the behavior isn't a regression hiding hook errors that previously "disappeared".
  2. Log-level check for the shutdown race — the closed/missing-loop branches now log via the helper. Confirm those fire at debug (not warning/error) during normal gateway teardown, or shutdown will spam error logs on every turn that races the loop.
  3. Test determinismtest_live_step_loop_still_delivers_the_event polls with time.sleep(0.02) up to 5s. Within the flake policy's loose-bounds rule it is acceptable, but a sync delivery (e.g. run_until_complete on a wait_for) or an asyncio.Event would make it fully deterministic and faster.

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

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants