Skip to content

feat: dispose session-owned platform drivers on teardown - #70

Merged
cryptotavares merged 6 commits into
mainfrom
cryptotavares/session-scoped-device-teardown
Oct 29, 2026
Merged

cryptotavares merged 6 commits into
mainfrom
cryptotavares/session-scoped-device-teardown

Conversation

@cryptotavares

@cryptotavares cryptotavares commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary: Dispose session-owned platform drivers after consumer cleanup, including failure and shutdown paths. Mobile drivers forward disposal to their own device backend so lifecycle-capable backends release their companions automatically. One server-owned lifecycle retries failed disposals. Only the current session's failures fail a call.

Companion implementation: MetaMask/device-mcp#43.

Problem

Core calls the consumer's cleanup() method to close a testing session. That contract does not release device-backend resources. Mobile cleanup can close the app while leaving its IDB companion running.

The consumer can also clear its driver during cleanup. Looking up the driver afterward loses the resource owner. Cleanup errors create another gap: releasing the backend must still run when closing the app fails.

sequenceDiagram
    participant C as Core
    participant S as Consumer session manager
    participant D as Mobile driver
    C->>S: cleanup
    S->>S: Close app and clear driver
    S-->>C: Cleanup result
    Note over C,D: No captured driver or disposal hook releases backend resources
Loading

Solution

Introduce one server-owned cleanup lifecycle and an optional platform-driver disposal contract.

  1. Capture the owner. Read getPlatformDriver() before consumer cleanup can clear the driver.
  2. Keep cleanup order. Run consumer app/session cleanup first. Dispose the captured driver afterward, even when cleanup throws.
  3. Retain failed disposals in the server. createServer owns one DriverLifecycle. Tools reach it through ToolContext.cleanupSession(). A later cleanup or daemon stop retries the retained drivers.
  4. Fail only for the current session. Consumer cleanup and the current driver's disposal decide the result. Drivers from earlier sessions are retried best-effort and in parallel. Their failures go to the daemon log and do not fail the call.
  5. Use the lifecycle everywhere. Apply it to cleanup, forced relaunch, failed launch, daemon stop, and server startup rollback.
flowchart TD
    T[cleanup / launch --force / failed launch] -->|ToolContext.cleanupSession| L[DriverLifecycle, one per server]
    S[Daemon stop] --> L
    R[Startup rollback] --> L
    L --> B[Capture session-owned driver]
    B --> C[Run consumer cleanup]
    C -->|success or failure| D[Dispose captured driver]
    C -->|in parallel| P[Retry older retained drivers, best-effort]
    P -->|failure| W[Keep retained, write WARN to daemon log]
    D --> E{Cleanup or current disposal failed?}
    E -->|no| F[Return consumer cleanup result]
    E -->|yes| G[Retain the failed current driver]
    G --> H[Report current session errors to caller]
    G -.->|next cleanup or stop| P
    W -.->|next cleanup or stop| P
    D --> I[Mobile driver delegates to its backend]

    classDef new fill:#fff3cd,stroke:#d39e00
    classDef stop fill:#f8d7da,stroke:#c82333
    class L,B,D,P,W,G,I new
    class H stop
Loading

Yellow boxes are new. Red boxes stop successful cleanup completion.

What changes

Call or condition Before After
mm cleanup Call consumer cleanup Capture the driver, clean up the consumer, then dispose the driver
Consumer clears its driver Core loses access after cleanup The captured driver remains available for disposal
Consumer cleanup throws Propagate the cleanup error Attempt disposal, then propagate the original or combined errors
Current driver disposal fails No core disposal lifecycle Fail the call and retain the driver in the server lifecycle; the next cleanup or daemon stop retries it
Driver from an earlier session still fails n/a Retry it best-effort, in parallel, on every cleanup; log a [WARN] line in the daemon log; do not fail the current cleanup or launch
Forced relaunch Clean up the active session before launch Also await old-driver disposal before launching
Consumer launch rejects Return the classified launch error Attempt partial-session cleanup and preserve the original launch error
Daemon stop or startup rollback Clean up consumer resources Also attempt captured driver disposal and a final best-effort retry of retained drivers
Older backend has no disposal hook Existing mobile operations Mobile disposal completes without a backend call
Tool context built outside the daemon n/a cleanupSession(manager) falls back to per-manager retention

Decisions for reviewers

Decision Reason
Keep IPlatformDriver.dispose() optional Browser drivers and existing consumer implementations remain compatible.
Capture before consumer cleanup Consumers can clear their driver reference as part of resetting session state.
The server owns retry state (DriverLifecycle), not a map keyed by the session manager Retry state must not depend on which wrapper of the session manager a caller holds. On the Phase 1 stack each request gets a new wrapper, so a key-based map loses failed disposals after the request. Cleanup, stop, and rollback must also share one retry list.
Optional ToolContext.cleanupSession() Tools use the server lifecycle without knowing about wrappers. Contexts built by tests or other hosts still work through the fallback.
Preserve launch errors after best-effort rollback Consumer error codes remain the actionable launch result, even if rollback also fails. The rollback error goes to the debug log.
Only the current session can fail a call; older retained drivers are retried best-effort A driver that keeps failing would otherwise fail every later cleanup and block launch --force permanently. Each retry can take about 10 s, so serial retries also push cleanup toward the 30 s request timeout. Parallel retries and daemon-log warnings keep the retry without blocking the user.
Support older device-mcp versions structurally Core can merge independently. Companion teardown activates when a consumer supplies a lifecycle-capable backend.
Leave unexposed drivers with the consumer Core cannot capture a driver that the consumer never returns. Consumers must dispose those partial-launch resources locally.

Review guide

Read the files in this order:

# File What to check
1 src/server/cleanup-session.ts DriverLifecycle: capture order, error aggregation, retained disposal ownership, fallback for contexts without a lifecycle
2 src/platform/mobile-platform-driver.ts, src/platform/types.ts Optional hook, concurrent disposal, and retry behavior
3 src/server/create-server.ts, src/types/http.ts One lifecycle per server, ToolContext.cleanupSession, shutdown and startup rollback
4 src/tools/cleanup.ts, src/tools/launch.ts Cleanup integration, forced relaunch ordering, and launch-error preservation
5 src/server/session-manager.ts Consumer ownership contract
6 Changed and new tests, vitest.config.mts Lifecycle boundaries, server-level retry, compatibility, and automatically raised coverage thresholds
7 README, agent reference, changelog Release adoption and consumer partial-launch responsibilities
Testing
  • yarn build passes.
  • yarn lint passes, including ESLint, formatting, constraints, dependency checks, and changelog validation.
  • yarn test passes after merging main (Phase 1 stack, chore: bump @metamask/device-mcp to ^0.5.0 #58–fix!: review follow-ups: off-screen animations, no late clicks, batchTimeoutMs cap #71): 53 files and 1,835 tests. Typechecking and configured coverage thresholds pass (97.53 / 94.91 / 96.73 / 97.68).
  • The package-type check (attw --pack) passes for CommonJS, ESM, and bundler resolution.
  • Core's normal test command raises coverage thresholds automatically. This branch includes that generated update.
  • Lifecycle tests cover driver capture before reset, consumer errors, retained retries, older backends, forced relaunch, failed launch, and server teardown.
  • Server integration tests: a failed /tool/cleanup is retried by the next /tool/cleanup, and by server.stop(). Both tests fail when the retry state is keyed by a per-request session-manager wrapper.
  • A stuck driver from an earlier session does not fail a later /tool/cleanup or launch --force. It is retried on every cleanup, in parallel with other retained drivers, and server.stop() logs it.
  • Merged main after the Phase 1 stack landed. Conflicts were "keep both sides": in mobile-platform-driver.ts, keep #excludeDevOverlay, #windowSize and #disposing; in platform/types.ts, keep dispose?() and the click signal docs; in create-server.ts and types/http.ts, keep signal and the removal of knowledgeStore, and add driverLifecycle and cleanupSession.
Live check with both built branches Result
Cleanup A while B uses another simulator B continues returning its expected device ID
Cleanup A while B uses the same simulator B continues returning its expected device ID
Query the disposed backend Rejects with IDB backend has been disposed
Compare companion processes afterward Both pre-existing companions remain; all test-owned companions are removed
Mobile app on the Phase 1 stack + device-mcp#43: launch/cleanup, forced relaunch ×2, kill companion, kill daemon, simulator reboot The session companion is released on every path; no companion accumulates (details in device-mcp#43)

The live checks read the screen and tap non-wallet elements. They do not submit transactions or exercise wallet flows.

Docs, adoption, and boundaries
  • README.md documents session-owned drivers, fresh instances per launch, the server-owned retry, and consumer-managed partial-launch failures.
  • SKILL.md clarifies that --shutdown stops the worktree daemon. Driver disposal does not stop Metro or shut down simulators.
  • CHANGELOG.md records the lifecycle hook, cleanup entry points, and server-owned retries.
  • Publish the linked device-mcp implementation before consumers adopt it. This PR keeps the existing device-mcp dependency range.
  • The Mobile consumer disposes the iOS driver in its adapter cleanup, which covers a failed launch where the provider resets its state before core can see the driver. Disposal is idempotent with core's.
  • Consumers that call their own cleanup() directly must also manage driver disposal. Drivers hidden during partial launch remain the consumer's responsibility.
  • Separate companions isolate connection teardown. Sessions targeting the same simulator still share app state.

Failed disposals were kept in a module-level WeakMap keyed by the session
manager object a caller passed. When a tool receives a per-request wrapper
of the manager (a Proxy on the core stack), a failed disposal is stored
under that wrapper and is lost after the request. The consumer already
cleared its driver, so the companion leaks, and stop() and startup
rollback never see it.

- Add DriverLifecycle, which owns the pending set. createServer creates
  one and uses it for stop(), startup rollback and the tools, through a
  new optional ToolContext.cleanupSession().
- Keep cleanupSession(manager, lifecycle?) with a per-manager fallback
  for contexts built outside the daemon.
- Add createServer tests: a failing /tool/cleanup is retried by the next
  /tool/cleanup and by stop().
itsyoboieltr
itsyoboieltr previously approved these changes Oct 29, 2026
cryptotavares added a commit to MetaMask/device-mcp that referenced this pull request Oct 29, 2026
> **Summary:** Give each iOS backend its own IDB companion, dispose only
its owned resources, and recover when that companion dies or loses the
simulator's touch input during a session. Ending one session leaves
other companions available, including sessions on the same simulator.

Companion integration:
MetaMask/client-mcp-core#70.

## Problem

The IDB backend connects through a shared UDID mapping. It has no
disposal hook or record of companion ownership. Closing a testing
session can leave its companion running. Disconnecting a shared mapping
does not establish which process the session can stop.

Parallel agents need a clear boundary between their resources. A
simulator UDID identifies a device, but does not identify a session. Two
sessions can target the same simulator. Teardown therefore needs a
session-owned connection, even when the device ID matches.

A long-lived companion also breaks silently. In a real iOS
release-validation run, the simulator restarted during a session. `idb
connect` reused the old companion from the shared mapping. Reads
(`describe-all`, screenshots) still worked, but every tap failed with
`The dtuhidd connection … has been invalidated; events sent on it would
be discarded`. The only fix was to find and kill the companion by hand.

```mermaid
sequenceDiagram
    participant A as Session A
    participant I as Shared IDB mapping
    participant B as Session B
    A->>I: Connect simulator UDID
    B->>I: Connect simulator UDID
    Note over A,B: The mapping does not establish session ownership
    A->>A: End session
    Note over A,I: No backend disposal contract releases its companion
    Note over I: Simulator restarts: the mapped companion keeps serving reads, but every tap is discarded
```

## Solution

Add optional backend lifecycle hooks and give each IDB backend a private
companion.

1. **Own the connection.** Start one companion child with a private
socket. Route IDB commands and recordings through that connection.
2. **Drain initialization.** Share an in-flight connection attempt.
Disposal waits for that attempt and prevents later device commands.
3. **Release owned resources.** Finish the active recording, stop the
companion child, and remove its private directory. Retain failed
disposal state for retry.
4. **Connect lifecycle entry points.** Dispose on device selection,
stdio close, stdin EOF, SIGINT, and SIGTERM.
5. **Recover during a session.** Watch the companion for its whole life.
When it is gone, or when a command fails because the companion or the
simulator HID connection is lost, start a fresh private companion and
send the command once more, only if the first attempt was provably not
delivered.
6. **Limit orphans.** Start companions with `--terminate-offline true`
and a one-hour idle shutdown. On the first connection in a process,
remove private directories that a dead process left behind.

```mermaid
flowchart TD
    A[First device call] --> B[Start private companion]
    B --> C[Route commands through private connection]
    C --> X{Command failed?}
    X -->|companion gone, HID invalidated, companion unreachable| R[Replace private companion]
    R --> S{First attempt delivered?}
    S -->|no| C2[Send once more]
    S -->|maybe| T[IdbUnavailableError with code and remediation]
    C2 -->|same failure| T
    C --> D[Selection or server shutdown]
    D --> E[Wait for initialization]
    E --> F[Stop owned recording]
    F --> G[Stop owned companion child]
    G --> H[Remove owned socket directory]
    D --> I[Reject later calls on disposed backend]

    classDef new fill:#fff3cd,stroke:#d39e00
    classDef stop fill:#f8d7da,stroke:#c82333
    class B,C,E,F,G,H,R,C2 new
    class I,T stop
```

Yellow boxes are new. Red boxes stop the call.

## What changes

| Call or condition | Before | After |
|---|---|---|
| Create an iOS backend | Connect through a shared UDID mapping | Start
a private companion for that backend |
| Dispose session A | No backend hook | Stop A's recording and
companion; B remains available |
| Two sessions target one simulator | Connection ownership follows the
shared device mapping | Each backend owns a separate companion |
| Dispose during initialization | No lifecycle coordination | Wait for
initialization, release resources, and reject the waiting device call |
| Dispose an unused lazy backend | No lifecycle hook | Complete without
connecting to a device |
| Select another device | Reset the lazy backend synchronously | Await
previous backend disposal before switching |
| Stop signal or stdin EOF | No explicit backend disposal | Dispose the
backend and close the server |
| Disposal fails | No retry contract | Report the failure and retain
state for another disposal attempt |
| Companion exits during a session | Every later call fails on a dead
connection | The next call starts a fresh private companion |
| Simulator HID connection invalidated (`dtuhidd … invalidated`) | Every
tap fails with raw idb stderr | Replace the companion and send once
more; then `IDB_HID_UNAVAILABLE` |
| Companion unreachable (`Failed to connect to companion`) | Raw idb
stderr | Replace the companion and send once more; then
`IDB_COMPANION_UNAVAILABLE` |
| Delivery uncertain (`StatusCode.UNAVAILABLE`, `retry: unsafe`) | Raw
idb stderr | Resend read commands once; never resend input commands; the
error says the action may have run |
| Simulator shuts down | Companion stays alive and broken | Companion
exits (`--terminate-offline true`); the next call reconnects |
| Host killed with SIGKILL | Companion and `/tmp/device-mcp-idb-*` leak
| Companion exits with its pipes or after 1 h idle; the next process
removes the directory |
| Explicit simulator ID | `idb describe` can register a shared companion
| Resolved through `simctl` first; `idb describe` only for physical
devices |

## Decisions for reviewers

| Decision | Reason |
|---|---|
| Use one private companion per backend | Ownership stays local.
Sessions on one simulator do not need shared-companion reference
counting. |
| Keep `DeviceBackend.dispose()` and `reconnect()` optional | Existing
Android, Appium, and custom backends can keep their current lifecycle
behavior. Core can call `reconnect()` when present. |
| Make `selectDevice()` asynchronous | The previous backend must release
its resources before the lazy backend switches devices. Callers must
await selection. This is breaking for programmatic callers. |
| Signal only stored child-process handles | Teardown uses neither
global process discovery nor shared IDB disconnect commands. |
| Keep simulator and app lifecycle separate | Backend disposal releases
connection resources. Consumer cleanup remains responsible for closing
its app. |
| Retry once, after a fresh companion | The companion discards HID
events on an invalid connection, and an unreachable companion received
nothing. The resend cannot repeat a tap. A second failure is final. |
| Never resend input when delivery is uncertain | On
`StatusCode.UNAVAILABLE` the request may have arrived before the channel
failed. A repeated tap on a wallet confirmation is worse than an error.
|
| Do not pass `--retry-verdict` | Older fb-idb versions reject it, and
it only covers UI automation failures. A `retry: unsafe` verdict is
still honored when present. |
| `--idle-shutdown-time 3600` | It bounds the lifetime of an orphan. The
automatic reconnect makes an idle exit invisible to callers. |
| Sweep only directories with a dead owner and a dead socket | Live
sessions and shared companions are never touched. A leftover companion
is signaled only when its command line names that directory's socket, so
a reused PID is never signaled. |
| Export `IdbUnavailableError` with `code` | Core can map typed codes to
its own error codes instead of matching stderr text. |

## Review guide

Read the files in this order:

| # | File | What to check |
|---|---|---|
| 1 | `src/backends/idb-connection.ts` | Private connection ownership,
readiness validation, permanent exit listener, lifetime flags,
`owner.json`, directory cleanup |
| 2 | `src/utils/stop-child-process.ts` | Owned-child exit handling and
bounded signal escalation |
| 3 | `src/backends/idb-errors.ts` | Failure patterns, `notDelivered`,
remediation text |
| 4 | `src/backends/idb-backend.ts` | Connection guards, `reconnect()`,
the `#idb` wrapper (one reconnect, generation check for concurrent
callers, no input resend on uncertain delivery), recording shutdown,
disposal retry |
| 5 | `src/backends/idb-companion-sweep.ts` | Stale criteria, PID-reuse
safety, once per process |
| 6 | `src/backends/index.ts`, `src/cli/device-mcp.ts`,
`src/tools/select-device.ts`, `src/utils/platform.ts` | Lazy
initialization and `reconnect` passthrough, awaited selection, EOF and
signal integration, `simctl` lookup |
| 7 | Changed and new tests | Cross-session isolation, partial startup
failures, retry, shutdown ordering, recovery, sweep |
| 8 | README, agent reference, changelog | Lifecycle contract, recovery
and error codes, asynchronous selection migration |

<details>
<summary>Testing</summary>

- `yarn build` passes.
- `yarn lint` passes, including ESLint, formatting, constraints,
dependency checks, and changelog validation.
- `yarn test` passes: 31 files, 652 passed tests, and 6 SDK-gated skips.
Typechecking and configured coverage thresholds pass. `idb-backend.ts`
statement coverage rises from 69 % to 75 %.
- Recovery tests: companion exit after readiness → respawn; HID
invalidation → one reconnect and one retry → typed error on the second
failure; uncertain delivery → no input resend; concurrent failures share
one reconnect; spawn flags; sweep with real sockets and processes.
- Live macOS checks exercise the built device package with the built
core lifecycle branch (client-mcp-core#70), on the Phase 1 core stack,
with the Mobile app on an iPhone 17 Pro simulator.

| Live check | Result |
|---|---|
| Core cleanup of A on different simulators | B still returns its
expected device ID |
| Core cleanup of A on the same simulator | B still returns its expected
device ID |
| Stdio EOF, SIGINT, and SIGTERM | Each server exits with code 0 and
removes its owned companion |
| `mm launch` → `mm cleanup` | The session companion and its directory
are removed in under 1 s; shared companions are unchanged |
| Kill the private companion during a session | The next `list-testids`
and `click` succeed on a fresh companion |
| `kill -9` the host daemon | The companion exits by itself; the next
launch removes the leftover directory |
| Shut down and boot the simulator during a session | The companion
exits with the simulator; the next tap reconnects and succeeds |
| `mm launch --force` twice | One private companion at a time |

The live checks read the screen and tap non-wallet elements. They do not
submit transactions or exercise wallet flows. A live companion whose HID
connection is invalidated (the original incident) did not reproduce on
demand; unit tests cover that path. Recording exit ordering uses a
mocked child-process test.

</details>

<details>
<summary>Docs and adoption</summary>

- `README.md` documents disposal, private connection ownership, awaited
device selection, and a new "Recovery and lifetime" section (reconnect,
error codes, companion flags, sweep).
- `SKILL.md` documents explicit device IDs, server shutdown behavior,
and what to do on `IDB_HID_UNAVAILABLE` / `IDB_COMPANION_UNAVAILABLE`.
- `CHANGELOG.md` records the lifecycle hooks, recovery, companion
lifetime, and the asynchronous selection change.
- `selectDevice()` is now asynchronous, so this needs a minor release.
Publish it before consumers adopt the lifecycle. The linked core change
forwards disposal without requiring this release at build time.
- Separate companions isolate connection teardown. App state remains
shared when sessions target the same simulator.
- An uncatchable process kill cannot run disposal. The companion
lifetime flags and the startup sweep limit what it leaks. Metro shutdown
remains outside this hook.

</details>
cryptotavares added a commit that referenced this pull request Oct 29, 2026
…TimeoutMs cap (#71)

> **Summary:** Fix the review comments on #62, #64 and #65 in one PR on
top of the stack. The approved PRs stay unchanged.

**Stack context:** mm CLI v2, Phase 1, **P1-43**. Base:
[#69](#69), the top of
the stack.

## Problem

| Comment | Problem |
|---|---|
| [#65: off-screen
animations](#65 (comment))
| The browser settle ignored animations whose target was outside the
viewport or had zero size. A drawer sliding in, a bottom sheet or an
accordion opening from height 0 start that way, so the observation
caught them mid-move. |
| [#64: late
click](#64 (comment))
| `click` / `type` checked the request signal only before the target
wait. When the request timed out during the wait, the agent got "timed
out", but the click still ran once the target appeared. |
| [#64:
batchTimeoutMs](#64 (comment))
| `batchTimeoutMs` accepted up to 300 s, but the queue and the CLI stop
`run_steps` after 30 s. |
| [#62: refs after
`none`](#62 (comment))
| `--observe none` does not refresh refs; some refs can then click a
different element. Not documented. |
| [#62: reads and
animations](#62 (comment))
| Reads do not wait for animations, even with `--observe full`. Not
documented. |

## Solution

```mermaid
flowchart TD
    A[click / type] --> B[Wait for target]
    B --> C{Request timed out?}
    C -->|yes| D[MM_REQUEST_ABORTED, no action]
    C -->|no| E[Click / tap / type]
    E --> F[Mutating observation]
    F --> G[Wait for every running finite animation, max 1 s]
    G --> H[Read tree]

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

Yellow boxes are new or changed.

## What changes

| Commit | Change |
|---|---|
| `fix: wait for animations that start off screen or at zero size` |
`animationsSettled` waits for every running, finite animation of an
element. Infinite animations are still ignored; the 1 s cap is
unchanged. |
| `fix: do not click or type after the request timed out` |
`IPlatformDriver.click` / `type` take an optional `signal`. Both drivers
call `signal.throwIfAborted()` after the target wait, right before the
action (mobile `type` also after the focus tap). The tools pass
`context.signal` and return `MM_REQUEST_ABORTED`. |
| `fix!: cap run_steps batchTimeoutMs at 30 s` | Schema range
1000–30000, like the `cdp` `timeoutMs`. |
| `docs: …` | README, SKILL.md and CLI help: refs after `--observe
none`, reads and animations, abort limits, `batchTimeoutMs`. |

## Decisions for reviewers

| Decision | Reason |
|---|---|
| One new PR instead of changing #62/#64/#65 | Pushing to an approved PR
dismisses its approval, and the restack would change every PR above it.
|
| An action that already started is not cancelled |
`@metamask/device-mcp` takes no signal (`waitForElement`, `tapElement`,
`typeText`), and Playwright's actionability checks are part of
`locator.click`. The driver checks the signal between steps. Cancelling
inside device-mcp needs a device-mcp change (follow-up). |
| Cap `batchTimeoutMs` at 30 s instead of raising the request budget for
batches | Matches the CLI and queue default; long flows can be split
into several batches. |
| Reads stay without the animation wait (docs only) | It is a less exact
snapshot, not a wrong action. Can be revisited. |
| The 5 s testId collection cap stays | Agreed with the reviewer: watch
real usage first. |

## Breaking changes

`run_steps` rejects `batchTimeoutMs` above 30000 (validation error),
also when a server raised `requestTimeoutMs`.

## Review guide

| # | File | What to check |
|---|---|---|
| 1 | `src/tools/utils/in-page.ts` | `animationsSettled` without the
viewport/size filter. |
| 2 | `src/platform/playwright-driver.ts`,
`src/platform/mobile-platform-driver.ts`, `src/platform/types.ts` |
`throwIfAborted()` placement. |
| 3 | `src/tools/interaction.ts` | Signal passed; abort mapped to
`MM_REQUEST_ABORTED`. |
| 4 | `src/validation/schemas.ts` | `batchTimeoutMs` range and
description. |
| 5 | `README.md`, `SKILL.md`, `src/cli/mm.ts` | Docs. |

<details>
<summary>Testing</summary>

`yarn test`: 51 files, 1809 tests pass. `yarn lint` passes. Each commit
passes the tests on its own.

**Extension** (e2e fixture wallet, headless), before = #69, after = this
branch:

| Check | Before | After |
|---|---|---|
| Drawer sliding in from off screen (400 ms): observationMs | 515–516
(did not wait) | 914–929 (waited) |
| Accordion from height 0 (400 ms): observationMs | 515–516 | 916–917 |
| Home click observationMs | 513–514 | 512–515 |
| Send / account list click observationMs | 12–183 / 14–164 | 14–212 /
15–164 |
| `run_steps` click whose target appears 1 s after the 30 s queue
timeout | **clicked** | **not clicked** |

**Android** (device-mcp 0.6.0, real funds, benign taps): all smoke and
ref checks pass, same as #69.

**iOS** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref
checks pass, same as #69. (A first attempt hit the stale idb companion
after a simulator reboot, `dtuhidd connection ... invalidated`; fixed by
restarting the companion. See MetaMask/device-mcp#43 and #70.)

</details>
…-scoped-device-teardown

# Conflicts:
#	src/platform/mobile-platform-driver.ts
#	src/platform/types.ts
#	src/server/create-server.ts
#	src/tools/test-utils/mock-factories.ts
#	src/types/http.ts
@cryptotavares
cryptotavares merged commit 227674a into main Oct 29, 2026
27 checks passed
@cryptotavares
cryptotavares deleted the cryptotavares/session-scoped-device-teardown branch October 29, 2026 14:42
cryptotavares added a commit 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