Skip to content

fix: correct IDB recovery error reporting and resend safety - #44

Merged
cryptotavares merged 4 commits into
mainfrom
cryptotavares/idb-recovery-followups
Oct 29, 2026
Merged

cryptotavares merged 4 commits into
mainfrom
cryptotavares/idb-recovery-followups

Conversation

@cryptotavares

@cryptotavares cryptotavares commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary: Follow-up to #43. Report a command that fails during backend disposal as an interruption, not as a missing companion. Resend on uncertain delivery only for known read-only idb commands. Remove HID and verdict checks that idb stderr can never match.

Problem

#43 added one reconnect and one resend for a lost companion or an invalidated simulator HID connection. Review found four problems.

1. Disposal during a command gives wrong advice. Disposal can start while a tool call runs. Triggers are the core request queue after a 30 s timeout, core stop(), and device-mcp's own SIGINT, SIGTERM or stdin EOF shutdown. The failed command then calls reconnect(), which throws because the backend is disposed. #idb() wraps that error:

IDB_COMPANION_UNAVAILABLE: iOS simulator <udid>: IDB backend has been disposed. The private idb_companion cannot be reached even after a restart. Check that the simulator is booted (xcrun simctl list devices booted) and that idb_companion is installed, then recreate the session.

The companion is installed. The session ended.

2. launch and terminate are resent on uncertain delivery. isMutatingIdbCommand treats only ui input commands as mutating. On StatusCode.UNAVAILABLE, the request may have arrived, but top-level launch and terminate are sent again.

3. A failed cleanup hides the connect error. In createBackend() and IdbBackend.#connect(), a dispose() that throws replaces the real connect error.

4. Dead patterns and an overstated guarantee. The facebook/idb v1.7.1 source shows:

Pattern or claim Evidence in facebook/idb v1.7.1 Result
dtuhidd ... interrupted FBSimulatorControl/HID/SimulatorDTUHIDConnection.swift: only a companion log line. XPC reconnects on the next send. Never in idb stderr
dtuhidd ... did not answer Same file, connect(): the per-attempt line is a log line. The thrown dtuhidUnresponsive text (SimulatorHIDError.swift) ends with would be discarded without error. Already matched by the would be discarded pattern
retry: unsafe idb/cli/main.py: printed only with --retry-verdict. The verdict covers UI automation failures, not HID failures. device-mcp never passes the flag
"The resend cannot repeat a tap" write() throws dtuhidConnectionInvalidated for the refused event only. ui text sends one down/up pair per character (idb/common/hid.py). A partial command can land if the simulator dies midway

Solution

  1. Detect disposal before recovery. If the backend is disposed after the first failure, do not resend. Throw a teardown error that keeps the first idb error as cause. Non-strict callers get the first result, as before.
  2. Invert the resend list. Keep an explicit list of read-only commands. Treat every other command as mutating.
  3. Make cleanup best-effort. Ignore a cleanup failure and rethrow the original connect error.
  4. Match only thrown idb errors. Keep dtuhidd connection .*has been invalidated and events? sent (?:on|to) it would be discarded. Remove interrupted, did not answer and retry: unsafe. Correct the notDelivered and #idb() documentation.
flowchart TD
    A[idb command] --> B{Classified failure?}
    B -->|no| Z[Return result]
    B -->|yes| C[Reconnect or join newer companion]
    C --> D{Backend disposed?}
    D -->|yes| E["Error: IDB command interrupted<br/>cause = first idb error"]
    D -->|no, reconnect failed| F[IDB_COMPANION_UNAVAILABLE]
    D -->|no, reconnected| G{"notDelivered, or command on read-only list?"}
    G -->|yes| H[Send once more]
    G -->|no| I[IdbUnavailableError: action may have run]

    classDef new fill:#fff3cd,stroke:#d39e00
    classDef stop fill:#f8d7da,stroke:#c82333
    class D,G new
    class E,F,I stop
Loading

Yellow boxes are new or changed. Red boxes stop the call.

What changes

Call or condition Before After
Strict command fails, then the backend is disposed IDB_COMPANION_UNAVAILABLE with install advice Error: IDB command interrupted: the backend was disposed during the call; no resend
Non-strict command fails, then the backend is disposed First result returned First result returned; no resend (unchanged)
launch / terminate fail with StatusCode.UNAVAILABLE Resent once Not resent; falls back to simctl as before
describe, list-apps, screenshot, log, ui describe*, ui wait fail with StatusCode.UNAVAILABLE Resent once Resent once (unchanged)
Connect fails and the cleanup also fails Cleanup error thrown Connect error thrown
dtuhidd ... was interrupted / did not answer the liveness probe (attempt N of 5) text Classified as IDB_HID_UNAVAILABLE Not classified (companion log lines only)
dtuhidd did not answer a liveness probe in 5 attempts ... would be discarded without error IDB_HID_UNAVAILABLE IDB_HID_UNAVAILABLE (unchanged)

Decisions for reviewers

Decision Reason
Throw a new Error with cause, not rethrow the first error The first error is a raw idb failure, for example Failed to connect to companion. Alone, it looks like a device fault. The new message names the real reason, and cause keeps the detail.
Plain Error, not a new IdbUnavailableError code Disposal is not a transport fault and has no remediation. A new code would extend the exported type for a teardown path.
Keep one reconnect and one resend for HID invalidation In the observed incident the connection was already stale, so the first event failed and nothing landed. Only the documentation changes.
Remove retry: unsafe handling It is unreachable: device-mcp never passes --retry-verdict, and the verdict does not cover HID failures.
Read-only list: describe, list-apps, screenshot, log, ui describe-all, ui describe-point, ui describe, ui wait It covers every read-only idb command that idb-backend.ts sends. An unknown or new command defaults to "no resend".
Discard the cleanup error instead of attaching it The connect error is the actionable one. dispose() errors stay visible on explicit disposal.
Mark the #43 selectDevice() entry as BREAKING: #43 is not released yet, so its entries can still be edited.

Review guide

# File What to check
1 src/backends/idb-errors.ts HID_PATTERNS and their source comments; notDelivered JSDoc
2 src/backends/idb-backend.ts #idb() disposed checks and interrupted(); isMutatingIdbCommand; #connect() catch
3 src/backends/index.ts createBackend() catch
4 Tests idb-errors.test.ts, idb-backend.test.ts (companion recovery block), lazy-backend.test.ts (error propagation)
5 README.md Recovery table
Testing
  • yarn build passes.
  • yarn lint passes, including ESLint, formatting, constraints, dependency checks, and changelog validation.
  • yarn test passes: 31 files, 663 passed tests, 6 SDK-gated skips. Typechecking and coverage thresholds pass. idb-errors.ts has 100 % coverage.
  • New tests, each fails on main and passes on this branch:
    • disposal during a pending ui tap whose first attempt then fails with Failed to connect to companion: rejects with the interruption error, not IdbUnavailableError, no install advice, cause is the first error, one tap only;
    • disposal after a failed command joins a companion that another caller replaced: interruption error, one tap only;
    • launch and terminate on StatusCode.UNAVAILABLE: sent once, then the simctl fallback runs;
    • failed connect with a failing cleanup, in IdbBackend and in createBackend(): the connect error is thrown;
    • the interrupted and per-attempt liveness log lines are not classified.
  • Other new tests: describe, list-apps, screenshot and log are still resent once on StatusCode.UNAVAILABLE; a non-strict command whose first attempt fails during disposal is not resent; the 5-attempt liveness error is still classified as IDB_HID_UNAVAILABLE.
  • No live simulator run. All changes are in error handling paths that the unit tests drive with mocked exec and companion connections.
Possible follow-ups (out of scope)
  • reconnect() stops an active screen recording.
  • The selectDevice() hint when the previous backend fails to dispose.
  • The simctl lookup in src/utils/platform.ts.
  • Cache the companion executable path.
  • Classify failures on stderr only.
  • unref() the companion stdio.
  • Detect a minimum companion version.

Follow-up to #43.

- Report a command that fails while the backend is disposed as an
  interruption, not IDB_COMPANION_UNAVAILABLE, and never resend it.
- Treat every idb command except known read-only ones as mutating, so
  launch and terminate are not resent on uncertain delivery.
- Keep the original connect error when cleanup also fails.
- Drop HID patterns and the retry: unsafe check that cannot match idb
  stderr, and correct the notDelivered documentation.
@cryptotavares
cryptotavares requested a review from a team as a code owner October 29, 2026 13:18
@cryptotavares
cryptotavares enabled auto-merge (squash) October 29, 2026 14:39
@cryptotavares
cryptotavares merged commit 918eec1 into main Oct 29, 2026
35 checks passed
@cryptotavares
cryptotavares deleted the cryptotavares/idb-recovery-followups branch October 29, 2026 14:57
cryptotavares added a commit to MetaMask/client-mcp-core that referenced this pull request Oct 29, 2026
> **Summary:** Upgrade `@metamask/device-mcp` to `^0.7.0`. 0.7.0
declares `DeviceBackend.dispose()`, so the mobile driver calls it
without a type cast. iOS sessions get private IDB companion teardown and
recovery.

## Problem

Core pins `@metamask/device-mcp` `^0.6.0`. On 0.x a caret range does not
pick up 0.7.0.

- The `DeviceBackend` type in 0.6.0 has no `dispose()`. #70 therefore
calls the hook through a structural cast in
`MobilePlatformDriver.dispose()`.
- The README says core "does not update the core dependency range" and
that older device-mcp versions stay compatible. That text was correct
before 0.7.0 was released.

## Solution

Adopt the device-mcp 0.7.0 release. Remove the cast and the temporary
README text.

0.7.0 contains:

- [device-mcp#43](MetaMask/device-mcp#43):
optional `DeviceBackend.dispose()` and `reconnect()`, one private IDB
companion per iOS backend, and recovery when the companion or the
simulator HID connection is lost.
- [device-mcp#44](MetaMask/device-mcp#44):
correct recovery error reporting, and no resend of commands that can
change the device.
- [device-mcp#42](MetaMask/device-mcp#42):
Android clipboard helper.

```mermaid
flowchart TD
    A[mm cleanup / launch --force / stop] --> B[DriverLifecycle]
    B --> C[MobilePlatformDriver.dispose]
    C --> D{backend.dispose?}
    D -->|IdbBackend 0.7.0| E[Stop the private companion and recording]
    D -->|AdbBackend: no hook| F[Nothing to release]

    classDef new fill:#fff3cd,stroke:#d39e00
    class C,E new
```

Yellow boxes change: the driver calls the typed hook, and the 0.7.0 iOS
backend releases its companion.

## What changes

| Boundary | Before | After |
|---|---|---|
| `package.json` dependency range | `@metamask/device-mcp ^0.6.0` |
`^0.7.0` |
| `yarn.lock` resolution | npm 0.6.0 | npm 0.7.0 (dependencies
unchanged) |
| `MobilePlatformDriver.dispose()` | Casts the backend to `DeviceBackend
& { dispose?() }` | Calls `this.#backend.dispose?.()` on the typed
`DeviceBackend` |
| `README.md` teardown paragraph | Older device-mcp versions are
compatible; the range is not updated | Core requires 0.7.0 or later; iOS
releases its companion; Android has no hook |
| Disposal test name | "supports device-mcp versions without disposal" |
"completes for backends without a disposal hook" |

## Decisions for reviewers

| Decision | Reason |
|---|---|
| Keep the optional call `dispose?.()` | `dispose()` is still optional
in the 0.7.0 `DeviceBackend` type. `IdbBackend` implements it;
`AdbBackend` and `AppiumBackend` do not. |
| Keep the test for a backend without the hook | It now covers the ADB
backend, not an older device-mcp version. |
| Async IIFE instead of `Promise.resolve().then(...)` | Same behaviour,
simpler, and the same style as device-mcp. |
| `chore:`, not breaking | Core's own API does not change. Every symbol
core imports from device-mcp still exists. The `selectDevice()` break in
0.7.0 is on `LazyDeviceBackend`, which core does not use. |
| No changelog entry | Matches the previous bump (#69). |

## Review guide

| # | File | What to check |
|---|---|---|
| 1 | `package.json`, `yarn.lock` | The range is `^0.7.0`. Only the
device-mcp entry and the workspace range change. |
| 2 | `src/platform/mobile-platform-driver.ts` | The cast is gone.
Shared concurrent disposal and retry after failure are unchanged. |
| 3 | `README.md` | The teardown paragraph under "Session-scoped device
teardown". |
| 4 | `src/platform/mobile-platform-driver.dispose.test.ts` | Only the
test name changes. |

<details>
<summary>Testing</summary>

Device-mcp 0.7.0 is installed from npm, without yalc.

- `yarn build` passes.
- `yarn lint` passes, including ESLint, formatting, constraints,
dependency and dedupe checks, and changelog validation.
- `yarn test` passes: 53 files and 1,835 tests. Typechecking and
coverage thresholds pass. Thresholds are unchanged.
- `tsc --noEmit -p tsconfig.test.json` reports 8 errors on `main` and 8
on this branch. All 8 already exist on `main`.
- In the installed package, `dist/backends/types.d.mts` declares
`dispose?(): Promise<void>` and `reconnect?(): Promise<void>` on
`DeviceBackend`. `IdbBackend` declares `dispose()`; `AdbBackend` and
`AppiumBackend` do not.

No device run for this PR. The live teardown and recovery checks are in
device-mcp#43 and #70, and they used the same code.

</details>
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