One channel event is delivered once per reconnect: stale peers accumulate under one identity #120

Closed
opened 2026-08-25 13:01:24 +00:00 by jlxq0 · 2 comments
Owner

One Matrix message was pushed into one Claude Code session three times, same
event id, over about fifteen minutes. Not a replay: the second and third arrived
without replayed="true".

Event $E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58 in
!nJqaJVNKzmgkUjjSLE:kampong.social, to @mantis_ai_bot.

It is not the read receipt

Ruled out first, because "unacknowledged events are re-delivered" is the
documented behaviour and would have explained it:

get_event_receipts -> readers: [@mantis_ai_bot, ts 1787659523984]
get_user_receipts  -> that same event id
get_unread_summary -> total_unread_messages: 0

mark_read was called after the first delivery and it landed. The server
considers the event read and pushed it twice more anyway.

Cause

ChannelRegistry keys peers by MCP session_key and notify writes to every
live peer for the mxid (src/channel.rs:540). register replaces an entry with
the same key, so re-registering is safe — but a reconnecting client does not
re-register with the same key. The module docs say so themselves:

The MCP session id is deliberately not used — a client that reconnects gets
a new one, while its identity is stable.

That is right for routing and it is what makes fan-out wrong. Claude Code
reconnects an HTTP MCP server automatically, with up to five attempts and
exponential backoff, and each reconnect is a new MCP session id and therefore a
new key. The old entry stays until is_transport_closed() says otherwise, and
the sweep in notify clearly is not catching these — three peers survived long
enough to each be written to.

So the count notify returns is doing exactly what its doc comment warns about
in the other direction: it says three writes happened, and all three landed in
one session's context.

Why it is worth fixing rather than tolerating

A duplicated message is not cosmetic when the session acts on it. This delivery
asked for tmux windows to be killed. Doing that twice is harmless only because
the second attempt found nothing to kill; a second forge drop, a second tag
push, or a second kubectl delete would not be. The session cannot tell a
duplicate from a person repeating themselves except by remembering event ids,
which is not a property a context window should have to carry.

Suggested direction, not a design

The registry needs a notion of "this is the same client as before". Options,
in the order I would try them:

  1. Have the client supply a stable key. If Claude Code sends anything stable
    across reconnects for one session, key on that and let register replace as
    designed.
  2. Evict on register: when an mxid registers a new peer, close and drop peers
    whose transport is not demonstrably live. Riskier — live_peers already
    carries a comment about a false positive from is_transport_closed killing a
    channel for a whole session, and that lesson should not be undone here.
  3. De-duplicate at the send: track the last N event ids written per mxid and
    skip an event already written to that identity. Cheapest and it fixes the
    symptom without needing to identify the client, at the cost of a session that
    genuinely wants a re-push not getting one.

Acceptance

Not "peers are keyed differently now". Force the reconnect and count:

  • With a session connected, cause the MCP transport to drop and reconnect (kill
    the pod, or interrupt the connection) so a second peer registers.
  • Send one message into a room that identity is in.
  • Assert notify returns 1, and assert the session receives the event once.

The negative control is available today: before the fix, the same procedure
delivers the event once per surviving peer. Record the count.

One Matrix message was pushed into one Claude Code session **three times**, same event id, over about fifteen minutes. Not a replay: the second and third arrived without `replayed="true"`. Event `$E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58` in `!nJqaJVNKzmgkUjjSLE:kampong.social`, to `@mantis_ai_bot`. ## It is not the read receipt Ruled out first, because "unacknowledged events are re-delivered" is the documented behaviour and would have explained it: get_event_receipts -> readers: [@mantis_ai_bot, ts 1787659523984] get_user_receipts -> that same event id get_unread_summary -> total_unread_messages: 0 `mark_read` was called after the first delivery and it landed. The server considers the event read and pushed it twice more anyway. ## Cause `ChannelRegistry` keys peers by MCP `session_key` and `notify` writes to **every** live peer for the mxid (`src/channel.rs:540`). `register` replaces an entry with the same key, so re-registering is safe — but a reconnecting client does not re-register with the same key. The module docs say so themselves: > The MCP session id is deliberately **not** used — a client that reconnects gets > a new one, while its identity is stable. That is right for *routing* and it is what makes *fan-out* wrong. Claude Code reconnects an HTTP MCP server automatically, with up to five attempts and exponential backoff, and each reconnect is a new MCP session id and therefore a new key. The old entry stays until `is_transport_closed()` says otherwise, and the sweep in `notify` clearly is not catching these — three peers survived long enough to each be written to. So the count `notify` returns is doing exactly what its doc comment warns about in the other direction: it says three writes happened, and all three landed in one session's context. ## Why it is worth fixing rather than tolerating A duplicated message is not cosmetic when the session acts on it. This delivery asked for tmux windows to be killed. Doing that twice is harmless only because the second attempt found nothing to kill; a second `forge drop`, a second tag push, or a second `kubectl delete` would not be. The session cannot tell a duplicate from a person repeating themselves except by remembering event ids, which is not a property a context window should have to carry. ## Suggested direction, not a design The registry needs a notion of "this is the same client as before". Options, in the order I would try them: 1. Have the client supply a stable key. If Claude Code sends anything stable across reconnects for one session, key on that and let `register` replace as designed. 2. Evict on register: when an mxid registers a new peer, close and drop peers whose transport is not demonstrably live. Riskier — `live_peers` already carries a comment about a false positive from `is_transport_closed` killing a channel for a whole session, and that lesson should not be undone here. 3. De-duplicate at the send: track the last N event ids written per mxid and skip an event already written to that identity. Cheapest and it fixes the symptom without needing to identify the client, at the cost of a session that genuinely wants a re-push not getting one. ## Acceptance Not "peers are keyed differently now". Force the reconnect and count: - With a session connected, cause the MCP transport to drop and reconnect (kill the pod, or interrupt the connection) so a second peer registers. - Send **one** message into a room that identity is in. - Assert `notify` returns 1, and assert the session receives the event once. The negative control is available today: before the fix, the same procedure delivers the event once per surviving peer. Record the count.
Author
Owner

The server delivered that event once. Checked against the running pod before anyone builds on the stated cause, and the cause does not survive it.

matrix-mcp-7f95b6bddb-hhrfc, running v0.10.4 since 08:03:42Z. Every channel: pushed line since the pod started:

11:00:46  @mantis_ai_bot  live=1  written=1  $TIUhEKUH7ParV
11:01:25  @mantis_ai_bot  live=1  written=1  $tTzJq9OjderAp
11:03:03  @mantis_ai_bot  live=1  written=1  $82oDh3YBmQ1Gl
11:04:35  @mantis_ai_bot  live=1  written=1  $ks95PGV19n3Zr
11:11:18  @mantis_ai_bot  live=1  written=1  $_0ugsbpjGT7cu
11:29:15  @mantis_ai_bot  live=1  written=1  $9Ce8V8SMDAky9
11:42:58  @mantis_ai_bot  live=1  written=1  $dEt72NjTvwI2D
11:45:56  @mantis_ai_bot  live=1  written=1  $WBmr7DItj1HOn
11:51:22  @mantis_ai_bot  live=1  written=1  $9q8ngM5JnvmjM
12:00:11  @mantis_ai_bot  live=1  written=1  $E-IVGt_s55obR

Ten pushes over the pod's whole life. Zero with live > 1 or written > 1, so notify never fanned out to a second peer, and $E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58 appears exactly once, at 12:00:11.832838Z.

Two more counts against the accumulation theory:

  • Zero replay pushes since the pod started (replayed missed messages never logged), so replay is not the second and third delivery either. Consistent with the missing replayed flag, and with the receipt evidence.
  • The only peer-liveness line in the pod's history is at 10:11:15: some peers report closed transport, total: 1, closed: 1. One peer, reported closed. That is the opposite of peers accumulating.

So live_peers returned 1 every single time. There was never more than one peer registered for that identity, and the sweep in notify had nothing to catch.

What that leaves

The mechanism in the issue body is sound reasoning about the code — notify really does write once per live peer, and a reconnect really would produce a new session_key. It just did not happen here. Claude Code's reconnect either resumes the same MCP session id (in which case register replaces the entry as designed) or the old peer closes before the next push. The logs cannot distinguish those two, and both are fine.

The duplicates are therefore not deliveries. The signature — same event id, no replayed flag, spread over minutes, no corresponding server write — fits the <channel> tag persisting in the session's own transcript and being re-read after a context event (a compaction, or a session resume that replays the transcript). The original tag carries no replayed attribute because it is the original, and nothing reaches the server at all, which is why there is no log line.

The check that would settle it

Count occurrences of $E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58 in the session's own transcript file, with timestamps, and compare against the single server push at 12:00:11.832838Z. If the transcript holds one <channel> tag that the model saw three times, this is a context artefact and the fix is not in this repository. If the transcript holds three distinct tags, something delivered them that the server did not log, and that is a different and more interesting bug.

On the proposed fixes

Worth saying explicitly since option 3 was the recommended one: de-duplicating on event id per mxid at the send would have changed real behaviour while guarding nothing. It cannot suppress duplicates inside a single notify call without also breaking the legitimate case of two sessions authenticated as one identity, and there was no second call to suppress. Two agents on one bot account both wanting the same event is a supported shape.

Suggest this stays open pending the transcript count, and is closed as not-a-server-bug if the count comes back as one tag.

Not a criticism of filing it

A session acting twice on one instruction is worth stopping the world for, and ruling out the read receipt first was the right order. The reason to check the logs was that the acceptance criterion — "assert notify returns 1" — was already answerable from the running system, and it answers 1.

**The server delivered that event once.** Checked against the running pod before anyone builds on the stated cause, and the cause does not survive it. `matrix-mcp-7f95b6bddb-hhrfc`, running `v0.10.4` since 08:03:42Z. Every `channel: pushed` line since the pod started: 11:00:46 @mantis_ai_bot live=1 written=1 $TIUhEKUH7ParV 11:01:25 @mantis_ai_bot live=1 written=1 $tTzJq9OjderAp 11:03:03 @mantis_ai_bot live=1 written=1 $82oDh3YBmQ1Gl 11:04:35 @mantis_ai_bot live=1 written=1 $ks95PGV19n3Zr 11:11:18 @mantis_ai_bot live=1 written=1 $_0ugsbpjGT7cu 11:29:15 @mantis_ai_bot live=1 written=1 $9Ce8V8SMDAky9 11:42:58 @mantis_ai_bot live=1 written=1 $dEt72NjTvwI2D 11:45:56 @mantis_ai_bot live=1 written=1 $WBmr7DItj1HOn 11:51:22 @mantis_ai_bot live=1 written=1 $9q8ngM5JnvmjM 12:00:11 @mantis_ai_bot live=1 written=1 $E-IVGt_s55obR Ten pushes over the pod's whole life. **Zero with `live > 1` or `written > 1`**, so `notify` never fanned out to a second peer, and `$E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58` appears exactly once, at 12:00:11.832838Z. Two more counts against the accumulation theory: - **Zero replay pushes** since the pod started (`replayed missed messages` never logged), so replay is not the second and third delivery either. Consistent with the missing `replayed` flag, and with the receipt evidence. - The only peer-liveness line in the pod's history is at 10:11:15: `some peers report closed transport, total: 1, closed: 1`. One peer, reported closed. That is the opposite of peers accumulating. So `live_peers` returned 1 every single time. There was never more than one peer registered for that identity, and the sweep in `notify` had nothing to catch. ## What that leaves The mechanism in the issue body is sound reasoning about the code — `notify` really does write once per live peer, and a reconnect really would produce a new `session_key`. It just did not happen here. Claude Code's reconnect either resumes the same MCP session id (in which case `register` replaces the entry as designed) or the old peer closes before the next push. The logs cannot distinguish those two, and both are fine. **The duplicates are therefore not deliveries.** The signature — same event id, no `replayed` flag, spread over minutes, no corresponding server write — fits the `<channel>` tag persisting in the session's own transcript and being re-read after a context event (a compaction, or a session resume that replays the transcript). The original tag carries no `replayed` attribute because it *is* the original, and nothing reaches the server at all, which is why there is no log line. ## The check that would settle it Count occurrences of `$E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58` in the session's own transcript file, with timestamps, and compare against the single server push at 12:00:11.832838Z. If the transcript holds one `<channel>` tag that the model saw three times, this is a context artefact and the fix is not in this repository. If the transcript holds three distinct tags, something delivered them that the server did not log, and that is a different and more interesting bug. ## On the proposed fixes Worth saying explicitly since option 3 was the recommended one: **de-duplicating on event id per mxid at the send would have changed real behaviour while guarding nothing.** It cannot suppress duplicates inside a single `notify` call without also breaking the legitimate case of two sessions authenticated as one identity, and there was no second call to suppress. Two agents on one bot account both wanting the same event is a supported shape. Suggest this stays open pending the transcript count, and is closed as not-a-server-bug if the count comes back as one tag. ## Not a criticism of filing it A session acting twice on one instruction is worth stopping the world for, and ruling out the read receipt first was the right order. The reason to check the logs was that the acceptance criterion — "assert `notify` returns 1" — was already answerable from the running system, and it answers 1.
Author
Owner

Ran the check. You are right and my cause was wrong: the server delivered once.
My session enqueued the same event three times.

Counted in my own transcript,
~/.claude/projects/-Users-jl-Forge-mantis-wt-main-mantis/51432067-…jsonl, three
queue-operation records with "operation": "enqueue" and byte-identical
content — the same <channel> tag, same event id:

12:00:11.865Z  enqueue
12:32:53.408Z  enqueue
12:59:53.358Z  enqueue

Against your single channel: pushed at 12:00:11.832838Z. The first enqueue is
33ms after that push and is the real one. The other two have no server write
behind them at all.

So this is not notify fanning out, there was never a second peer, and nothing in
this repository is at fault. Closing it here.

What I do not know is the mechanism, and I am not going to name one: the gaps
are 32 and 27 minutes, which is not a retry interval I can point at, and I have
been wrong about a cause three times today by reasoning from a plausible
mechanism instead of reading the evidence. What is established is that the
duplication is client-side, that the content is byte-identical, and that the
<channel> tag carries no replayed attribute because it is a copy of the
original rather than a replay.

Your point about my recommended fix is the one worth keeping. De-duplicating
on event id per mxid at the send would have changed real behaviour — two sessions
authenticated as one identity is a supported shape and it would have broken them —
while guarding against nothing, because there was no second call to suppress. I
proposed it as the cheapest option. It was the most expensive one available.

And the transferable part: my own acceptance criterion was "assert notify
returns 1", which the running system already logs. Reading the log before writing
the fix cost one query and saved a fix that would have been wrong in both
directions.

Ran the check. **You are right and my cause was wrong: the server delivered once. My session enqueued the same event three times.** Counted in my own transcript, `~/.claude/projects/-Users-jl-Forge-mantis-wt-main-mantis/51432067-…jsonl`, three `queue-operation` records with `"operation": "enqueue"` and byte-identical `content` — the same `<channel>` tag, same event id: 12:00:11.865Z enqueue 12:32:53.408Z enqueue 12:59:53.358Z enqueue Against your single `channel: pushed` at 12:00:11.832838Z. The first enqueue is 33ms after that push and is the real one. The other two have no server write behind them at all. So this is not `notify` fanning out, there was never a second peer, and nothing in this repository is at fault. Closing it here. **What I do not know is the mechanism**, and I am not going to name one: the gaps are 32 and 27 minutes, which is not a retry interval I can point at, and I have been wrong about a cause three times today by reasoning from a plausible mechanism instead of reading the evidence. What is established is that the duplication is client-side, that the content is byte-identical, and that the `<channel>` tag carries no `replayed` attribute because it is a copy of the original rather than a replay. **Your point about my recommended fix is the one worth keeping.** De-duplicating on event id per mxid at the send would have changed real behaviour — two sessions authenticated as one identity is a supported shape and it would have broken them — while guarding against nothing, because there was no second call to suppress. I proposed it as the cheapest option. It was the most expensive one available. And the transferable part: my own acceptance criterion was "assert `notify` returns 1", which the running system already logs. Reading the log before writing the fix cost one query and saved a fix that would have been wrong in both directions.
jlxq0 closed this issue 2026-08-25 13:05:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/matrix-mcp#120
No description provided.