Skip to content

fix(marionette_mcp): time out connect when the VM service stops answering - #143

Merged
mateusz-pietras merged 7 commits into
leancodepl:mainfrom
uveshm003:fix/131-connect-timeout
Oct 28, 2026
Merged

mateusz-pietras merged 7 commits into
leancodepl:mainfrom
uveshm003:fix/131-connect-timeout

Conversation

@uveshm003

Copy link
Copy Markdown

Fixes #131

Problem

VmServiceConnector.connect() awaited vmServiceConnectUri, getVM and getIsolate with no timeout. Only streamListen had one (500 ms, from #127). When an app accepts the connection but then stops answering, for example on a sleeping device or with the app paused in the debugger, connect waits until the MCP client gives up. That can take up to 30 minutes, which matches the 1 m 43 s and 9 min hangs in the issue's timeline. disconnect(), which connect calls first when it is already connected, also awaited VmService.dispose() with no limit.

Change

  • connect(uri, {timeout}) puts a timeout on the whole handshake, 30 s by default (VmServiceConnector.defaultConnectTimeout). When the timeout fires, it throws a TimeoutException that names the URI and what to check, and leaves the connector disconnected so the next connect starts fresh. The connect tool already returns that as an isError result: Failed to connect to app: … Timed out connecting to the VM service at ws://… after 30s. Make sure the app is still running, is not paused in the debugger, and its device is awake, then call connect again.
  • A handshake that times out is abandoned. If its WebSocket connects later, that service is disposed instead of being published, and the error path tears down only the service this attempt opened.
  • disconnect() clears its state first, then waits at most 5 s for the old service to close, so an unresponsive app that was connected before can't block a reconnect.
  • The CLI already wraps connect in its own --timeout (5 s by default), so its behaviour is unchanged.

I left the per-call timeouts on the other tools (the second half of the issue's suggestion) out of this PR on purpose. take_screenshots, scroll_to and hot_restart can legitimately take a while, so choosing those limits seemed like a maintainer decision. I'm happy to follow up if you want them.

Tests

New file packages/marionette_mcp/test/vm_service/connect_timeout_test.dart. The tests use a local fake VM service built on HttpServer and WebSocketTransformer:

  • connects to a responsive VM service
  • times out when the VM service stops answering requests
  • times out when the WebSocket handshake never completes
  • can connect again after a timed-out attempt
  • still reports a missing marionette binding without waiting
  • reconnects when the previous VM service stopped answering

I also ran these tests against the unfixed vm_service_connector.dart from main. The three timeout tests hung until the test runner's 8 s timeout, which reproduces the bug; the other three passed. The last test passes on main too, because the fake closes its socket cleanly, so it guards the new disconnect behaviour rather than reproducing a hang.

Checks run locally (Dart 3.12.2, not the Flutter 3.47.6 pinned in .fvmrc):

  • dart test in packages/marionette_mcp: 235 passed
  • dart test in packages/marionette_cli: 103 passed
  • dart analyze --fatal-infos lib bin test in packages/marionette_mcp: no issues
  • dart analyze --fatal-infos lib bin in packages/marionette_cli: no issues
  • dart format --set-exit-if-changed: clean

I haven't tested this against a real sleeping device. The hang is reproduced with the fake VM service above.

I added a CHANGELOG entry under Unreleased.

…ring

connect awaited the WebSocket handshake, getVM and getIsolate with no
timeout, so an app that accepted the connection but stopped responding
(a sleeping device, an app paused in the debugger) made the tool wait
until the MCP client gave up, which can take up to 30 minutes.

The handshake now fails after 30 seconds with a TimeoutException that
says what to check, and leaves the connector disconnected so the next
connect starts fresh. A handshake that times out is abandoned: if its
service connects later it is disposed instead of being published.
disconnect also stops waiting for an unresponsive VM service to close
after 5 seconds, so the previous app can't block a reconnect.

Fixes leancodepl#131
Copilot AI balanced review requested due to automatic review settings October 23, 2026 18:34
@netlify

netlify Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

👷 Deploy request for marionette-landing pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 299fd8e

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unbounded MCP setup requests, overlapping-attempt cleanup, and CLI timeout compatibility remain unresolved.

3 open issues
What changed in this PR

Addresses #131 by bounding VM service connection waits, extending the earlier stream-subscription timeout from #127.

Changes:

  • Adds a configurable handshake timeout, defaulting to 30 seconds, with actionable errors.
  • Bounds disconnect cleanup and handles WebSockets that connect after an attempt times out.
  • Adds fake-server regression tests and an Unreleased changelog entry.
File Description
packages/​marionette_mcp/​test/​vm_service/​connect_timeout_test.dart Tests responsive connections, timeouts, retries, and reconnects.
packages/​marionette_mcp/​lib/​src/​vm_service/​vm_service_connector.dart Adds handshake deadlines and bounded connection cleanup.
CHANGELOG.md Documents the connection timeout behavior.

Review effort: Balanced

@mateusz-pietras

Copy link
Copy Markdown
Member

Hello, thank you for the contribution. We will look into the provided solution as well as review issues raised by copilot and follow up shortly.

mateusz-pietras and others added 6 commits October 27, 2026 12:44
An attempt published its VM service, event subscription and registered
services as soon as the WebSocket opened, and looked up the isolate
through the shared service. When a second connect started while the
first was still in flight, the first could finish on the second's
service, and on timeout it skipped its cleanup because the shared
service was no longer its own: its socket stayed open and its
ServiceRegistered events kept landing in the next connection's
registry.

Each attempt now keeps its service, subscription, registered services
and isolate lookup local, publishes them only when it succeeds, and
closes its own connection when it fails. An attempt overtaken by a
newer connect or a disconnect closes itself instead of publishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connect awaited disconnect() for the previous app before its deadline
started and again for its own connection after a failure. Each close
waits up to 5 s for a VM service that stopped reading, so a timed-out
connect could take about 10 s longer than its timeout.

connect now drops both connections without waiting for them to close;
they still close in the background, with the same 5 s limit. The test
freezes a proxied VM service in the middle of a large request, which
makes closing that connection hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"reconnects when the previous VM service stopped answering" passed
without the limit: a VM service that only stops answering still
reads, so closing its connection finishes at once. Closing hangs when
the app stops reading while a request is still queued.

Replace it with a test that freezes a proxied VM service in the middle
of a large request and expects disconnect to return after the limit.
Without the limit, disconnect does not return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndshake

The deadline covered only the VM service handshake. An app paused in
the debugger still answers getVM and getIsolate, so the handshake
finished, and the tool then waited indefinitely for getVersion: service
extensions don't run while the isolate is paused. listExtensions and
getConfiguration had the same problem.

The connect tool now runs the handshake, the version check, the
dynamic tool registration and the session setup under one 30 s
deadline. When it fires, the tool disables the dynamic tools,
disconnects and says what to check. The setup keeps running in the
background, so it changes the connection, the tools and the session
only while its call has not timed out or been replaced by a newer one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The instance, doctor and unregister commands wrapped connect in their
own --timeout, but connect now gives up after its own 30 s default
first. With --timeout above 30 s, a slow but working app failed after
30 s, and `unregister --stale` removed it.

The commands now pass --timeout to connect. The instance commands keep
their message, and doctor prints connect's message instead of "Future
not completed". connect's message no longer tells the reader to "call
connect again" or to check for an app paused in the debugger: the
CLI has no connect call to repeat, and a paused app still finishes the
handshake. The MCP connect tool says both in its own message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
withStepLogging logged a connect call's result into whatever session was
active when the call returned. A call that timed out after a newer
connect had opened a session therefore wrote its error line into the
newer session's steps.md. The connect tool now logs its own step, and
only while the call is still the current one.

Add tests for the race guards that had none: a timed-out call does not
log into a newer session, a replaced call does not disconnect the newer
connection, an attempt overtaken by a newer connect closes itself, and
a stale attempt's ServiceRegistered event does not answer a pending
waitForServiceRegistration. Give the connect tool test and the overlap
test more room before the deadline, so a slow runner can't make them
fail or pass without proving anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mateusz-pietras
mateusz-pietras merged commit eb897eb into leancodepl:main Oct 28, 2026
3 checks passed
@mateusz-pietras

Copy link
Copy Markdown
Member

@uveshm003 Thank you once again for the contribution, I added small clean ups and connection hardening. This will land in the next release

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.

connect sometimes hangs for minutes with no timeout — timeline attached

3 participants