Skip to content

channels/changed reconciles before responding, deadlocking servers that announce from inside a request #160

Description

@csd-oss

Summary

ChannelRegistry.handleChanged reconciles before it responds, while handleRegister responds before it reconciles. Reconciling sends a server-bound channels/open or channels/close per descriptor. A server that announces a channel from inside a request it is currently serving therefore cannot be answered: its own loop is busy, so the reconcile request it must answer is never read, and the announcement times out.

handleRegister's comment already names the hazard:

// Respond before reconciliation — the server blocks on this response and
// can't process channels/open until it arrives.

src/mcpl/channel-registry.ts:569-572 (response at :572, reconcile at :577)

handleChanged does the opposite: reconcile at :656, responder?.respond(...) at :658. Identical in 0.13.0, 0.14.0 and main.

Why it bites

MCPL servers commonly announce a channel from inside a tools/call handler (an agent asking to refresh or subscribe). A server that serves one request at a time — the natural shape for a stdio child — then deadlocks:

  1. server: tools/call refresh_channels → channels/changed
  2. host: handleChanged registers the descriptor, calls reconcileChannels, which awaits channels/open
  3. server: cannot read that request; it is inside step 1
  4. server: announcement times out (mcpl-core default 30s), the tool answers, and only then is the host's channels/open served

Observed in zulip-mcp as antra-tess/zulip_mcp#20: a stream the bot joined after startup stayed -32023 Unknown channel for channels/open until the process was restarted, because a restart goes through channels/register (which answers first) while refresh_channels goes through channels/changed (which does not). Mentions from the stream were delivered the whole time, since the message path is off the request loop.

Suggested fix

Respond first, reconcile after, as handleRegister does:

responder?.respond({ results: addedResults });
if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);

The itemized verdicts are known before reconciling, so nothing in the response depends on it. That also removes the case where a reconcile-time channels/open races a server that has not yet committed the descriptor.

Workaround in the server (not a fix)

zulip-mcp now records the descriptor before announcing, bounds the wait, and treats a host-initiated channels/open / channels/close as the confirmation the announcement itself can never get: antra-tess/zulip_mcp#21. That keeps the channel usable, but every such server has to carry the same workaround until this is fixed here.

Not verified

Read from 0.13.0, 0.14.0 and main sources plus a harness that mimics handleChanged; no connectome-host process was run.

🤖 Generated with Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions