Skip to content

Trap exits in Session so terminate/2 runs on supervisor-initiated stop - #210

Merged
zoedsoupe merged 3 commits into
zoedsoupe:mainfrom
malcolmsgc:fix/session-trap-exit-terminate
Jul 16, 2026
Merged

zoedsoupe merged 3 commits into
zoedsoupe:mainfrom
malcolmsgc:fix/session-trap-exit-terminate

Conversation

@malcolmsgc

Copy link
Copy Markdown
Contributor

Addresses behaviour 4 in #204.

Problem

Anubis.Server.Session is use GenServer and its init/1 returns
{:ok, state, :hibernate} without Process.flag(:trap_exit, true). The library
already traps exits on its transport processes
(server/transport/streamable_http.ex, server/transport/stdio.ex), just not on
Session.

Because of that, when a session is stopped via the supervisor —
Supervisor.stop_session/3 -> DynamicSupervisor.terminate_child/2, a :shutdown
exit — the non-trapping process dies immediately without running
terminate/2 (standard OTP: a non-trapping process receiving :shutdown
terminates without invoking terminate/2).

This is the path a spec-compliant client DELETE /mcp takes
(handle_delete -> delete_session_from_store -> stop_session_process -> Supervisor.stop_session). On it:

  • the library's own [:anubis_mcp, :server, :terminate] telemetry (emitted before
    the exported?(module, :terminate, 2) gate) never fires;
  • the server module's optional terminate/2 never fires — so any
    cleanup/observability a host hangs off terminate/2 is silently skipped on
    explicit disconnect.

The idle-expiry path already works, because handle_info(:session_expired, ...)
returns {:stop, {:shutdown, :session_expired}, ...}, and a callback-initiated
{:stop, …} always runs terminate/2. The gap is specifically the
explicit-stop (DELETE) path — arguably the more common close for short-lived tool
sessions.

Change

  • Process.flag(:trap_exit, true) in Session.init/1. With trapping, the
    supervisor's :shutdown is delivered through the gen_server loop and
    terminate/2 (+ the [:anubis_mcp, :server, :terminate] telemetry) runs on
    every stop path — explicit DELETE, idle expiry, and supervisor shutdown alike —
    consistent with how the transports already behave.
  • An explicit handle_info({:EXIT, _pid, _reason}, state) clause that ignores the
    signal. Session's general handle_info catch-all forwards unmatched messages to
    the host's module.handle_info/2; this clause keeps a stray {:EXIT, …} (from
    a process a host links to the session pid) from leaking into the host callback.

Trapping rationale

Trapping was deemed safe due to:

  • Tasks are spawned with Task.Supervisor.async_nolink, so they are not linked to
    the session — task crashes deliver {:DOWN, …} (already handled) / {ref, result}, never {:EXIT, …}. Trapping changes nothing for tasks.
  • The session links only to its supervisor parent; a parent exit signal to a
    trapping GenServer is intercepted by the gen_server loop and triggers normal
    termination (which runs terminate/2) rather than being dispatched to
    handle_info. That interception is exactly what makes the fix work.
  • So in normal operation the session receives no {:EXIT, …} at handle_info; the
    explicit clause is defense-in-depth for the exotic host-link case.

Tests

  • Host terminate/2 fires when the session is stopped via the supervisor
    (DynamicSupervisor.terminate_child, the mechanism Supervisor.stop_session/3
    uses), asserted via telemetry; the process is confirmed dead.
  • The library [:anubis_mcp, :server, :terminate] telemetry fires on the same
    path, with the session id in metadata.

Both fail before the change (the session dies on :shutdown without running
terminate/2) and pass after. mix lint (format + credo --strict + dialyzer)
and mix test pass.

…stop

Session did not trap exits, so a supervisor :shutdown (the path an explicit
client DELETE takes via stop_session -> DynamicSupervisor.terminate_child)
killed the process without running terminate/2 or its
[:anubis_mcp, :server, :terminate] telemetry. Only callback-initiated
{:stop, ...} returns (e.g. idle expiry) ran terminate/2.

Set Process.flag(:trap_exit, true) in init/1 so :shutdown is delivered
through the gen_server loop and terminate/2 (+ telemetry) runs on every stop
path, consistent with the transports which already trap exits. Tasks use
Task.Supervisor.async_nolink so trapping introduces no stray {:EXIT}; an
explicit handle_info({:EXIT, ...}) clause guards against an exit signal from
a process a host links to the session pid leaking into the host's
handle_info/2.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@zoedsoupe, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f7be4751-eda6-4082-9077-b9ac6c0bf88a

📥 Commits

Reviewing files that changed from the base of the PR and between c880f9f and 74389aa.

📒 Files selected for processing (2)
  • lib/anubis/server/session.ex
  • test/anubis/server/session_test.exs
📝 Walkthrough

Walkthrough

Anubis.Server.Session now traps linked process exits and ignores {:EXIT, ...} messages during normal processing. Tests add supervised-session setup and verify that supervisor-initiated termination invokes the server’s terminate/2 callback, emits host-level telemetry with the termination reason, emits library-level telemetry with the session ID, and stops the session process.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Clear and specific: it accurately summarizes trapping exits in Session to ensure terminate/2 runs on supervisor stops.
Description check ✅ Passed Covers the problem, solution, and rationale, with extra tests and context beyond the template’s required sections.
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
✨ Simplify code
  • Create PR with simplified code

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: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 030a4440-6da3-447b-b5a8-cde61cc424a9

📥 Commits

Reviewing files that changed from the base of the PR and between 2c641a0 and 5b934f1.

📒 Files selected for processing (3)
  • lib/anubis/server/session.ex
  • test/anubis/server/session_test.exs
  • test/support/stub_terminate_server.ex

Comment thread lib/anubis/server/session.ex
Comment thread test/anubis/server/session_test.exs
…isor stop

Tighten the supervisor-stop assertion from a wildcard reason to :shutdown,
pinning that supervisor-initiated termination runs terminate/2 with the
graceful :shutdown reason (not :killed). Guards against a regression where
the session fails to stop promptly on a supervisor :shutdown.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/anubis/server/session_test.exs (2)

536-568: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

P2: Compose this setup through the MCP test helpers.

start_supervised_session/2 manually rebuilds transport and server setup instead of composing the prescribed Anubis.MCP.Case setup functions. Keep the DynamicSupervisor-specific portion, but reuse the shared setup helpers where applicable so lifecycle tests do not drift from the rest of the suite.

Source: Coding guidelines


592-611: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

P2: Assert the library telemetry reason too.

The session termination telemetry includes both session_id and reason, but this test only verifies the session ID. It can therefore pass if the library event reports an incorrect termination reason.

✅ Suggested assertion
-      assert_receive {:lib_terminate, %{session_id: ^session_id}}, 500
+      assert_receive {:lib_terminate, %{reason: :shutdown, session_id: ^session_id}}, 500

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ebcb9b44-d332-489d-8338-647ee08aedb6

📥 Commits

Reviewing files that changed from the base of the PR and between 5b934f1 and c880f9f.

📒 Files selected for processing (1)
  • test/anubis/server/session_test.exs

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 13, 2026
@malcolmsgc

Copy link
Copy Markdown
Contributor Author

Overlaps with #209.

@zoedsoupe zoedsoupe left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

thanks for the contribution

@zoedsoupe
zoedsoupe merged commit bd3c363 into zoedsoupe:main Jul 16, 2026
12 checks passed
zoedsoupe added a commit that referenced this pull request Jul 16, 2026
#210)

Addresses behaviour 4 in #204.

### Problem

`Anubis.Server.Session` is `use GenServer` and its `init/1` returns
`{:ok, state, :hibernate}` without `Process.flag(:trap_exit, true)`. The
library
already traps exits on its transport processes
(`server/transport/streamable_http.ex`, `server/transport/stdio.ex`),
just not on
`Session`.

Because of that, when a session is stopped via the supervisor —
`Supervisor.stop_session/3` -> `DynamicSupervisor.terminate_child/2`, a
`:shutdown`
exit — the non-trapping process dies immediately without running
`terminate/2` (standard OTP: a non-trapping process receiving
`:shutdown`
terminates without invoking `terminate/2`).

This is the path a spec-compliant client `DELETE /mcp` takes
(`handle_delete -> delete_session_from_store -> stop_session_process ->
Supervisor.stop_session`). On it:

- the library's own `[:anubis_mcp, :server, :terminate]` telemetry
(emitted before
  the `exported?(module, :terminate, 2)` gate) never fires;
- the server module's optional `terminate/2` never fires — so any
cleanup/observability a host hangs off `terminate/2` is silently skipped
on
  explicit disconnect.

The idle-expiry path already works, because
`handle_info(:session_expired, ...)`
returns `{:stop, {:shutdown, :session_expired}, ...}`, and a
callback-initiated
`{:stop, …}` always runs `terminate/2`. The gap is specifically the
explicit-stop (DELETE) path — arguably the more common close for
short-lived tool
sessions.

### Change

- `Process.flag(:trap_exit, true)` in `Session.init/1`. With trapping,
the
supervisor's `:shutdown` is delivered through the `gen_server` loop and
`terminate/2` (+ the `[:anubis_mcp, :server, :terminate]` telemetry)
runs on
every stop path — explicit DELETE, idle expiry, and supervisor shutdown
alike —
  consistent with how the transports already behave.
- An explicit `handle_info({:EXIT, _pid, _reason}, state)` clause that
ignores the
signal. Session's general `handle_info` catch-all forwards unmatched
messages to
the host's `module.handle_info/2`; this clause keeps a stray `{:EXIT,
…}` (from
a process a host links to the session pid) from leaking into the host
callback.

### Trapping rationale

Trapping was deemed safe due to:

- Tasks are spawned with `Task.Supervisor.async_nolink`, so they are not
linked to
the session — task crashes deliver `{:DOWN, …}` (already handled) /
`{ref,
  result}`, never `{:EXIT, …}`. Trapping changes nothing for tasks.
- The session links only to its supervisor parent; a parent exit signal
to a
trapping GenServer is intercepted by the `gen_server` loop and triggers
normal
  termination (which runs `terminate/2`) rather than being dispatched to
  `handle_info`. That interception is exactly what makes the fix work.
- So in normal operation the session receives no `{:EXIT, …}` at
`handle_info`; the
  explicit clause is defense-in-depth for the exotic host-link case.

### Tests

- Host `terminate/2` fires when the session is stopped via the
supervisor
(`DynamicSupervisor.terminate_child`, the mechanism
`Supervisor.stop_session/3`
  uses), asserted via telemetry; the process is confirmed dead.
- The library `[:anubis_mcp, :server, :terminate]` telemetry fires on
the same
  path, with the session id in metadata.

Both fail before the change (the session dies on `:shutdown` without
running
`terminate/2`) and pass after. `mix lint` (format + credo --strict +
dialyzer)
and `mix test` pass.

---------

Co-authored-by: zoey <zoey.spessanha@zeetech.io>
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