Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional platform-driver disposal and forward mobile disposal to lifecycle-capable device-mcp backends.

### Fixed

- Dispose session-owned drivers after consumer cleanup, including cleanup errors, forced relaunch, failed launch, daemon shutdown, and server startup rollback. Retain failed disposals in one server-owned lifecycle, so the next cleanup or daemon stop retries them.

## [0.12.0]

### Added
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@ type ISessionManager = {
};
```

### Session-scoped device teardown

Core captures `sessionManager.getPlatformDriver?.()` before calling the consumer's `cleanup()`, then calls the driver's optional `dispose()` hook. Disposal runs even when consumer cleanup fails. This applies to `mm cleanup`, forced relaunch, failed launch, daemon shutdown, and server startup rollback. A failed disposal fails that cleanup call and is retained for a later attempt. The daemon owns that retention (one per `createServer` instance), so a later `mm cleanup`, forced relaunch, or daemon stop retries it, whatever wrapper of the session manager a tool receives. Only the current session's cleanup and disposal can fail a call. Drivers retained from earlier sessions are retried best-effort and in parallel. When they fail again, the daemon writes a `[WARN]` line to its log, and the current cleanup or launch continues. Tools reach this retention through `ToolContext.cleanupSession()`; without it (a context built outside the daemon), the tools fall back to retention per session-manager object.

`MobilePlatformDriver.dispose()` delegates to its backend's optional `dispose()` hook. With a device-mcp version that implements private IDB companions, this stops only that backend's companion and recording. Older device-mcp versions remain compatible, but do not gain companion teardown. Adopt the device-mcp lifecycle release in the consumer after it is published; this change does not update the core dependency range.

Consumers must expose a session-owned driver through `getPlatformDriver()` and create a fresh driver/backend for each launch. If launch fails before the driver is exposed, the consumer must dispose that locally held driver in its own failure path. Direct calls to a consumer's `cleanup()` also remain the consumer's responsibility.

`mm cleanup --shutdown` additionally stops the worktree's daemon. Driver disposal does not shut down a simulator, stop Metro, or terminate shared IDB companions. Use separate worktrees/daemons and explicit device IDs for independent agent sessions. Separate companions isolate connection teardown even for the same simulator; app state on that simulator remains shared.

### Workflow Context & Capabilities

The `WorkflowContext` aggregates optional capabilities that consumers inject through the `contextFactory`. The tool system checks for capabilities at runtime — tools that depend on missing capabilities return clear errors.
Expand Down
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ mm describe-screen # 4. Re-describe after every action to get fresh refs
mm cleanup --shutdown # 5. Clean up when done
```

Cleanup also disposes the session driver's owned resources after consumer app/session cleanup, including when cleanup fails. With a lifecycle-capable device-mcp backend, this releases its private companion and recording. `--shutdown` stops the worktree's daemon; it does not shut down a simulator or stop Metro. Independent agents should use separate worktrees/daemons and explicit device IDs.

**Critical rules:**

- **Always `describe-screen` before interacting.** Refs like `e1`, `e2` are ephemeral — use the refs from the latest response (`describe-screen` or the `observations` of your last action).
Expand Down
46 changes: 46 additions & 0 deletions src/platform/mobile-platform-driver.dispose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { DeviceBackend } from '@metamask/device-mcp';
import { describe, expect, it, vi } from 'vitest';

import { MobilePlatformDriver } from './mobile-platform-driver.js';

describe('MobilePlatformDriver disposal', () => {
it('disposes only its own backend once across concurrent and repeated calls', async () => {
const disposeA = vi.fn().mockResolvedValue(undefined);
const disposeB = vi.fn().mockResolvedValue(undefined);
const a = new MobilePlatformDriver({
platform: 'ios',
dispose: disposeA,
} as unknown as DeviceBackend);
const b = new MobilePlatformDriver({
platform: 'ios',
dispose: disposeB,
} as unknown as DeviceBackend);
await Promise.all([a.dispose(), a.dispose()]);
await a.dispose();
expect(disposeA).toHaveBeenCalledTimes(1);
expect(disposeB).not.toHaveBeenCalled();
await b.dispose();
expect(disposeB).toHaveBeenCalledTimes(1);
});

it('supports device-mcp versions without disposal', async () => {
const driver = new MobilePlatformDriver({
platform: 'ios',
} as DeviceBackend);
expect(await driver.dispose()).toBeUndefined();
});

it('allows a retry after failed disposal', async () => {
const dispose = vi
.fn()
.mockRejectedValueOnce(new Error('failed'))
.mockResolvedValue(undefined);
const driver = new MobilePlatformDriver({
platform: 'ios',
dispose,
} as unknown as DeviceBackend);
await expect(driver.dispose()).rejects.toThrowError('failed');
await driver.dispose();
expect(dispose).toHaveBeenCalledTimes(2);
});
});
16 changes: 16 additions & 0 deletions src/platform/mobile-platform-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export class MobilePlatformDriver implements IPlatformDriver {

#windowSize: Viewport | undefined;

#disposing: Promise<void> | undefined;

/**
* @param backend - The device-mcp backend to delegate to.
* @param bundleId - The app bundle ID for getAppState calls.
Expand All @@ -112,6 +114,20 @@ export class MobilePlatformDriver implements IPlatformDriver {
this.#excludeDevOverlay = options.excludeDevOverlay ?? true;
}

/** Release only this backend's resources; compatible with older device-mcp releases. */
async dispose(): Promise<void> {
const backend = this.#backend as DeviceBackend & {
dispose?(): Promise<void>;
};
this.#disposing ??= Promise.resolve()
.then(async () => backend.dispose?.())
.catch((error: unknown) => {
this.#disposing = undefined;
throw error;
});
await this.#disposing;
}

/**
* @param targetType - Target identifier type.
* @param targetValue - Target value for element lookup.
Expand Down
3 changes: 3 additions & 0 deletions src/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export type ScreenCapture = {
};

export type IPlatformDriver = {
/** Release resources owned by this driver. Idempotent; does not shut down the device. */
dispose?(): Promise<void>;

/**
* Waits for the target, then clicks it. When `signal` is aborted while the
* driver waits for the target, the driver throws the abort reason and does
Expand Down
Loading
Loading