One channel event is delivered once per reconnect: stale peers accumulate under one identity #120
Labels
No labels
blocked
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
waiting-on-julian
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/matrix-mcp#120
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-E0QeeG58in!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:
mark_readwas called after the first delivery and it landed. The serverconsiders the event read and pushed it twice more anyway.
Cause
ChannelRegistrykeys peers by MCPsession_keyandnotifywrites to everylive peer for the mxid (
src/channel.rs:540).registerreplaces an entry withthe 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:
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, andthe sweep in
notifyclearly is not catching these — three peers survived longenough to each be written to.
So the count
notifyreturns is doing exactly what its doc comment warns aboutin 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 tagpush, or a second
kubectl deletewould not be. The session cannot tell aduplicate 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:
across reconnects for one session, key on that and let
registerreplace asdesigned.
whose transport is not demonstrably live. Riskier —
live_peersalreadycarries a comment about a false positive from
is_transport_closedkilling achannel for a whole session, and that lesson should not be undone here.
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:
the pod, or interrupt the connection) so a second peer registers.
notifyreturns 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.
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, runningv0.10.4since 08:03:42Z. Everychannel: pushedline since the pod started:Ten pushes over the pod's whole life. Zero with
live > 1orwritten > 1, sonotifynever fanned out to a second peer, and$E-IVGt_s55obRo_Z56QXjMir8oU933Zhvp-E0QeeG58appears exactly once, at 12:00:11.832838Z.Two more counts against the accumulation theory:
replayed missed messagesnever logged), so replay is not the second and third delivery either. Consistent with the missingreplayedflag, and with the receipt evidence.some peers report closed transport, total: 1, closed: 1. One peer, reported closed. That is the opposite of peers accumulating.So
live_peersreturned 1 every single time. There was never more than one peer registered for that identity, and the sweep innotifyhad nothing to catch.What that leaves
The mechanism in the issue body is sound reasoning about the code —
notifyreally does write once per live peer, and a reconnect really would produce a newsession_key. It just did not happen here. Claude Code's reconnect either resumes the same MCP session id (in which caseregisterreplaces 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
replayedflag, 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 noreplayedattribute 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-E0QeeG58in 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
notifycall 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
notifyreturns 1" — was already answerable from the running system, and it answers 1.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, threequeue-operationrecords with"operation": "enqueue"and byte-identicalcontent— the same<channel>tag, same event id:Against your single
channel: pushedat 12:00:11.832838Z. The first enqueue is33ms after that push and is the real one. The other two have no server write
behind them at all.
So this is not
notifyfanning out, there was never a second peer, and nothing inthis 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 noreplayedattribute because it is a copy of theoriginal 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
notifyreturns 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.