Skip to content

fix(photon): stop sidecar-crash fatal handler from cancelling its own supervisor task - #73170

Closed
ygd58 wants to merge 1 commit into
NousResearch:mainfrom
ygd58:fix/photon-sidecar-self-cancel-race
Closed

fix(photon): stop sidecar-crash fatal handler from cancelling its own supervisor task#73170
ygd58 wants to merge 1 commit into
NousResearch:mainfrom
ygd58:fix/photon-sidecar-self-cancel-race

Conversation

@ygd58

@ygd58 ygd58 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Context

Fixes #73159.

Problem

When the Photon sidecar exits unexpectedly, _supervise_sidecar() (running as self._sidecar_supervisor_task) correctly detects SIDECAR_CRASHED and calls self._notify_fatal_error(). The Gateway's fatal-error handler answers by calling adapter.disconnect() -> _stop_sidecar() -- from INSIDE the very task executing this whole chain.

_stop_sidecar() unconditionally cancelled self._sidecar_supervisor_task. Cancelling the currently-running task raises CancelledError at its own next await point. Since CancelledError inherits from BaseException, the Gateway's except Exception guards don't catch it -- the handler aborts before ever reaching the "queue for background reconnection" step. Photon then stays permanently dead until a manual restart.

Fix

In _stop_sidecar()'s cleanup, check whether self._sidecar_supervisor_task is asyncio.current_task() before cancelling it. When we're inside the supervisor's own call stack, it's already finishing on its own once _notify_fatal_error() returns, so skip the cancel and just clear the reference.

This mirrors an existing precedent in the same file: disconnect() already guards its OTHER task-cancellation (self._sidecar_health_task) the same way, just not the supervisor task in _stop_sidecar().

Verification

Per the issue's note that existing tests mock out _notify_fatal_error() (so this chain was never exercised), added two tests that drive the REAL chain: one confirms the task completes without CancelledError and the post-disconnect reconnect-queue step actually runs; a second confirms external cleanup (a different task) still correctly cancels a running supervisor. Reverting only the fix (keeping the test) reproduces the exact CancelledError from the bug report.

2 new tests pass; 113/113 in the full tests/plugins/platforms/photon/ directory (no regression).

… supervisor task

Fixes NousResearch#73159.

When the Photon sidecar exits unexpectedly, _supervise_sidecar()
(running as self._sidecar_supervisor_task) correctly detects
SIDECAR_CRASHED and calls self._notify_fatal_error(). The Gateway's
fatal-error handler answers that by calling adapter.disconnect(),
which calls _stop_sidecar() -- from INSIDE the very task that's
currently executing this whole chain.

_stop_sidecar()'s cleanup unconditionally cancelled
self._sidecar_supervisor_task. Cancelling the currently-running task
raises CancelledError at its own next await point (inside
_notify_fatal_error() or _stop_sidecar() itself). Since
asyncio.CancelledError inherits from BaseException (not Exception),
the Gateway's `except Exception` guards around the fatal-error handler
don't catch it -- the handler aborts before ever reaching the
"queue platform for background reconnection" step. Photon then stays
permanently in `retrying` state until the whole process is manually
restarted, even though detection worked correctly and the underlying
transient upstream outage had long since recovered.

Fix: in _stop_sidecar()'s cleanup, check whether
self._sidecar_supervisor_task is asyncio.current_task() before
cancelling it. A task cannot legally cancel itself in any useful way
anyway (the cancellation only takes effect at its own next await,
which is exactly the corruption described above) -- when we're inside
the supervisor's own call stack, it's already in the process of
finishing on its own once _notify_fatal_error() returns, so skip the
cancel and just clear the reference.

This mirrors an existing precedent in the same file: disconnect()
already guards its OTHER task-cancellation (self._sidecar_health_task)
the same way (`if task is not asyncio.current_task()`), just not the
supervisor task in _stop_sidecar().

Per the issue's own note that existing tests mock out
_notify_fatal_error() entirely (so this integration chain was never
exercised), added two tests that drive the REAL chain: one runs
_supervise_sidecar() as an actual asyncio task with a real
_notify_fatal_error() that calls the real disconnect() -> _stop_sidecar(),
confirming the task completes without CancelledError and that the
post-disconnect reconnect-queue step actually executes; a second
confirms the OTHER call path (external cleanup, a different task)
still correctly cancels a running supervisor exactly as before.
Reverting only the adapter.py fix (keeping the new test) reproduces
the exact CancelledError from the bug report, confirming this is a
genuine regression test.

2 new tests pass; 113/113 in the full tests/plugins/platforms/photon/
directory (no regression to sidecar lifecycle, overflow recovery, or
health-monitoring behavior).
@alt-glitch alt-glitch added type/bug Something isn't working comp/plugins Plugin system and bundled plugins comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 28, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #72475 repairs the same Photon fatal-handoff self-cancellation by detaching notification, while this patch guards _stop_sidecar() directly. These are alternate mechanisms; maintainer selection is needed.

teknium1 added a commit that referenced this pull request Jul 28, 2026
…isconnect()

Follow-up widening of the #73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
teknium1 added a commit that referenced this pull request Jul 28, 2026
…isconnect()

Follow-up widening of the #73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
teknium1 added a commit that referenced this pull request Jul 29, 2026
…isconnect()

Follow-up widening of the #73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
teknium1 added a commit that referenced this pull request Jul 29, 2026
…isconnect()

Follow-up widening of the #73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
teknium1 added a commit that referenced this pull request Jul 29, 2026
…isconnect()

Follow-up widening of the #73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #73563 — cherry-picked with authorship preserved, and the same current_task() guard was widened to the inbound-task cancel in disconnect() (same class). Together with #72475 this closes #73159.

@teknium1 teknium1 closed this Jul 29, 2026
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…isconnect()

Follow-up widening of the NousResearch#73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…isconnect()

Follow-up widening of the NousResearch#73170 pattern: apply the same
asyncio.current_task() guard used for the health task (and now the
supervisor task in _stop_sidecar) to the inbound-task cancel path in
disconnect(), so an inbound task that triggers disconnect() cannot
cancel-and-await itself.
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 comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have 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.

Photon SIDECAR_CRASHED: reconnect never executes — CancelledError race in _stop_sidecar() cancels own supervisor

3 participants