Skip to content

fix(bridge): reconnect to Live on demand instead of locking into offline mode - #14

Open
jwilkins wants to merge 1 commit into
Pantani:mainfrom
jwilkins:fix/bridge-reconnect
Open

jwilkins wants to merge 1 commit into
Pantani:mainfrom
jwilkins:fix/bridge-reconnect

Conversation

@jwilkins

Copy link
Copy Markdown

Problem

The MCP server only tries to reach the AbletonMind Remote Script once, at startup. If Live isn't running at that moment — the normal case when Claude Desktop / Cursor launches MCP servers on app start — createBridgeRuntime() returns an offline bridge and every tool call fails for the life of the process:

Ableton bridge offline: failed to connect: connect ECONNREFUSED 127.0.0.1:9876

…even after Live is up and the bridge is listening. The only workaround is restarting the MCP host after Live has fully loaded. Restarting Live mid-session has the same effect, because TcpJsonRpcClient's auto-reconnect loop stops after the first failed attempt.

Fix

src/server/bridge-runtime.ts: the runtime now reconnects on demand.

  • Each bridge.call() checks the current client; if it isn't connected it creates a fresh TcpJsonRpcClient, runs the handshake, then forwards the call.
  • Concurrent calls share one in-flight reconnect attempt.
  • Failures are rate-limited by reconnectCooldownMs (default 1 s); calls inside the window fail fast with the last error.
  • New onClient() hook so index.ts re-attaches the notification forwarder to whichever client is current (and detaches it from the old one).
  • The startup attempt and its log line are unchanged, except the warning now says the server will reconnect on the next tool call.

src/live-client/tcp-client.ts: scheduleReconnect() reschedules itself after a failed attempt. It used to call handleDisconnect(), which returns early when the state is already "disconnected" with no socket — so the backoff loop ran exactly once.

Tests

5 new tests, each verified to fail without the fix:

  • recovers when Live starts after the server
  • no retry inside the cooldown window
  • dropped client is replaced and notification subscribers move to the new one
  • concurrent calls share a single reconnect
  • TcpJsonRpcClient keeps retrying after a failed reconnect

tsc --noEmit, biome check, deps:validate, complexity all clean; vitest run: 198 passed, 4 skipped.

Repro

  1. Quit Live. Start the MCP server (e.g. launch Claude Desktop).
  2. Open Live with AbletonMind selected as a Control Surface.
  3. Call any tool, e.g. session_get_info.
    • Before: Ableton bridge offline: … ECONNREFUSED forever.
    • After: connects and returns session info.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JehnFxtQ4wX3KSf7Lc2keo

…ine mode

The MCP server connected to the Remote Script exactly once, at startup. If
Live was not running yet (the usual case when the MCP host launches the
server first), createBridgeRuntime() returned an offline bridge that
rejected every tool call for the lifetime of the process with the cached
startup error, even after Live came up. The same happened after a Live
restart, because the TCP client's own reconnect loop gave up after one
failed attempt.

bridge-runtime:
- Every bridge.call() checks the current client and, when it is not
  connected, opens a fresh TcpJsonRpcClient and repeats the handshake
  before forwarding the call.
- Concurrent calls share a single in-flight reconnect attempt.
- Failed attempts are rate limited (reconnectCooldownMs, default 1s) so a
  burst of calls while Live is closed does not hammer the socket.
- New onClient() hook lets index.ts re-attach the notification forwarder
  to whichever client is current; the previous one is detached.

tcp-client:
- scheduleReconnect() now reschedules itself after a failed attempt.
  Previously it called handleDisconnect(), which returns early when the
  state is already "disconnected" with no socket, ending the backoff loop
  after the first failure.

Tests: 5 new cases (startup-offline recovery, cooldown, client
replacement with subscriber hand-off, single-flight reconnect, and the
tcp-client retry loop). Each fails without the fix; full suite 198 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JehnFxtQ4wX3KSf7Lc2keo
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6ba09b02-d1d6-42c6-9fc6-81829ae72fc0


Comment @coderabbitai help to get the list of available commands.

@jwilkins

Copy link
Copy Markdown
Author

@coderabbitai

@coderabbitai

coderabbitai Bot commented Sep 27, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@jwilkins, what would you like me to check on this PR?

You are interacting with an AI system.

@Pantani Pantani 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 this — the core fix works. I checked out the branch: typecheck, lint and npm test pass, and a real-socket test (server starts with Live down → Live comes up → Live restarts) reconnects correctly and moves event.* notifications to the new client. A few things need to change before merge, though.

Blocking

1. TcpJsonRpcClient: close() during an in-flight reconnect now loops forever (src/live-client/tcp-client.ts:418)

connect()'s onError sets this.state = "disconnected", overwriting "closed" if close() ran while the attempt was in flight. The new guard this.state !== "closed" then passes and scheduleReconnect() keeps going forever (timer keeps the process alive, log spam). Before this PR the same race leaked a single attempt; now it is unbounded.

Repro (reconnect attempts went 4 → 21 within 100 ms after close()):

it("stops reconnecting after close() lands mid-connect", async () => {
  const sockets: net.Socket[] = [];
  const srv = net.createServer((s) => { sockets.push(s); s.on("error", () => {}); });
  await new Promise<void>((r) => srv.listen(0, "127.0.0.1", () => r()));
  const port = (srv.address() as net.AddressInfo).port;
  const client = new TcpJsonRpcClient({ host: "127.0.0.1", port, reconnectInitialMs: 5, reconnectMaxMs: 5 });
  await client.connect();
  const c = client as unknown as { reconnectAttempt: number; connect: () => Promise<void> };
  const orig = c.connect.bind(client);
  let done = false;
  c.connect = () => { const p = orig(); if (!done) { done = true; void client.close(); } return p; };
  srv.close(); sockets.forEach((s) => s.destroy());
  await new Promise((r) => setTimeout(r, 30));
  const n = c.reconnectAttempt;
  await new Promise((r) => setTimeout(r, 100));
  expect(c.reconnectAttempt).toBe(n); // fails today
});

The same race on the success path is pre-existing but worth fixing together: onConnect sets state = "connected" after close(), leaking an open socket. Suggested fix: in both onConnect and onError, don't overwrite "closed" — if the client was closed meanwhile, sock.destroy() and reject. Note the new "keeps retrying after a failed reconnect attempt" test calls close() while the loop is running and can hit exactly this race.

(Side note: after this PR no production path uses autoReconnect: true anymore — the runtime, doctor and chat all pass false — so this change is a library fix, not part of the server fix. Fine to keep, but it must not regress close().)

Should fix (here or in a follow-up)

2. ableton-mind chat still locks into offline mode (src/cli/chat.ts:161-188)

createChatContext() connects once at startup with autoReconnect: false. If Live isn't running yet, or restarts during the session, every tool call fails until the chat process is restarted — the exact bug this PR fixes for the MCP server. docs/guide/local-copilot.md:61 implies it recovers once the Remote Script is active. It also throws a plain Error instead of JsonRpcTransportError. Reusing createBridgeRuntime() there would fix it.

3. No connect timeout (src/live-client/tcp-client.ts:187)

With on-demand reconnect, any tool call past the cooldown can trigger net.createConnection with no timeout, and concurrent calls share that in-flight attempt. With ABLETON_MIND_HOST pointing at an unreachable host (Docker / host.docker.internal setups), calls can hang for the OS SYN timeout (tens of seconds) instead of failing fast. Previously this only affected startup. The per-call timeoutMs doesn't cover connect + handshake either. A connect timeout (e.g. reuse defaultTimeoutMs) would close this.

Minor

  1. Existing tests now depend on the wall clock (tests/server-bridge-runtime.test.ts:7 and :48): they rely on the call landing inside the real 1 s cooldown. On a slow CI runner past 1 s, the default factory opens a real socket to 127.0.0.1:9876; with Live running on a dev machine, the first test (handshake vi.fn() resolves) would call session.get_info on real Live. Please pass clientFactory and now in those tests.
  2. onClient with the same function twice overwrites the Map entry without running the previous cleanup, leaking the first listener.
  3. Leftovers: header comment in src/index.ts:6 and :14 still describes "offline mode"; createOfflineBridgeClient is now unused.

@Pantani

Pantani commented Oct 13, 2026

Copy link
Copy Markdown
Owner

@jwilkins any update?

This branch has not been deployed

No deployments
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