mobile-debug-mcp 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +10 -0
  2. package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
  8. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
  9. package/agent-plugin/mcp.json +1 -1
  10. package/agent-plugin/mcp_config.json +1 -1
  11. package/agent-plugin/plugin.json +1 -1
  12. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
  13. package/dist/contracts/device-readiness.js +1 -0
  14. package/dist/interact/waits/ui-change-wait.js +9 -2
  15. package/dist/interact/waits/ui-wait.js +7 -3
  16. package/dist/manage/index.js +3 -0
  17. package/dist/observe/device-readiness.js +116 -0
  18. package/dist/observe/evidence.js +31 -0
  19. package/dist/observe/ios-trace/sessions.js +5 -3
  20. package/dist/observe/readiness-service.js +21 -9
  21. package/dist/observe/readiness.js +30 -0
  22. package/dist/observe/screenshot-encoding.js +4 -0
  23. package/dist/observe/snapshot-collection.js +7 -2
  24. package/dist/server/definitions/manage.js +3 -2
  25. package/dist/server/handlers/action-execution.js +23 -14
  26. package/dist/server/handlers/compact-interaction.js +106 -0
  27. package/dist/server/handlers/compact-state.js +47 -0
  28. package/dist/server/handlers/input.js +2 -0
  29. package/dist/server/handlers/interact.js +2 -0
  30. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  31. package/dist/server/handlers/journey.js +18 -20
  32. package/dist/server/handlers/observe.js +2 -1
  33. package/dist/server/handlers/tool-handlers.js +9 -5
  34. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  35. package/dist/server/tool-definitions.js +9 -4
  36. package/dist/server-core.js +5 -2
  37. package/dist/server.js +1 -1
  38. package/dist/system/android.js +19 -15
  39. package/dist/system/index.js +2 -1
  40. package/dist/utils/android/utils.js +7 -16
  41. package/dist/utils/app-installation.js +77 -0
  42. package/dist/utils/benchmark-metrics.js +43 -0
  43. package/dist/utils/exec.js +4 -0
  44. package/dist/utils/ios/utils.js +4 -11
  45. package/dist/utils/resolve-device.js +3 -2
  46. package/dist/utils/timing.js +3 -3
  47. package/docs/CHANGELOG.md +25 -0
  48. package/docs/benchmarks/README.md +1 -0
  49. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  50. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  51. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  52. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  53. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  54. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  55. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  56. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  57. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  58. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  59. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  60. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  61. package/docs/specs/031-ios-system-traces/plan.md +28 -0
  62. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  63. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  64. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  65. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  66. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  67. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  68. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  69. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  70. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  71. package/docs/specs/034-app-installation-status/intent.md +41 -0
  72. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  73. package/docs/specs/034-app-installation-status/plan.md +73 -0
  74. package/docs/specs/034-app-installation-status/spec.md +114 -0
  75. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  76. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  77. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  78. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  79. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  80. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  81. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  82. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  83. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  84. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  85. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  86. package/docs/tools/TOOLS.md +1 -0
  87. package/docs/tools/compact-interactions.md +56 -0
  88. package/docs/tools/correlated-evidence.md +6 -0
  89. package/docs/tools/interact.md +17 -8
  90. package/docs/tools/manage.md +10 -2
  91. package/docs/tools/observe.md +42 -2
  92. package/docs/tools/system.md +6 -0
  93. package/package.json +1 -1
  94. package/src/contracts/device-readiness.ts +8 -0
  95. package/src/interact/waits/ui-change-wait.ts +7 -2
  96. package/src/interact/waits/ui-wait.ts +6 -3
  97. package/src/manage/index.ts +2 -0
  98. package/src/observe/device-readiness.ts +96 -0
  99. package/src/observe/evidence.ts +30 -1
  100. package/src/observe/ios-trace/sessions.ts +4 -2
  101. package/src/observe/readiness-service.ts +21 -9
  102. package/src/observe/readiness.ts +33 -0
  103. package/src/observe/screenshot-encoding.ts +4 -0
  104. package/src/observe/snapshot-collection.ts +7 -2
  105. package/src/server/common.ts +2 -2
  106. package/src/server/definitions/manage.ts +3 -2
  107. package/src/server/handlers/action-execution.ts +22 -14
  108. package/src/server/handlers/compact-interaction.ts +91 -0
  109. package/src/server/handlers/compact-state.ts +43 -0
  110. package/src/server/handlers/input.ts +2 -0
  111. package/src/server/handlers/interact.ts +2 -0
  112. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  113. package/src/server/handlers/journey.ts +15 -17
  114. package/src/server/handlers/observe.ts +2 -1
  115. package/src/server/handlers/payload.ts +2 -1
  116. package/src/server/handlers/tool-handlers.ts +9 -5
  117. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  118. package/src/server/tool-definitions.ts +8 -4
  119. package/src/server-core.ts +5 -2
  120. package/src/server.ts +1 -1
  121. package/src/system/android.ts +18 -11
  122. package/src/system/index.ts +2 -1
  123. package/src/types.ts +2 -0
  124. package/src/utils/android/utils.ts +8 -10
  125. package/src/utils/app-installation.ts +73 -0
  126. package/src/utils/benchmark-metrics.ts +42 -0
  127. package/src/utils/exec.ts +5 -0
  128. package/src/utils/ios/utils.ts +4 -10
  129. package/src/utils/resolve-device.ts +4 -2
  130. package/src/utils/timing.ts +3 -2
  131. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  132. package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
  133. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  134. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  135. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  136. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  137. package/test/device/automated/latency/end-to-end.ts +149 -0
  138. package/test/device/automated/latency/owned-transport.ts +81 -0
  139. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  140. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  141. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  142. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  143. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  144. package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
  145. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  146. package/test/fixtures/contracts/tool-definitions.json +51 -15
  147. package/test/fixtures/latency-journey-app/build.ts +3 -3
  148. package/test/unit/debugger/handle-guards.test.ts +2 -1
  149. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  150. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  151. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  152. package/test/unit/observe/device-readiness.test.ts +97 -0
  153. package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
  154. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  155. package/test/unit/server/compact-interaction.test.ts +87 -0
  156. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  157. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  158. package/test/unit/server/conditional_actions.test.ts +7 -3
  159. package/test/unit/server/correlated-evidence.test.ts +18 -6
  160. package/test/unit/server/end-to-end-report.test.ts +46 -0
  161. package/test/unit/server/input-device-readiness.test.ts +55 -0
  162. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  163. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  164. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  165. package/test/unit/server/readiness.test.ts +24 -1
  166. package/test/unit/server/run_journey.test.ts +2 -1
  167. package/test/unit/server/selector_tap_target.test.ts +2 -1
  168. package/test/unit/server/system-trace.test.ts +6 -4
  169. package/test/unit/utils/app-installation.test.ts +78 -0
  170. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  171. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -51,6 +51,8 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
51
51
 
52
52
  const deviceContext = new AsyncLocalStorage<DeviceInfo>()
53
53
 
54
+ export function currentResolvedDevice() { return deviceContext.getStore() }
55
+
54
56
  export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
55
57
  bindEvidenceDevice(device)
56
58
  return deviceContext.run(device, operation)
@@ -92,7 +94,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
92
94
  if (prefer === "emulator") candidates = candidates.filter(d => d.simulator)
93
95
 
94
96
  if (appId) {
95
- const installed = candidates.filter(d => d.appInstalled)
97
+ const installed = candidates.filter(d => d.appInstallation ? d.appInstallation.status === 'installed' && d.appInstallation.appId === appId && d.appInstallation.deviceId === d.id : d.appInstalled === true)
96
98
  if (installed.length > 0) candidates = installed
97
99
  }
98
100
 
@@ -117,7 +119,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
117
119
  return candidates[0]
118
120
  }
119
121
 
120
- const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }))
122
+ const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled, appInstallation: d.appInstallation }))
121
123
  const err = Object.assign(new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`), { devices: list })
122
124
  throw err
123
125
  }
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { recordBenchmarkSpan } from './benchmark-metrics.js'
2
3
  const timings = new AsyncLocalStorage<Record<string, number>>()
3
4
  export function withTiming<T>(values: Record<string, number>, operation: () => Promise<T>) { return timings.run(values, operation) }
4
5
  export function recordTiming(component: string, milliseconds: number) {
6
+ recordBenchmarkSpan(component, milliseconds)
5
7
  const values = timings.getStore()
6
8
  if (values) values[component] = (values[component] ?? 0) + milliseconds
7
9
  }
@@ -9,7 +11,6 @@ export async function measure<T>(component: string, operation: () => Promise<T>)
9
11
  const start = performance.now()
10
12
  try { return await operation() }
11
13
  finally {
12
- const values = timings.getStore()
13
- if (values) values[component] = (values[component] ?? 0) + performance.now() - start
14
+ recordTiming(component, performance.now() - start)
14
15
  }
15
16
  }
@@ -104,6 +104,11 @@ if (!process.env.MCP_DEBUGGER_DEVICE) {
104
104
  assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
105
105
  const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
106
106
  assert.equal(readiness.readiness.state, 'debugger_paused')
107
+ assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
108
+ assert.equal(readiness.device_readiness.input_ready, false)
109
+ const readyWait = await call('wait_for_app_ready', { platform: 'android', deviceId })
110
+ assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
111
+ assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
107
112
  for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
108
113
  const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
109
114
  assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
@@ -134,7 +134,13 @@ if (!process.env.MCP_IOS_DEBUGGER_DEVICE) {
134
134
  assert.equal(storedTap.delivery_status, 'not_delivered'); assert.equal(storedTap.dispatch_started, false)
135
135
  assert.deepEqual(storedTap.debugger.device, { platform: 'ios', id: deviceId })
136
136
  console.log('PASS paused stored iOS handle without platform/device hints rejected before dispatch')
137
- assert.equal((await call('get_app_readiness', { platform: 'ios', deviceId })).readiness.state, 'debugger_paused')
137
+ const readiness = await call('get_app_readiness', { platform: 'ios', deviceId })
138
+ assert.equal(readiness.readiness.state, 'debugger_paused')
139
+ assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
140
+ assert.equal(readiness.device_readiness.input_ready, false)
141
+ const readyWait = await call('wait_for_app_ready', { platform: 'ios', deviceId })
142
+ assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
143
+ assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
138
144
  await remove(bp)
139
145
  for (const kind of ['into', 'over', 'out']) {
140
146
  const response = await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind })
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict'
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
4
+
5
+ const client = new Client({ name: 'compact-results-smoke', version: '1.0.0' })
6
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
7
+ async function call(name: string, args: Record<string, unknown> = {}) {
8
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
9
+ const item = response.content.find(item => item.type === 'text')
10
+ assert(item?.type === 'text')
11
+ return { value: JSON.parse(item.text), bytes: Buffer.byteLength(item.text) }
12
+ }
13
+ async function details(value: Record<string, any>) {
14
+ const result = await call('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id })
15
+ assert.equal(result.value.success, true)
16
+ return result.value.result
17
+ }
18
+ try {
19
+ await client.connect(transport)
20
+ const discovery = await call('list_devices')
21
+ for (const platform of ['android', 'ios']) {
22
+ const device = discovery.value.devices.find((d: Record<string, any>) => d.platform === platform && d.simulator && (platform === 'android' || d.booted))
23
+ assert(device, `A booted ${platform} simulator/emulator is required`)
24
+ const target = { platform, deviceId: device.id }, appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
25
+ const none = await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' })
26
+ assert.equal(none.value.success, true)
27
+ assert.equal(none.value.verification.status, 'not_requested')
28
+ assert.equal(none.value.observed_state.status, 'unknown')
29
+ assert.ok(none.bytes <= 1280 + Buffer.byteLength(device.id))
30
+ assert.equal((await details(none.value)).evidence.action_id, none.value.action_id)
31
+ const observed = await call('start_app', { ...target, appId, verificationMode: 'light', verificationTimeoutMs: 30000, responseMode: 'compact' })
32
+ assert.equal(observed.value.success, true)
33
+ assert.equal(observed.value.observed_state.status, 'observed', JSON.stringify(observed.value))
34
+ assert.ok(observed.bytes <= 1280 + Buffer.byteLength(device.id))
35
+ const blocked = await call('tap_element', { ...target, selector: { text: '__mcp_absent_compact_fixture__' }, verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 1000 })
36
+ assert.equal(blocked.value.success, false)
37
+ assert.equal(blocked.value.dispatch_started, false)
38
+ assert.equal(blocked.value.observed_state.status, 'unknown')
39
+ assert.equal((await details(blocked.value)).success, false)
40
+ const tree = await call('get_ui_tree', target)
41
+ const element = tree.value.elements?.find((e: Record<string, any>) => e.visible && typeof e.text === 'string' && e.text.trim())
42
+ assert(element, 'Settings must expose a visible text target')
43
+ const journey = await call('run_journey', { ...target, defaults: { verificationMode: 'none' }, steps: [
44
+ { id: 'open', type: 'start_app', appId },
45
+ { id: 'observe', type: 'assert', assertion: { kind: 'element_visible', selector: { text: element.text } }, timeoutMs: 15000 }
46
+ ] })
47
+ assert.equal(journey.value.success, true, JSON.stringify(journey.value))
48
+ assert.equal(journey.value.steps[1].result.observed_state.status, 'observed')
49
+ const retainedJourney = await details(journey.value)
50
+ const retainedStep = await details(journey.value.steps[0].result)
51
+ assert.equal(retainedJourney.steps[0].result.evidence.action_id, retainedStep.evidence.action_id)
52
+ assert.ok(retainedStep.evidence.dispatch_completed_at_ms)
53
+ console.log(JSON.stringify({ status: 'PASS', platform, device: device.id, os: device.osVersion, server: client.getServerVersion(),
54
+ bytes: { none: none.bytes, observed: observed.bytes, blocked: blocked.bytes, journey: journey.bytes },
55
+ observed: observed.value.observed_state, journeyAssertion: journey.value.steps[1].result.observed_state,
56
+ checks: ['dispatch-only unknown', 'fresh post-observation', 'no-dispatch failure', 'journey', 'read-only original/child lookup'], cleanup: 'Settings remains foreground; no app data/settings changed' }))
57
+ }
58
+ } finally { await client.close() }
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync, readdirSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+
7
+ export function command(command: string, args: string[], timeout = 30000) {
8
+ return execFileSync(command, args, { encoding: 'utf8', timeout, killSignal: 'SIGKILL', stdio: 'pipe' }).trim()
9
+ }
10
+ export function fixturePresent(adb: (...args: string[]) => string, app: string) {
11
+ return adb('shell', 'pm', 'list', 'packages', app).split('\n').includes(`package:${app}`)
12
+ }
13
+
14
+ export function cleanupFixture(adb: (...args: string[]) => string, app: string, remove: boolean) {
15
+ if (!fixturePresent(adb, app)) return false
16
+ adb('shell', 'am', 'force-stop', app)
17
+ if (!remove) return false
18
+ adb('uninstall', app)
19
+ if (fixturePresent(adb, app)) throw new Error('FIXTURE_REMOVAL_FAILED')
20
+ return true
21
+ }
22
+
23
+ export const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex')
24
+ export function treeHash(root: string): string {
25
+ const hash = createHash('sha256')
26
+ const visit = (directory: string) => {
27
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
28
+ const file = path.join(directory, entry.name)
29
+ if (entry.isDirectory()) visit(file)
30
+ else if (entry.isFile()) { hash.update(path.relative(root, file)); hash.update(readFileSync(file)) }
31
+ }
32
+ }
33
+ visit(root)
34
+ return hash.digest('hex')
35
+ }
36
+ function version(tool: string, args: string[]) {
37
+ try { return command(tool, args).split('\n').filter(line => !/Installed as/.test(line)).slice(0, 3).join('\n') }
38
+ catch { return 'unavailable' }
39
+ }
40
+ export function environment(deviceId: string, apk: string) {
41
+ const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
42
+ const connected = command('adb', ['devices']).split('\n').filter(line => /\sdevice$/.test(line.trim()))
43
+ return {
44
+ commit: command('git', ['rev-parse', 'HEAD']),
45
+ working_tree: command('git', ['status', '--porcelain', '--untracked-files=all']),
46
+ source_sha256: treeHash('src'), build_sha256: treeHash('dist'), harness_sha256: treeHash('test/device/automated/latency'), fixture_sha256: sha256(apk),
47
+ package_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
48
+ node: process.version, host: { platform: os.platform(), release: os.release(), arch: os.arch() },
49
+ platform: 'android', model: adb('shell', 'getprop', 'ro.product.model'),
50
+ discovery_topology: { android_emulators: connected.filter(line => line.startsWith('emulator-')).length,
51
+ android_other_devices: connected.filter(line => !line.startsWith('emulator-')).length },
52
+ os: adb('shell', 'getprop', 'ro.build.version.release'), api: adb('shell', 'getprop', 'ro.build.version.sdk'),
53
+ avd: adb('emu', 'avd', 'name').split('\n')[0], display: adb('shell', 'wm', 'size'), density: adb('shell', 'wm', 'density'),
54
+ tool_versions: { adb: version('adb', ['version']), javac: version(process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', 'javac') : 'javac', ['--version']), android_build_tools: '36.0.0',
55
+ emulator: version(path.join(process.env.ANDROID_HOME ?? path.join(os.homedir(), 'Library/Android/sdk'), 'emulator/emulator'), ['-version']),
56
+ android_compile_platform: '36', xcode: version('xcodebuild', ['-version']), idb: version('idb', ['--version']) },
57
+ timeouts: { setup_ms: 30000, call_ms: 300000, action_ms: 30000, wait_ms: 10000, verification_ms: 60000,
58
+ adb_ms: Number(process.env.MCP_ADB_TIMEOUT ?? 20000) },
59
+ ios: { journey: 'blocked: shared #89 fixture unavailable', observations: 'blocked: idb executable unavailable and no booted simulator at investigation', physical_device: 'excluded' }
60
+ }
61
+ }
@@ -0,0 +1,90 @@
1
+ import { summarize } from './report.js'
2
+
3
+ export interface Span { stage: string; start_ms: number; end_ms: number }
4
+ export interface RequestMetric {
5
+ event: 'latency_metric'; request_id: string; tool: string
6
+ handler_completed: boolean; elapsed_ms: number; spans: Span[]
7
+ }
8
+ export interface TreeMetric {
9
+ event: 'ui_tree_metric'; type: string; stage?: string; elapsed_ms?: number
10
+ acquisition_id?: string; success?: boolean; durations_ms?: Record<string, number>
11
+ }
12
+ export interface RequestSample {
13
+ tool: string; elapsed_ms: number; response_bytes?: number; success: boolean
14
+ failure?: string; failed_step?: string; failure_code?: string; metrics: RequestMetric[]; tree: TreeMetric[]
15
+ }
16
+ export interface Attempt {
17
+ case: string; iteration: number; warmup: boolean; success: boolean
18
+ failure?: string; setup_ms: number; requests: RequestSample[]
19
+ }
20
+
21
+ export function unionDuration(spans: Span[], limit: number) {
22
+ const sorted = spans.map(span => [Math.max(0, span.start_ms), Math.min(limit, span.end_ms)])
23
+ .filter(([start, end]) => Number.isFinite(start) && Number.isFinite(end) && end >= start)
24
+ .sort((a, b) => a[0] - b[0])
25
+ let end = 0, total = 0
26
+ for (const [start, stop] of sorted) { total += Math.max(0, stop - Math.max(end, start)); end = Math.max(end, stop) }
27
+ return total
28
+ }
29
+
30
+ export function summarizeAttempts(attempts: Attempt[]) {
31
+ return Object.fromEntries([...new Set(attempts.map(attempt => attempt.case))].map(name => {
32
+ const selected = attempts.filter(attempt => attempt.case === name)
33
+ const measured = selected.filter(attempt => !attempt.warmup)
34
+ const successful = measured.filter(attempt => attempt.success)
35
+ const requests = successful.flatMap(attempt => attempt.requests)
36
+ const metrics = requests.flatMap(request => request.metrics)
37
+ const tree = requests.flatMap(request => request.tree)
38
+ const stages = [...new Set(metrics.flatMap(metric => metric.spans.map(span => span.stage)))]
39
+ const treeStages = [...new Set(tree.filter(event => event.type === 'stage').map(event => event.stage!))]
40
+ return [name, {
41
+ attempts: measured.length, failures: measured.filter(attempt => !attempt.success).length,
42
+ warmup_failures: selected.filter(attempt => attempt.warmup && !attempt.success).length,
43
+ total_ms: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + request.elapsed_ms, 0))),
44
+ response_bytes: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + (request.response_bytes ?? 0), 0))),
45
+ handler_ms: summarize(metrics.map(metric => metric.elapsed_ms)),
46
+ handler_unattributed_ms: summarize(metrics.map(metric => Math.max(0, metric.elapsed_ms - unionDuration(metric.spans, metric.elapsed_ms)))),
47
+ client_minus_handler_ms: summarize(requests.filter(request => request.metrics.length === 1)
48
+ .map(request => request.elapsed_ms - request.metrics[0].elapsed_ms)),
49
+ inclusive_components_ms: Object.fromEntries(stages.map(stage => [stage, summarize(metrics.map(metric =>
50
+ metric.spans.filter(span => span.stage === stage).reduce((sum, span) => sum + span.end_ms - span.start_ms, 0)))])),
51
+ physical_reads: tree.filter(event => event.type === 'physical_read_completed').length,
52
+ failed_physical_reads: tree.filter(event => event.type === 'physical_read_completed' && event.success === false).length,
53
+ tree_stages_ms: Object.fromEntries(treeStages.map(stage => [stage, summarize(tree.filter(event => event.type === 'stage' && event.stage === stage).map(event => event.elapsed_ms ?? 0))])),
54
+ tree_acquisition_ms: summarize(tree
55
+ .filter(event => event.type === 'acquisition_completed').map(event => event.elapsed_ms ?? 0)),
56
+ missing_request_metrics: requests.filter(request => request.metrics.length !== 1).length
57
+ }]
58
+ }))
59
+ }
60
+
61
+ export function observationOverhead(attempts: Attempt[]) {
62
+ return Object.fromEntries(['screenshot', 'tree', 'snapshot'].map(name => {
63
+ const pairs = attempts.filter(attempt => attempt.case === `${name}-on` && !attempt.warmup && attempt.success)
64
+ .flatMap(on => {
65
+ const off = attempts.find(attempt => attempt.case === `${name}-off` && attempt.iteration === on.iteration && !attempt.warmup && attempt.success)
66
+ return off ? [on.requests.reduce((sum, request) => sum + request.elapsed_ms, 0) - off.requests.reduce((sum, request) => sum + request.elapsed_ms, 0)] : []
67
+ })
68
+ return [name, { paired_on_minus_off_ms: summarize(pairs), interpretation: 'Includes device/order variability; not an isolated causal overhead estimate' }]
69
+ }))
70
+ }
71
+
72
+ // Whitelist timing metadata rather than persisting unrestricted server stderr.
73
+ export function parseMetric(line: string): RequestMetric | TreeMetric | null {
74
+ try {
75
+ const value = JSON.parse(line)
76
+ const finite = (number: unknown) => typeof number === 'number' && Number.isFinite(number) && number >= 0
77
+ if (value.event === 'latency_metric' && typeof value.tool === 'string' && typeof value.request_id === 'string' &&
78
+ typeof value.handler_completed === 'boolean' && finite(value.elapsed_ms) && Array.isArray(value.spans) &&
79
+ value.spans.every((span: Span) => typeof span.stage === 'string' && finite(span.start_ms) && finite(span.end_ms) && span.end_ms >= span.start_ms)) {
80
+ return { event: value.event, request_id: value.request_id, tool: value.tool,
81
+ handler_completed: value.handler_completed, elapsed_ms: value.elapsed_ms,
82
+ spans: value.spans.map((span: Span) => ({ stage: span.stage, start_ms: span.start_ms, end_ms: span.end_ms })) }
83
+ }
84
+ if (value.event === 'ui_tree_metric' && typeof value.type === 'string' &&
85
+ (value.elapsed_ms === undefined || finite(value.elapsed_ms))) return { event: value.event, type: value.type,
86
+ stage: value.stage, elapsed_ms: value.elapsed_ms, acquisition_id: value.acquisition_id,
87
+ success: value.success, durations_ms: value.durations_ms }
88
+ } catch { /* Non-metric stderr is intentionally discarded. */ }
89
+ return null
90
+ }
@@ -0,0 +1,98 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import { OwnedTransport } from './owned-transport.js'
4
+ import { parseMetric, type RequestMetric, type TreeMetric, type RequestSample } from './end-to-end-report.js'
5
+
6
+ function object(value: unknown): Record<string, unknown> {
7
+ return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
8
+ }
9
+ export function validObservation(tool: string, data: Record<string, unknown>, content: unknown, deviceId: string) {
10
+ const treeValid = (value: Record<string, unknown>) => !value.error && Array.isArray(value.elements) && value.elements.length > 0 && object(value.device).id === deviceId
11
+ if (tool === 'get_ui_tree') return treeValid(data)
12
+ if (tool === 'capture_screenshot') return object(data.device).id === deviceId && Array.isArray(content) && content.some(item => {
13
+ const block = object(item)
14
+ return block.type === 'image' && typeof block.data === 'string' && block.data.length > 0
15
+ })
16
+ if (tool === 'capture_debug_snapshot') {
17
+ const raw = object(data.raw)
18
+ return treeValid(object(raw.ui_tree)) && !Object.keys(raw).some(key => key.endsWith('_error') && raw[key])
19
+ }
20
+ return true
21
+ }
22
+
23
+ export class UnsafeMeasurement extends Error {}
24
+ export class BenchmarkSession {
25
+ readonly client = new Client({ name: 'end-to-end-latency', version: '1.0' })
26
+ readonly transport: OwnedTransport
27
+ private events: (RequestMetric | TreeMetric)[] = []
28
+ private pending = ''
29
+ private interrupted = new AbortController()
30
+ constructor(readonly deviceId: string, readonly instrumented: boolean, server = 'dist/server.js') {
31
+ this.transport = new OwnedTransport({ ...process.env,
32
+ MOBILE_DEBUG_MCP_LATENCY_METRICS: instrumented ? '1' : '0',
33
+ MOBILE_DEBUG_MCP_UI_TREE_METRICS: instrumented ? '1' : '0'
34
+ }, chunk => {
35
+ this.pending += chunk
36
+ let index: number
37
+ while ((index = this.pending.indexOf('\n')) >= 0) {
38
+ const event = parseMetric(this.pending.slice(0, index))
39
+ if (event) this.events.push(event)
40
+ this.pending = this.pending.slice(index + 1)
41
+ }
42
+ if (this.pending.length > 1024 * 1024) this.pending = ''
43
+ }, [server])
44
+ }
45
+
46
+ async connect() { await this.client.connect(this.transport, { timeout: 15000 }) }
47
+ abort() { this.interrupted.abort() }
48
+ async close() { await this.client.close(); await this.transport.close() }
49
+
50
+ async call(tool: string, args: Record<string, unknown>, samples: RequestSample[]) {
51
+ if (this.interrupted.signal.aborted) throw new UnsafeMeasurement('INTERRUPTED')
52
+ this.events = []
53
+ const started = performance.now()
54
+ const sample: RequestSample = { tool, elapsed_ms: 0, success: false, metrics: [], tree: [] }
55
+ samples.push(sample)
56
+ try {
57
+ const response = await this.client.callTool({ name: tool,
58
+ arguments: { platform: 'android', deviceId: this.deviceId, ...args }
59
+ }, undefined, { timeout: 300000, signal: this.interrupted.signal })
60
+ sample.elapsed_ms = performance.now() - started
61
+ console.log(` ${tool}: ${Math.round(sample.elapsed_ms)} ms`)
62
+ sample.response_bytes = Buffer.byteLength(JSON.stringify(response))
63
+ // Stderr and stdout are independent streams. Await the matching metric
64
+ // outside the measured round trip before dispatching the next request.
65
+ const deadline = performance.now() + 1000
66
+ while (this.instrumented && !this.events.some(event => event.event === 'latency_metric') && performance.now() < deadline) await delay(5)
67
+ sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
68
+ sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
69
+ const text = (response.content as { type: string; text?: string }[]).find(item => item.type === 'text')?.text
70
+ const data = JSON.parse(text ?? '{}')
71
+ sample.success = !response.isError && data.success !== false && !data.error && data.status !== 'timeout' && validObservation(tool, data, response.content, this.deviceId)
72
+ if (!sample.success) {
73
+ sample.failure = 'TOOL_OUTCOME_FAILED'
74
+ // Only fixture-generated step IDs and enum-style codes may be saved.
75
+ const step = typeof data.stopped_at_step_id === 'string' ? data.stopped_at_step_id : ''
76
+ if (/^(launch|tap-\d+|assert-\d+)$/.test(step)) sample.failed_step = step
77
+ const failed = Array.isArray(data.steps) ? data.steps.find((value: Record<string, unknown>) => value.id === step) : undefined
78
+ const code = data.failure_code ?? object(object(failed).result).failure_code ?? object(data.error).code
79
+ if (typeof code === 'string' && /^[A-Z][A-Z_]{0,63}$/.test(code)) sample.failure_code = code
80
+ throw new Error(sample.failure)
81
+ }
82
+ if (this.instrumented && (sample.metrics.length !== 1 || sample.metrics[0].tool !== tool)) {
83
+ sample.success = false; sample.failure = 'METRIC_CORRELATION_FAILED'
84
+ throw new UnsafeMeasurement(sample.failure)
85
+ }
86
+ return data as Record<string, unknown>
87
+ } catch (error) {
88
+ if (!sample.elapsed_ms) sample.elapsed_ms = performance.now() - started
89
+ sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
90
+ sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
91
+ if (!sample.failure) {
92
+ sample.failure = this.interrupted.signal.aborted ? 'INTERRUPTED' : 'TRANSPORT_OR_RESPONSE_FAILED'
93
+ throw new UnsafeMeasurement(sample.failure)
94
+ }
95
+ throw error
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,149 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
3
+ import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
4
+ import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
5
+ import { summarizeAttempts, observationOverhead, type Attempt } from './end-to-end-report.js'
6
+
7
+ const deviceId = process.env.LATENCY_DEVICE_ID
8
+ if (!deviceId || !deviceId.startsWith('emulator-')) throw new Error('Set LATENCY_DEVICE_ID to an explicit Android emulator serial')
9
+ const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
10
+ const appId = 'dev.mobiledebugmcp.latencyfixture'
11
+ const smoke = process.argv.includes('--smoke')
12
+ const rounds = smoke ? 1 : 13
13
+ const warmups = smoke ? 0 : 3
14
+ const cases = ['standalone', 'full-debug', 'full-compact', 'light-debug', 'light-compact',
15
+ 'screenshot-on', 'screenshot-off', 'tree-on', 'tree-off', 'snapshot-on', 'snapshot-off']
16
+ const flow = [['saved-session', 'idle-detail'], ['activate', 'active-detail'], ['home', 'home-screen'],
17
+ ['saved-session', 'active-detail'], ['deactivate', 'idle-detail'], ['stop', 'stopped']]
18
+ const selector = (id: string) => ({ accessibility_id: id })
19
+ const controls = { actionTimeoutMs: 30000, verificationTimeoutMs: 60000 }
20
+ const attempts: Attempt[] = []
21
+ const phases: { phase: string; elapsed_ms: number; success: boolean }[] = []
22
+ const sessions = new Map<boolean, BenchmarkSession>()
23
+ const cleanup: { server?: boolean; fixture?: boolean; removed_install?: boolean } = {}
24
+ let metadata: ReturnType<typeof environment> | undefined
25
+ let interrupted = false
26
+ let fatal: string | undefined
27
+ let fixtureExisted = false
28
+ let installAttempted = false
29
+ let sourceStable: boolean | undefined
30
+ const startedAt = new Date().toISOString()
31
+ const root = 'docs/benchmarks/end-to-end-latency'
32
+ const reportName = smoke ? 'smoke' : 'baseline'
33
+ async function phase<T>(name: string, operation: () => T | Promise<T>) {
34
+ const start = performance.now()
35
+ let success = false
36
+ try { const result = await operation(); success = true; return result }
37
+ finally { phases.push({ phase: name, elapsed_ms: performance.now() - start, success }) }
38
+ }
39
+ function save() {
40
+ mkdirSync(root, { recursive: true })
41
+ const summaries = summarizeAttempts(attempts)
42
+ const report = { status: fatal ? 'stopped' : interrupted ? 'interrupted' : attempts.length === rounds * cases.length ? 'finished' : 'in_progress',
43
+ performance_evidence: !smoke && sourceStable !== false, smoke, warmups, measured_attempts_per_case: smoke ? 1 : 10,
44
+ started_at: startedAt, source_and_build_unchanged: sourceStable,
45
+ planned_cases: cases, unrun_attempts: rounds * cases.length - attempts.length,
46
+ outcome: fatal || interrupted || attempts.some(value => !value.success) || cleanup.server === false || cleanup.fixture === false ? 'fail' : cleanup.server && cleanup.fixture ? 'pass' : 'pending',
47
+ invocation: `LATENCY_DEVICE_ID=[emulator] MCP_ADB_TIMEOUT=${process.env.MCP_ADB_TIMEOUT ?? '20000'} npx tsx test/device/automated/latency/end-to-end.ts${smoke ? ' --smoke' : ''}`,
48
+ metadata, phases, fatal, cleanup, summaries, observation_overhead: observationOverhead(attempts), attempts }
49
+ writeFileSync(`${root}/${reportName}.json`, JSON.stringify(report, null, 2) + '\n')
50
+ const rows = Object.entries(summaries).map(([name, value]) => `| ${name} | ${value.total_ms?.count ?? 0}/${value.attempts} | ${value.total_ms?.p50.toFixed(1) ?? 'n/a'} | ${value.total_ms?.p95.toFixed(1) ?? 'n/a'} | ${value.failures} | ${value.warmup_failures} |`)
51
+ writeFileSync(`${root}/${reportName}.md`, `# End-to-end latency ${smoke ? 'smoke (not performance evidence)' : 'baseline'}\n\nStatus: ${report.status}. Build: ${metadata?.build_sha256 ?? 'unavailable'}.\n\n| Case | Successful/measured | p50 ms | p95 ms | Failures | Warm-up failures |\n| --- | ---: | ---: | ---: | ---: | ---: |\n${rows.join('\n')}\n\nSee ${reportName}.json for exact source/build hashes, environment, phase durations, attempt order, inclusive components and failure records. Percentiles use the successful measured count shown for each case. With 1–19 successful samples, nearest-rank p95 is the sample maximum. Zero successful samples yield no percentiles. Failed attempts are excluded from percentiles, retained in raw data, and fail the run.\n\nIntervals can overlap; component sums are not an additive latency budget. Client-minus-handler includes scheduling, transport and uninstrumented work. Handler residual excludes the union of recorded spans. Tree acquisition/parse/retry events are separate nested diagnostics. Application work inside requests remains included. Startup, fixture builds/resets and external validation are excluded from steady-state round trips. SDK wire serialization is not isolated.\n`)
52
+ }
53
+ async function reset(session: BenchmarkSession, observation: boolean) {
54
+ adb('shell', 'am', 'force-stop', appId)
55
+ if (!adb('shell', 'pm', 'clear', appId).includes('Success')) throw new Error('RESET_FAILED')
56
+ if (observation) {
57
+ adb('shell', 'am', 'start', '-W', '-n', `${appId}/.MainActivity`)
58
+ await session.call('expect_element_visible', { selector: selector('home-screen'), timeout_ms: 30000, poll_interval_ms: 100 }, [])
59
+ }
60
+ }
61
+ async function journey(session: BenchmarkSession, mode: string, attempt: Attempt) {
62
+ if (mode === 'standalone') {
63
+ await session.call('start_app', { appId, ...controls }, attempt.requests)
64
+ for (const [target, expected] of flow) {
65
+ const found = await session.call('wait_for_ui', { selector: selector(target), condition: 'clickable', timeout_ms: 10000, poll_interval_ms: 100 }, attempt.requests)
66
+ const element = found.element as { elementId?: string } | undefined
67
+ if (!element?.elementId) throw new Error('TARGET_HANDLE_MISSING')
68
+ await session.call('tap_element', { elementId: element.elementId, ...controls }, attempt.requests)
69
+ await session.call('expect_element_visible', { selector: selector(expected), timeout_ms: 30000, poll_interval_ms: 100 }, attempt.requests)
70
+ }
71
+ return
72
+ }
73
+ const [verificationMode, responseMode] = mode.split('-')
74
+ const steps: Record<string, unknown>[] = [{ id: 'launch', type: 'start_app', appId }]
75
+ flow.forEach(([target, expected], index) => {
76
+ steps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: 10000, pollIntervalMs: 100 } })
77
+ steps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: 30000, pollIntervalMs: 100 })
78
+ })
79
+ await session.call('run_journey', { defaults: { verificationMode, ...controls }, responseMode, steps }, attempt.requests)
80
+ }
81
+ async function attempt(name: string, iteration: number) {
82
+ if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
83
+ const instrumented = !name.endsWith('-off')
84
+ const session = sessions.get(instrumented)!
85
+ const observation = /^(screenshot|tree|snapshot)-/.test(name)
86
+ const value: Attempt = { case: name, iteration, warmup: iteration < warmups, success: false, setup_ms: 0, requests: [] }
87
+ attempts.push(value)
88
+ const start = performance.now()
89
+ try {
90
+ await reset(session, observation)
91
+ value.setup_ms = performance.now() - start
92
+ if (!observation) await journey(session, name, value)
93
+ else {
94
+ const tool = name.startsWith('screenshot') ? 'capture_screenshot' : name.startsWith('tree') ? 'get_ui_tree' : 'capture_debug_snapshot'
95
+ await session.call(tool, tool === 'capture_debug_snapshot' ? { includeScreenshot: false, includeLogs: false } : {}, value.requests)
96
+ }
97
+ value.success = true
98
+ } catch (error) {
99
+ if (!value.setup_ms) value.setup_ms = performance.now() - start
100
+ value.failure = error instanceof UnsafeMeasurement ? error.message : value.requests.at(-1)?.failure ?? 'SETUP_OR_ASSERTION_FAILED'
101
+ if (error instanceof UnsafeMeasurement || !value.requests.length) throw new UnsafeMeasurement(value.failure)
102
+ } finally { save() }
103
+ }
104
+ async function main() {
105
+ const signal = () => { interrupted = true; for (const session of sessions.values()) session.abort() }
106
+ process.on('SIGINT', signal); process.on('SIGTERM', signal)
107
+ try {
108
+ if (adb('get-state') !== 'device' || adb('shell', 'getprop', 'ro.kernel.qemu') !== '1') throw new Error('EMULATOR_REQUIRED')
109
+ fixtureExisted = fixturePresent(adb, appId)
110
+ const apk = await phase('fixture_build', buildFixture)
111
+ metadata = environment(deviceId!, apk)
112
+ installAttempted = true
113
+ await phase('fixture_install', () => adb('install', '-r', apk))
114
+ for (const enabled of [true, false]) {
115
+ const session = new BenchmarkSession(deviceId!, enabled)
116
+ sessions.set(enabled, session)
117
+ await phase(`server_startup_metrics_${enabled}`, () => session.connect())
118
+ }
119
+ for (let round = 0; round < rounds; round++) {
120
+ const order = round % 2 ? [...cases].reverse() : cases
121
+ for (const name of order) {
122
+ console.log(`${smoke ? 'smoke' : `round ${round + 1}/${rounds}`} ${name}`)
123
+ await attempt(name, round)
124
+ }
125
+ }
126
+ } catch (error) {
127
+ fatal = error instanceof UnsafeMeasurement ? error.message : 'PREREQUISITE_OR_CLEANUP_FAILED'
128
+ process.exitCode = 1
129
+ } finally {
130
+ cleanup.server = true
131
+ for (const session of sessions.values()) {
132
+ try { await session.close(); cleanup.server &&= session.transport.cleanupVerified }
133
+ catch { cleanup.server = false }
134
+ }
135
+ try {
136
+ if (installAttempted && cleanupFixture(adb, appId, !fixtureExisted)) cleanup.removed_install = true
137
+ cleanup.fixture = true
138
+ } catch { cleanup.fixture = false }
139
+ if (!cleanup.server || !cleanup.fixture || interrupted || attempts.some(value => !value.success)) process.exitCode = 1
140
+ if (!cleanup.server || !cleanup.fixture) fatal = 'CLEANUP_FAILED'
141
+ if (metadata) {
142
+ sourceStable = metadata.source_sha256 === treeHash('src') && metadata.build_sha256 === treeHash('dist') && metadata.harness_sha256 === treeHash('test/device/automated/latency')
143
+ if (!sourceStable) { fatal = 'MEASURED_SOURCE_CHANGED'; process.exitCode = 1 }
144
+ }
145
+ save()
146
+ process.off('SIGINT', signal); process.off('SIGTERM', signal)
147
+ }
148
+ }
149
+ main().catch(() => { console.error('Benchmark failed; inspect timing report'); process.exitCode = 1 })
@@ -0,0 +1,81 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'
4
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
5
+ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
6
+
7
+ // A dedicated POSIX process group bounds cleanup to this benchmark's server
8
+ // and its children. The SDK client and stdio wire protocol are unchanged.
9
+ export class OwnedTransport implements Transport {
10
+ onclose?: () => void
11
+ onerror?: (error: Error) => void
12
+ onmessage?: (message: JSONRPCMessage) => void
13
+ private child?: ChildProcessWithoutNullStreams
14
+ private buffer = new ReadBuffer({ maxBufferSize: 64 * 1024 * 1024 })
15
+ private closed?: Promise<void>
16
+ private closing?: Promise<void>
17
+ cleanupVerified = false
18
+ constructor(private env: NodeJS.ProcessEnv, private stderr: (chunk: string) => void,
19
+ private args = ['dist/server.js']) {}
20
+
21
+ async start() {
22
+ if (process.platform === 'win32') throw new Error('POSIX benchmark transport required')
23
+ if (this.child) throw new Error('Already started')
24
+ this.child = spawn(process.execPath, this.args, { env: this.env, detached: true, stdio: 'pipe' })
25
+ this.closed = new Promise(resolve => this.child!.once('close', () => { this.onclose?.(); resolve() }))
26
+ this.child.on('error', error => this.onerror?.(error))
27
+ this.child.stdin.on('error', error => this.onerror?.(error))
28
+ this.child.stderr.on('data', chunk => this.stderr(String(chunk)))
29
+ this.child.stdout.on('data', chunk => {
30
+ try {
31
+ this.buffer.append(chunk)
32
+ let message
33
+ while ((message = this.buffer.readMessage()) !== null) this.onmessage?.(message)
34
+ } catch (error) { this.onerror?.(error as Error) }
35
+ })
36
+ await new Promise<void>((resolve, reject) => { this.child!.once('spawn', resolve); this.child!.once('error', reject) })
37
+ }
38
+
39
+ async send(message: JSONRPCMessage) {
40
+ if (!this.child) throw new Error('Not started')
41
+ await new Promise<void>((resolve, reject) => this.child!.stdin.write(serializeMessage(message), error => error ? reject(error) : resolve()))
42
+ }
43
+
44
+ private groupExists() {
45
+ if (!this.child?.pid) return false
46
+ try { process.kill(-this.child.pid, 0); return true }
47
+ catch (error) {
48
+ const code = (error as NodeJS.ErrnoException).code
49
+ if (code === 'ESRCH') return false
50
+ // Darwin can return EPERM while a killed group is being reaped. It is
51
+ // still unconfirmed: retry until ESRCH, never count EPERM as absence.
52
+ if (code === 'EPERM') return true
53
+ throw error
54
+ }
55
+ }
56
+
57
+ private signal(signal: NodeJS.Signals) {
58
+ if (!this.child?.pid) return
59
+ try { process.kill(-this.child.pid, signal) }
60
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
61
+ }
62
+
63
+ close() {
64
+ this.closing ??= this.cleanup()
65
+ return this.closing
66
+ }
67
+
68
+ private async cleanup() {
69
+ if (!this.child) { this.cleanupVerified = true; return }
70
+ this.child.stdin.end()
71
+ await Promise.race([this.closed, delay(1000)])
72
+ if (this.groupExists()) this.signal('SIGTERM')
73
+ await Promise.race([this.closed, delay(1000)])
74
+ if (this.groupExists()) this.signal('SIGKILL')
75
+ const deadline = performance.now() + 3000
76
+ while (this.groupExists() && performance.now() < deadline) await delay(50)
77
+ this.cleanupVerified = !this.groupExists()
78
+ this.buffer.clear()
79
+ if (!this.cleanupVerified) throw new Error('Owned process group did not exit')
80
+ }
81
+ }