mobile-debug-mcp 0.35.0 → 0.36.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 (112) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +86 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +253 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +36 -16
  19. package/dist/debugger/guard.js +4 -4
  20. package/dist/debugger/ios-worker.js +62 -0
  21. package/dist/debugger/ios.js +57 -0
  22. package/dist/debugger/sessions.js +33 -16
  23. package/dist/debugger/worker.js +6 -4
  24. package/dist/interact/index.js +12 -2
  25. package/dist/manage/ios.js +2 -0
  26. package/dist/observe/ios-trace/artifact.js +63 -0
  27. package/dist/observe/ios-trace/capabilities.js +86 -0
  28. package/dist/observe/ios-trace/native.js +93 -0
  29. package/dist/observe/ios-trace/notification.js +35 -0
  30. package/dist/observe/ios-trace/process.js +98 -0
  31. package/dist/observe/ios-trace/sessions.js +244 -0
  32. package/dist/observe/readiness-service.js +1 -1
  33. package/dist/observe/readiness.js +4 -4
  34. package/dist/observe/system-trace-error.js +7 -0
  35. package/dist/observe/system-trace-router.js +81 -0
  36. package/dist/observe/system-trace.js +4 -8
  37. package/dist/server/handlers/debugger-handlers.js +27 -8
  38. package/dist/server/handlers/diagnostic.js +12 -4
  39. package/dist/server/handlers/tool-handlers.js +19 -9
  40. package/dist/server/schemas/debugger-schema.js +10 -7
  41. package/dist/server/schemas/system-trace-schema.js +14 -9
  42. package/dist/server-core.js +1 -1
  43. package/dist/server.js +4 -4
  44. package/dist/utils/ios/utils.js +3 -0
  45. package/docs/CHANGELOG.md +23 -1
  46. package/docs/agent-plugin-installation-verification.md +39 -0
  47. package/docs/code-structure.md +3 -1
  48. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  49. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  50. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  51. package/docs/specs/031-ios-system-traces/plan.md +138 -0
  52. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  53. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  54. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  55. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  56. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  57. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  58. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  59. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  60. package/docs/tools/TOOLS.md +2 -2
  61. package/docs/tools/debugger.md +45 -5
  62. package/docs/tools/system-traces.md +81 -14
  63. package/package.json +2 -1
  64. package/scripts/agent-plugin.mjs +55 -5
  65. package/src/debugger/android.ts +1 -1
  66. package/src/debugger/guard.ts +5 -5
  67. package/src/debugger/ios-worker.ts +49 -0
  68. package/src/debugger/ios.ts +45 -0
  69. package/src/debugger/lldb/breakpoints.py +51 -0
  70. package/src/debugger/lldb/inspection.py +109 -0
  71. package/src/debugger/lldb/worker.py +203 -0
  72. package/src/debugger/sessions.ts +24 -12
  73. package/src/debugger/worker.ts +7 -4
  74. package/src/interact/index.ts +13 -2
  75. package/src/manage/ios.ts +2 -0
  76. package/src/observe/ios-trace/artifact.ts +55 -0
  77. package/src/observe/ios-trace/capabilities.ts +74 -0
  78. package/src/observe/ios-trace/native.ts +72 -0
  79. package/src/observe/ios-trace/notification.ts +30 -0
  80. package/src/observe/ios-trace/process.ts +82 -0
  81. package/src/observe/ios-trace/sessions.ts +204 -0
  82. package/src/observe/readiness-service.ts +1 -1
  83. package/src/observe/readiness.ts +4 -4
  84. package/src/observe/system-trace-error.ts +3 -0
  85. package/src/observe/system-trace-router.ts +61 -0
  86. package/src/observe/system-trace.ts +4 -4
  87. package/src/server/handlers/debugger-handlers.ts +28 -9
  88. package/src/server/handlers/diagnostic.ts +11 -4
  89. package/src/server/handlers/tool-handlers.ts +20 -9
  90. package/src/server/schemas/debugger-schema.ts +10 -7
  91. package/src/server/schemas/system-trace-schema.ts +15 -9
  92. package/src/server-core.ts +1 -1
  93. package/src/server.ts +3 -3
  94. package/src/utils/ios/utils.ts +3 -0
  95. package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
  96. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  97. package/test/fixtures/contracts/tool-definitions.json +192 -32
  98. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  99. package/test/fixtures/ios-debugger-app/README.md +27 -0
  100. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  101. package/test/helpers/ios-trace-shutdown.ts +29 -0
  102. package/test/unit/debugger/handle-guards.test.ts +88 -0
  103. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  104. package/test/unit/debugger/ios.test.ts +104 -0
  105. package/test/unit/debugger/lldb-worker.py +166 -0
  106. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  107. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  108. package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
  109. package/test/unit/observe/readiness.test.ts +1 -1
  110. package/test/unit/observe/system-trace-router.test.ts +49 -0
  111. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  112. package/test/unit/server/system-trace.test.ts +12 -3
@@ -0,0 +1,55 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { XMLParser, XMLValidator } from 'fast-xml-parser'
4
+ import { getXcrunCmd } from '../../utils/ios/utils.js'
5
+ import { traceCommand, spawnTrace, type TraceSpawn } from './process.js'
6
+ import type { IosApp } from './capabilities.js'
7
+
8
+ export const IOS_TRACE_MAX_BYTES = 256 * 1024 * 1024
9
+ export async function traceSize(root: string, secure = false): Promise<number> {
10
+ const stat = await fs.lstat(root)
11
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw new Error('UNSAFE_ARTIFACT')
12
+ if (secure) await fs.chmod(root, stat.isDirectory() ? 0o700 : 0o600)
13
+ if (stat.isFile()) return stat.size
14
+ let size = 0
15
+ for (const name of await fs.readdir(root)) {
16
+ size += await traceSize(path.join(root, name), secure)
17
+ if (size > IOS_TRACE_MAX_BYTES) throw new Error('STORAGE_LIMIT')
18
+ }
19
+ return size
20
+ }
21
+ interface TocProcess { '@_pid'?: string; '@_path'?: string }
22
+ interface TocRun {
23
+ processes?: { process?: TocProcess | TocProcess[] }
24
+ info?: { target?: { device?: { '@_uuid'?: string }; process?: { '@_pid'?: string; '@_name'?: string } }; summary?: { 'template-name'?: string } }
25
+ data?: { table?: { '@_schema'?: string }[] | { '@_schema'?: string } }
26
+ }
27
+ export function validateToc(xml: string, device: string, app: IosApp) {
28
+ if (XMLValidator.validate(xml) !== true) throw new Error('INVALID_TOC')
29
+ const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml) as { 'trace-toc'?: { run?: TocRun | TocRun[] } }
30
+ const raw = parsed['trace-toc']?.run
31
+ if (!raw || Array.isArray(raw)) throw new Error('INVALID_TOC')
32
+ const target = raw.info?.target
33
+ if (target?.device?.['@_uuid'] !== device || Number(target.process?.['@_pid']) !== app.pid || raw.info?.summary?.['template-name'] !== 'Time Profiler') throw new Error('ARTIFACT_TARGET_MISMATCH')
34
+ const processes = raw.processes?.process
35
+ if (!(Array.isArray(processes) ? processes : [processes]).some(p => Number(p?.['@_pid']) === app.pid && p?.['@_path'] === app.executable)) throw new Error('ARTIFACT_APP_MISMATCH')
36
+ const tables = raw.data?.table
37
+ if (!(Array.isArray(tables) ? tables : [tables]).some(t => t?.['@_schema'] === 'time-sample')) throw new Error('SAMPLE_SCHEMA_MISSING')
38
+ }
39
+ export async function validateArtifact(file: string, device: string, app: IosApp, spawn: TraceSpawn = spawnTrace) {
40
+ const size = await traceSize(file, true)
41
+ if (size <= 0 || size > IOS_TRACE_MAX_BYTES) throw new Error('STORAGE_LIMIT')
42
+ const toc = path.join(path.dirname(file), 'toc.xml'), samples = path.join(path.dirname(file), 'samples.xml')
43
+ try {
44
+ await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--toc', '--output', toc], 15000, spawn)
45
+ if ((await fs.stat(toc)).size > 1024 * 1024) throw new Error('INVALID_TOC')
46
+ validateToc(await fs.readFile(toc, 'utf8'), device, app)
47
+ await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--xpath', '/trace-toc/run[@number="1"]/data/table[@schema="time-sample"]', '--output', samples], 15000, spawn)
48
+ if ((await fs.stat(samples)).size > IOS_TRACE_MAX_BYTES) throw new Error('STORAGE_LIMIT')
49
+ const xml = await fs.readFile(samples, 'utf8')
50
+ if (XMLValidator.validate(xml) !== true || !xml.includes('<trace-query-result>')) throw new Error('INVALID_SAMPLES')
51
+ const sample_rows = (xml.match(/<row(?:\s|>)/g) ?? []).length
52
+ return { path: file, format: 'instruments' as const, kind: (await fs.stat(file)).isDirectory() ? 'directory' as const : 'file' as const,
53
+ size_bytes: size, validation: { readable: true, schema: 'time-sample', sample_rows } }
54
+ } finally { await Promise.all([toc, samples].map(p => fs.rm(p, { force: true }))) }
55
+ }
@@ -0,0 +1,74 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { getXcrunCmd } from '../../utils/ios/utils.js'
5
+ import { traceCommand, type TraceSpawn, spawnTrace } from './process.js'
6
+ import { prepareNotification } from './notification.js'
7
+
8
+ export const IOS_TEMPLATES = ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'] as const
9
+ export type IosTemplate = typeof IOS_TEMPLATES[number]
10
+ export interface IosTarget { id: string; platform: 'ios'; simulator: boolean; osVersion: string; model: string }
11
+ export interface IosApp { app_id: string; pid: number; executable: string; started_at: string }
12
+ export interface IosCapabilities {
13
+ supported: boolean; backend: 'xctrace'; device: IosTarget | null; xcode_version: string | null
14
+ prerequisites: Record<string, 'available' | 'missing' | 'unknown'>; reasons: string[]; recovery: string[]
15
+ discovered_templates: string[]; templates: { name: IosTemplate; supported: boolean; coverage: string; reason: string | null }[]
16
+ app: IosApp | null; limitations: string[]
17
+ }
18
+ interface Simulators { devices: Record<string, { udid: string; state: string; name: string; isAvailable: boolean }[]> }
19
+ export async function resolveIosApp(device: string, appId: string, spawn: TraceSpawn = spawnTrace): Promise<IosApp> {
20
+ const run = (args: string[]) => traceCommand(getXcrunCmd(), args, 10000, spawn)
21
+ const container = await run(['simctl', 'get_app_container', device, appId, 'app'])
22
+ if (!path.isAbsolute(container) || !container.endsWith('.app')) throw new Error('APP_IDENTITY_UNAVAILABLE')
23
+ const output = await run(['simctl', 'spawn', device, 'launchctl', 'list'])
24
+ const ids = output.split('\n').filter(line => line.includes(`UIKitApplication:${appId}[`))
25
+ .map(line => Number(line.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1)
26
+ if (ids.length !== 1) throw new Error('APP_NOT_RUNNING')
27
+ const executable = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'comm='], 10000, spawn)
28
+ if (!executable.startsWith(container + '/') || executable.includes('\n')) throw new Error('APP_IDENTITY_UNAVAILABLE')
29
+ const started_at = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'lstart='], 10000, spawn)
30
+ if (!started_at || started_at.includes('\n')) throw new Error('APP_IDENTITY_UNAVAILABLE')
31
+ return { app_id: appId, pid: ids[0], executable, started_at }
32
+ }
33
+ export async function iosCapabilities(device: string, appId?: string, template: IosTemplate = 'Time Profiler', spawn: TraceSpawn = spawnTrace, host: string = process.platform): Promise<IosCapabilities> {
34
+ const result: IosCapabilities = {
35
+ supported: false, backend: 'xctrace', device: null, xcode_version: null,
36
+ prerequisites: { host: host === 'darwin' ? 'available' : 'missing', permissions: 'unknown', symbols: 'unknown' },
37
+ reasons: [], recovery: [], discovered_templates: [], templates: [], app: null,
38
+ limitations: ['App attachment may include sensitive system metadata, paths, logs and signposts.', 'Permissions and symbolication are not established by discovery.', 'Physical iOS capture is not supported in the initial matrix.']
39
+ }
40
+ if (host !== 'darwin') { result.reasons.push('MACOS_REQUIRED'); return result }
41
+ const probe = async (key: string, fn: () => Promise<void>, recovery: string) => {
42
+ try { await fn(); result.prerequisites[key] = 'available' }
43
+ catch (error) { result.prerequisites[key] = 'missing'; result.reasons.push(error instanceof Error && ['APP_NOT_RUNNING', 'APP_IDENTITY_UNAVAILABLE'].includes(error.message) ? error.message : key.toUpperCase() + '_UNAVAILABLE'); result.recovery.push(recovery) }
44
+ }
45
+ await probe('xcode', async () => { result.xcode_version = await traceCommand(getXcrunCmd(), ['xcodebuild', '-version'], 10000, spawn) }, 'Select a full Xcode installation with xcode-select.')
46
+ await probe('templates', async () => {
47
+ const output = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'templates'], 10000, spawn)
48
+ result.discovered_templates = output.split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('=='))
49
+ }, 'Install/select Xcode with Instruments and run xcrun xctrace list templates.')
50
+ await probe('instruments_targets', async () => {
51
+ const targets = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'devices'], 15000, spawn)
52
+ if (!targets.includes(`(${device})`)) throw new Error('TARGET_UNAVAILABLE')
53
+ }, 'Check xcrun xctrace list devices; use a simulator visible to Instruments.')
54
+ await probe('target', async () => {
55
+ const data = JSON.parse(await traceCommand(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], 10000, spawn)) as Simulators
56
+ const entries = Object.entries(data.devices).flatMap(([runtime, values]) => values.map(value => ({ ...value, runtime })))
57
+ const target = entries.find(d => d.udid === device)
58
+ if (!target || !target.isAvailable || target.state !== 'Booted') throw new Error('TARGET_UNAVAILABLE')
59
+ result.device = { platform: 'ios', id: target.udid, simulator: true, model: target.name, osVersion: target.runtime.split('iOS-')[1]?.replaceAll('-', '.') ?? target.runtime }
60
+ }, 'Use an explicit booted simulator UDID. Physical devices and the Mac host are unsupported.')
61
+ await probe('notification_helper', async () => {
62
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-trace-probe-'))
63
+ try { await prepareNotification(directory, spawn) } finally { await fs.rm(directory, { recursive: true, force: true }) }
64
+ }, 'Allow Xcode clang to compile the local Darwin-notification listener.')
65
+ if (appId && result.device) await probe('app', async () => { result.app = await resolveIosApp(device, appId, spawn) }, 'Start the requested development app on the selected simulator before tracing.')
66
+ result.templates = IOS_TEMPLATES.map(name => ({ name,
67
+ supported: name === 'Time Profiler' && result.discovered_templates.includes(name) && result.device !== null,
68
+ coverage: name === 'Time Profiler' ? 'CPU samples; bundled Hangs instrument is not independently verified' : name === 'System Trace' ? 'Scheduling coverage unverified' : name === 'Hangs' ? 'Not a standalone template here' : 'Unsupported on tested simulator',
69
+ reason: name === 'Time Profiler' ? null : 'TEMPLATE_TARGET_UNSUPPORTED'
70
+ }))
71
+ if (!result.templates.find(t => t.name === template)?.supported) result.reasons.push('TEMPLATE_TARGET_UNSUPPORTED')
72
+ result.supported = result.reasons.length === 0
73
+ return result
74
+ }
@@ -0,0 +1,72 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
3
+ import path from 'node:path'
4
+ import { getXcrunCmd } from '../../utils/ios/utils.js'
5
+ import { iosCapabilities, resolveIosApp, type IosApp, type IosTemplate } from './capabilities.js'
6
+ import { prepareNotification, listenNotification } from './notification.js'
7
+ import { spawnTrace, stopTraceChild, classifyTraceFailure, type TraceSpawn, type OwnedTraceChild } from './process.js'
8
+ import { traceSize, validateArtifact, IOS_TRACE_MAX_BYTES } from './artifact.js'
9
+
10
+ export interface IosRecording { recorder: OwnedTraceChild; file: string; ready_at_ms: number }
11
+ export class IosTraceNative {
12
+ private spawn: TraceSpawn
13
+ private children = new Set<OwnedTraceChild>()
14
+ private recorders = new Set<OwnedTraceChild>()
15
+ private closing = false
16
+ constructor(spawn: TraceSpawn = spawnTrace) {
17
+ this.spawn = (command, args, timeout) => {
18
+ if (this.closing) throw new Error('TRACE_BUSY')
19
+ const child = spawn(command, args, timeout)
20
+ this.children.add(child)
21
+ if (args.includes('xctrace') && args.includes('record')) this.recorders.add(child)
22
+ void child.done.then(() => { this.children.delete(child); this.recorders.delete(child) })
23
+ return child
24
+ }
25
+ }
26
+ shutdown() {
27
+ this.closing = true
28
+ for (const child of this.children) child.signal('SIGINT')
29
+ const helpers = setTimeout(() => { for (const child of this.children) if (!this.recorders.has(child)) child.signal('SIGKILL') }, 1000)
30
+ const recorders = setTimeout(() => { for (const child of this.recorders) child.signal('SIGKILL') }, 8000)
31
+ helpers.unref(); recorders.unref()
32
+ }
33
+ capabilities(device: string, appId?: string, template?: IosTemplate) { return iosCapabilities(device, appId, template, this.spawn) }
34
+ resolve(device: string, app: string) { return resolveIosApp(device, app, this.spawn) }
35
+ async storage(directory: string) {
36
+ const stat = await fs.statfs(directory)
37
+ if (stat.bavail * stat.bsize < 1024 * 1024 * 1024) throw new Error('STORAGE_LIMIT')
38
+ }
39
+ async start(device: string, app: IosApp, directory: string, duration: number, own: (recording: IosRecording) => void): Promise<IosRecording> {
40
+ await this.storage(directory)
41
+ const binary = await prepareNotification(directory, this.spawn)
42
+ return this.startRecorder(device, app, directory, duration, binary, own)
43
+ }
44
+ private async startRecorder(device: string, app: IosApp, directory: string, duration: number, binary: string, own: (recording: IosRecording) => void) {
45
+ const name = 'tools.mobiledebug.trace.' + randomUUID()
46
+ const listener = await listenNotification(binary, name, this.spawn)
47
+ let recording: IosRecording | undefined
48
+ try {
49
+ const current = await this.resolve(device, app.app_id)
50
+ if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at) throw new Error('APP_IDENTITY_CHANGED')
51
+ const file = path.join(directory, 'capture.trace')
52
+ const recorder = this.spawn(getXcrunCmd(), ['xctrace', 'record', '--template', 'Time Profiler', '--device', device,
53
+ '--attach', String(app.pid), '--time-limit', `${duration}ms`, '--output', file, '--notify-tracing-started', name], duration + 65000)
54
+ recording = { recorder, file, ready_at_ms: 0 }; own(recording)
55
+ await Promise.race([listener.line('READY', 35000), recorder.done.then(result => { throw new Error(classifyTraceFailure(result)) })])
56
+ if (!recorder.alive()) throw new Error('RECORDER_EXITED')
57
+ recording.ready_at_ms = Date.now()
58
+ return recording
59
+ } catch (error) {
60
+ if (recording) await stopTraceChild(recording.recorder).catch(() => {})
61
+ throw error
62
+ } finally { await stopTraceChild(listener, 1000).catch(() => {}) }
63
+ }
64
+ stop(recording: IosRecording, timeout?: number) { return stopTraceChild(recording.recorder, timeout) }
65
+ async monitor(device: string, app: IosApp, recording: IosRecording) {
66
+ const current = await this.resolve(device, app.app_id)
67
+ if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at) throw new Error('APP_IDENTITY_CHANGED')
68
+ try { if (await traceSize(recording.file) > IOS_TRACE_MAX_BYTES) throw new Error('STORAGE_LIMIT') }
69
+ catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error }
70
+ }
71
+ validate(recording: IosRecording, device: string, app: IosApp) { return validateArtifact(recording.file, device, app, this.spawn) }
72
+ }
@@ -0,0 +1,30 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { getXcrunCmd } from '../../utils/ios/utils.js'
4
+ import { traceCommand, spawnTrace, stopTraceChild, type TraceSpawn, type OwnedTraceChild } from './process.js'
5
+
6
+ // A private per-session listener avoids requiring a third-party notification utility.
7
+ const source = `#include <notify.h>
8
+ #include <dispatch/dispatch.h>
9
+ #include <stdio.h>
10
+ #include <stdlib.h>
11
+ int main(int argc, char **argv) {
12
+ if (argc != 2) return 2;
13
+ int token;
14
+ if (notify_register_dispatch(argv[1], &token, dispatch_get_main_queue(), ^(int t) {
15
+ puts("READY"); fflush(stdout); notify_cancel(t); exit(0);
16
+ }) != NOTIFY_STATUS_OK) return 3;
17
+ puts("ARMED"); fflush(stdout); dispatch_main();
18
+ }`
19
+ export async function prepareNotification(directory: string, spawn: TraceSpawn = spawnTrace) {
20
+ const file = path.join(directory, 'notification.c'), binary = path.join(directory, 'notification')
21
+ await fs.writeFile(file, source, { mode: 0o600 })
22
+ await traceCommand(getXcrunCmd(), ['clang', '-fblocks', file, '-o', binary], 30000, spawn)
23
+ await fs.chmod(binary, 0o700)
24
+ return binary
25
+ }
26
+ export async function listenNotification(binary: string, name: string, spawn: TraceSpawn = spawnTrace): Promise<OwnedTraceChild> {
27
+ const listener = spawn(binary, [name], 40000)
28
+ try { await listener.line('ARMED', 5000); return listener }
29
+ catch (error) { await stopTraceChild(listener, 1000).catch(() => {}); throw error }
30
+ }
@@ -0,0 +1,82 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+
3
+ export interface TraceCommandResult { code: number | null; output: string; timedOut: boolean }
4
+ export interface OwnedTraceChild {
5
+ done: Promise<TraceCommandResult>
6
+ alive(): boolean
7
+ signal(signal: NodeJS.Signals): void
8
+ line(text: string, timeout: number): Promise<void>
9
+ }
10
+ export type TraceSpawn = (command: string, args: string[], timeout: number) => OwnedTraceChild
11
+ const MAX_OUTPUT = 64 * 1024
12
+
13
+ export const spawnTrace: TraceSpawn = (command, args, timeout) => {
14
+ const child: ChildProcess = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
15
+ let output = '', settled = false, timedOut = false
16
+ const waiters = new Set<() => void>()
17
+ let force: ReturnType<typeof setTimeout> | undefined
18
+ const terminate = () => {
19
+ if (settled) return
20
+ child.kill('SIGTERM')
21
+ force ??= setTimeout(() => { if (!settled) child.kill('SIGKILL') }, 1000)
22
+ force.unref()
23
+ }
24
+ const timer = setTimeout(() => { timedOut = true; terminate() }, timeout)
25
+ timer.unref()
26
+ const done = new Promise<TraceCommandResult>(resolve => {
27
+ const finish = (code: number | null) => {
28
+ if (settled) return
29
+ settled = true; clearTimeout(timer); if (force) clearTimeout(force)
30
+ for (const wake of waiters) wake()
31
+ resolve({ code, output, timedOut })
32
+ }
33
+ child.on('error', () => finish(null))
34
+ child.on('close', finish)
35
+ const data = (chunk: Buffer) => {
36
+ output = (output + chunk.toString()).slice(-MAX_OUTPUT)
37
+ for (const wake of waiters) wake()
38
+ }
39
+ child.stdout?.on('data', data); child.stderr?.on('data', data)
40
+ })
41
+ return {
42
+ done, alive: () => !settled && child.exitCode === null && child.signalCode === null,
43
+ signal: signal => { if (!settled) child.kill(signal) },
44
+ line: (text, ms) => new Promise<void>((resolve, reject) => {
45
+ const deadline = setTimeout(() => { waiters.delete(check); reject(new Error('READINESS_TIMEOUT')) }, ms)
46
+ const check = () => {
47
+ if (output.split(/\r?\n/).includes(text)) { clearTimeout(deadline); waiters.delete(check); resolve() }
48
+ else if (settled) { clearTimeout(deadline); waiters.delete(check); reject(new Error('RECORDER_EXITED')) }
49
+ }
50
+ waiters.add(check); check()
51
+ })
52
+ }
53
+ }
54
+
55
+ export async function traceCommand(command: string, args: string[], timeout = 10000, spawn: TraceSpawn = spawnTrace) {
56
+ const result = await spawn(command, args, timeout).done
57
+ if (result.code !== 0 || result.timedOut) throw new Error(result.timedOut ? 'ACTION_TIMEOUT' : 'COMMAND_FAILED')
58
+ return result.output.trim()
59
+ }
60
+ export async function stopTraceChild(child: OwnedTraceChild, timeout = 30000) {
61
+ if (child.alive()) child.signal('SIGINT')
62
+ let timer: ReturnType<typeof setTimeout> | undefined
63
+ const result = await Promise.race([
64
+ child.done,
65
+ new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), timeout) })
66
+ ])
67
+ if (timer) clearTimeout(timer)
68
+ if (result) return result
69
+ child.signal('SIGKILL')
70
+ let forceTimer: ReturnType<typeof setTimeout> | undefined
71
+ await Promise.race([child.done, new Promise<void>(resolve => { forceTimer = setTimeout(resolve, 1000) })])
72
+ if (forceTimer) clearTimeout(forceTimer)
73
+ throw new Error('ACTION_TIMEOUT')
74
+ }
75
+
76
+ export function classifyTraceFailure(result: TraceCommandResult): string {
77
+ if (result.timedOut) return 'ACTION_TIMEOUT'
78
+ if (/permission|not permitted|authoriz|privacy|access denied/i.test(result.output)) return 'PROFILING_PERMISSION_REQUIRED'
79
+ if (/cannot find process|process.*not.*found/i.test(result.output)) return 'APP_NOT_RUNNING'
80
+ if (/disconnect|device.*unavailable/i.test(result.output)) return 'TARGET_DISCONNECTED'
81
+ return 'RECORDER_EXITED'
82
+ }
@@ -0,0 +1,204 @@
1
+ import { classifyTraceFailure } from './process.js'
2
+ import type { TraceClock } from '../system-trace.js'
3
+ import { TraceError } from '../system-trace-error.js'
4
+ import { promises as fs } from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import { randomUUID } from 'node:crypto'
8
+ import { evidenceSessionId } from '../evidence.js'
9
+ import { IosTraceNative, type IosRecording } from './native.js'
10
+ import type { IosApp, IosTarget } from './capabilities.js'
11
+ import type { validateArtifact } from './artifact.js'
12
+
13
+ export interface IosTraceResult {
14
+ trace_id: string; session_id: string; device: IosTarget; backend: 'xctrace'
15
+ state: 'starting' | 'recording' | 'stopping' | 'completed' | 'cancelled' | 'failed'
16
+ reason: string | null; recovery: string | null; completion_reason: 'requested' | 'duration_limit' | 'cancelled' | 'error' | null
17
+ capture_started_at_ms: number | null; capture_completed_at_ms: number | null; recording_ready_at_ms: number | null
18
+ app_id: string; pid: number | null; executable: string | null; template: 'Time Profiler'
19
+ artifact: Awaited<ReturnType<typeof validateArtifact>> | null
20
+ cleanup: { status: 'pending' | 'complete' | 'failed'; remaining_resources: string[] }
21
+ warnings: string[]; correlation_ids: string[]; clock_alignment: 'unknown'
22
+ }
23
+ interface Session {
24
+ result: IosTraceResult; directory?: string; app?: IosApp; recording?: IosRecording
25
+ timer?: ReturnType<typeof setTimeout>; deadline: number; terminal?: number; discard: boolean
26
+ queue: Promise<unknown>; linked: Set<string>; partial: Set<string>; recorderOk?: boolean; monitorFailures?: number
27
+ }
28
+ const reasonFor = (error: unknown, fallback: string) => {
29
+ const code = error instanceof Error ? error.message : ''
30
+ return ['STORAGE_LIMIT', 'APP_IDENTITY_CHANGED', 'ACTION_TIMEOUT', 'READINESS_TIMEOUT', 'RECORDER_EXITED', 'PROFILING_PERMISSION_REQUIRED', 'APP_NOT_RUNNING', 'TARGET_DISCONNECTED'].includes(code) ? code : fallback
31
+ }
32
+ export class IosTraceSessions {
33
+ private sessions = new Map<string, Session>()
34
+ private closing = false
35
+ constructor(private native = new IosTraceNative(), private time: TraceClock = {
36
+ wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
37
+ schedule: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref(); return timer }, clear: clearTimeout
38
+ }) {}
39
+ private get(id: string) {
40
+ this.prune(); const s = this.sessions.get(id)
41
+ if (!s) throw new TraceError('TRACE_NOT_FOUND')
42
+ return s
43
+ }
44
+ private prune() {
45
+ const terminal = [...this.sessions.values()].filter(s => s.terminal !== undefined && s.result.cleanup.status === 'complete')
46
+ for (const [index, s] of terminal.entries()) if (this.time.mono() - s.terminal! >= 600000 || index < terminal.length - 100) this.sessions.delete(s.result.trace_id)
47
+ }
48
+ private serial<T>(s: Session, fn: () => Promise<T>) {
49
+ const promise = s.queue.then(fn); s.queue = promise.catch(() => {}); return promise
50
+ }
51
+ private snapshot(s: Session) { return structuredClone(s.result) }
52
+ private fail(s: Session, reason: string) {
53
+ s.result.state = 'failed'; s.result.reason = reason; s.result.completion_reason = 'error'
54
+ s.result.recovery = reason === 'PROFILING_PERMISSION_REQUIRED' ? 'Grant the required profiling permission through the macOS or Xcode UI, then explicitly start a new capture.' : reason === 'TRACE_ARTIFACT_FAILED' ? 'Retry stop to validate the finalised owned artifact, or cancel to discard.' : 'Check target, app and profiling permissions; cancel the owned session before retrying start.'
55
+ for (const id of s.linked) s.partial.add(id)
56
+ }
57
+ async start(device: IosTarget, appId: string, duration = 30000) {
58
+ this.prune()
59
+ const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete')
60
+ if (this.closing || active.length >= 4 || active.some(s => s.result.device.id === device.id)) throw new TraceError('TRACE_BUSY')
61
+ if (!Number.isInteger(duration) || duration < 1000 || duration > 120000) throw new TraceError('INVALID_ARGUMENT')
62
+ const result: IosTraceResult = {
63
+ trace_id: randomUUID(), session_id: evidenceSessionId(), device: structuredClone(device), backend: 'xctrace', state: 'starting',
64
+ reason: null, recovery: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
65
+ recording_ready_at_ms: null, app_id: appId, pid: null, executable: null, template: 'Time Profiler', artifact: null,
66
+ cleanup: { status: 'pending', remaining_resources: [] }, warnings: [], correlation_ids: [], clock_alignment: 'unknown'
67
+ }
68
+ const s: Session = { result, deadline: Infinity, discard: false, queue: Promise.resolve(), linked: new Set(), partial: new Set() }
69
+ this.sessions.set(result.trace_id, s)
70
+ return this.serial(s, async () => {
71
+ try {
72
+ s.directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-ios-trace-'))
73
+ await fs.chmod(s.directory, 0o700)
74
+ s.result.cleanup.remaining_resources = [s.directory]
75
+ s.app = await this.native.resolve(device.id, appId)
76
+ s.result.pid = s.app.pid; s.result.executable = s.app.executable
77
+ if (this.closing) throw new TraceError('TRACE_BUSY')
78
+ s.result.capture_started_at_ms = this.time.wall()
79
+ s.deadline = Infinity
80
+ s.recording = await this.native.start(device.id, s.app, s.directory, duration, recording => { s.recording = recording; if (this.closing) recording.recorder.signal('SIGINT') })
81
+ if (this.closing) throw new Error('READINESS_TIMEOUT')
82
+ s.deadline = this.time.mono() + duration
83
+ s.result.recording_ready_at_ms = s.recording.ready_at_ms
84
+ s.result.state = 'recording'
85
+ this.schedule(s)
86
+ void s.recording.recorder.done.then(() => this.control(result.trace_id, 'status')).catch(() => {})
87
+ } catch (error) {
88
+ this.fail(s, reasonFor(error, 'TRACE_START_FAILED')); s.discard = true
89
+ await this.finalise(s, 'cancel', true)
90
+ }
91
+ return this.snapshot(s)
92
+ })
93
+ }
94
+ private schedule(s: Session) {
95
+ if (s.result.state !== 'recording') return
96
+ s.timer = this.time.schedule(() => {
97
+ void this.serial(s, () => this.poll(s)).finally(() => this.schedule(s)).catch(() => {})
98
+ }, 1000)
99
+ }
100
+ private async poll(s: Session) {
101
+ if (s.result.state !== 'recording' || !s.recording || !s.app) return
102
+ try {
103
+ await this.native.monitor(s.result.device.id, s.app, s.recording)
104
+ s.monitorFailures = 0
105
+ } catch (error) {
106
+ const reason = error instanceof Error && error.message === 'COMMAND_FAILED'
107
+ ? 'COMMAND_FAILED' : reasonFor(error, 'TRACE_MONITOR_FAILED')
108
+ const definitive = ['STORAGE_LIMIT', 'APP_IDENTITY_CHANGED', 'APP_NOT_RUNNING'].includes(reason)
109
+ s.monitorFailures = (s.monitorFailures ?? 0) + 1
110
+ if (definitive || s.monitorFailures >= 3) {
111
+ this.fail(s, reason); s.discard = true
112
+ await this.finalise(s, 'cancel', true)
113
+ return
114
+ }
115
+ }
116
+ if (this.time.mono() >= s.deadline || !s.recording.recorder.alive()) await this.finalise(s, 'duration')
117
+ }
118
+ async control(id: string, op: 'status' | 'stop' | 'cancel' | 'duration', timeout?: number) {
119
+ const s = this.get(id)
120
+ return this.serial(s, async () => {
121
+ if (s.result.cleanup.status === 'complete') return this.snapshot(s)
122
+ if (op === 'status' && s.result.state === 'recording' && s.recording?.recorder.alive() && this.time.mono() < s.deadline) {
123
+ await this.poll(s); return this.snapshot(s)
124
+ }
125
+ await this.finalise(s, op === 'status' ? 'duration' : op, false, timeout)
126
+ return this.snapshot(s)
127
+ })
128
+ }
129
+ private async finalise(s: Session, op: string, preserveFailure = false, timeout?: number) {
130
+ if (s.timer) { this.time.clear(s.timer); s.timer = undefined }
131
+ if (op === 'cancel') s.discard = true
132
+ if (!preserveFailure) s.result.state = 'stopping'
133
+ for (const id of s.linked) s.partial.add(id)
134
+ try {
135
+ await this.finishRecorder(s, op, timeout)
136
+ s.result.capture_completed_at_ms ??= this.time.wall()
137
+ await this.finishArtifact(s)
138
+ s.result.cleanup = { status: 'complete', remaining_resources: [] }; s.terminal = this.time.mono()
139
+ if (!preserveFailure) {
140
+ s.result.state = s.discard ? 'cancelled' : 'completed'; s.result.reason = s.discard ? 'TRACE_CANCELLED' : null; s.result.recovery = null
141
+ s.result.completion_reason = s.discard ? 'cancelled' : op === 'duration' ? 'duration_limit' : 'requested'
142
+ }
143
+ } catch (error) {
144
+ this.fail(s, reasonFor(error, s.recording?.recorder.alive() ? 'TRACE_STOP_FAILED' : 'TRACE_ARTIFACT_FAILED'))
145
+ s.result.cleanup.status = 'failed'
146
+ if (s.result.reason === 'STORAGE_LIMIT' && !s.recording?.recorder.alive()) await this.discardOversized(s)
147
+ }
148
+ }
149
+ private async discardOversized(s: Session) {
150
+ try {
151
+ if (s.directory) await fs.rm(s.directory, { recursive: true, force: true })
152
+ s.result.cleanup = { status: 'complete', remaining_resources: [] }; s.terminal = this.time.mono()
153
+ } catch { /* retain failed cleanup and owned path for cancel retry */ }
154
+ }
155
+ private async finishRecorder(s: Session, op: string, timeout?: number) {
156
+ if (!s.recording || s.recorderOk !== undefined) return
157
+ const ended = await this.native.stop(s.recording, timeout)
158
+ s.recorderOk = ended.code === 0 && !ended.timedOut
159
+ if (ended.output.includes('[Warning]')) s.result.warnings = ['INSTRUMENTS_RUN_WARNING: one or more Instruments warnings; coverage may be incomplete.']
160
+ if (!s.recorderOk && !s.discard) throw new Error(classifyTraceFailure(ended))
161
+ if (op === 'duration' && !ended.output.includes('Reached specified time limit') && this.time.mono() < s.deadline && !s.discard) throw new Error('RECORDER_EXITED')
162
+ }
163
+ private async finishArtifact(s: Session) {
164
+ if (!s.discard && !s.result.artifact) {
165
+ if (!s.recording || !s.app || !s.recorderOk) throw new Error('RECORDER_EXITED')
166
+ s.result.artifact = await this.native.validate(s.recording, s.result.device.id, s.app)
167
+ }
168
+ if (s.discard && s.directory) await fs.rm(s.directory, { recursive: true, force: true })
169
+ else if (s.directory) await Promise.all(['notification', 'notification.c'].map(p => fs.rm(path.join(s.directory!, p), { force: true })))
170
+ }
171
+ async link(id: string, device: { platform: string; id: string }, correlation: string) {
172
+ const s = this.get(id)
173
+ if (device.platform !== 'ios' || device.id !== s.result.device.id) throw new TraceError('TRACE_NOT_FOUND')
174
+ const status = await this.control(id, 'status')
175
+ if (status.state !== 'recording') throw new TraceError('TRACE_NOT_FOUND')
176
+ return this.serial(s, async () => {
177
+ if (s.result.state !== 'recording' || !s.recording?.recorder.alive() || this.time.mono() >= s.deadline) throw new TraceError('TRACE_NOT_FOUND')
178
+ if (!s.result.correlation_ids.includes(correlation)) {
179
+ if (s.result.correlation_ids.length >= 1000) throw new TraceError('TRACE_BUSY')
180
+ s.result.correlation_ids.push(correlation)
181
+ }
182
+ s.linked.add(correlation)
183
+ let finished: Promise<{ trace_id: string; coverage: string; clock_alignment: string }> | undefined
184
+ return () => finished ??= (async () => {
185
+ let active = false
186
+ try { active = (await this.control(id, 'status')).state === 'recording' } catch { /* unknown is partial */ }
187
+ s.linked.delete(correlation)
188
+ return { trace_id: id, coverage: !active || s.partial.has(correlation) ? 'partial' : 'covered', clock_alignment: 'unknown' }
189
+ })()
190
+ })
191
+ }
192
+ async shutdown() {
193
+ this.closing = true
194
+ this.native.shutdown()
195
+ const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete')
196
+ // Signal immediately even when an in-flight start/validation owns the serial queue.
197
+ for (const s of active) s.recording?.recorder.signal('SIGINT')
198
+ let timer: ReturnType<typeof setTimeout> | undefined
199
+ await Promise.race([Promise.allSettled(active.map(s => this.control(s.result.trace_id, 'cancel', 8000))),
200
+ new Promise<void>(resolve => { timer = setTimeout(resolve, 9000) })])
201
+ if (timer) clearTimeout(timer)
202
+ return active.filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s))
203
+ }
204
+ }
@@ -35,7 +35,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
35
35
  let count = 0
36
36
  while (wait || count < 2) {
37
37
  remainingBudget()
38
- debugPause = device!.platform === 'android' ? pausedReadiness(device!.id) : null
38
+ debugPause = pausedReadiness(device!.id, device!.platform)
39
39
  if (debugPause) { failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
40
40
  const captureStarted = Date.now()
41
41
  try {
@@ -21,7 +21,7 @@ export class ReadinessTracker {
21
21
  this.summary = {
22
22
  state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
23
23
  sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
24
- evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
24
+ evidence: [], limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
25
25
  }
26
26
 
27
27
  }
@@ -30,7 +30,7 @@ export class ReadinessTracker {
30
30
  this.signature = null
31
31
  this.summary = {
32
32
  ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
33
- sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
33
+ sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
34
34
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8)
35
35
  }
36
36
  return this.summary
@@ -42,7 +42,7 @@ export class ReadinessTracker {
42
42
  }
43
43
 
44
44
  observe(sample: unknown, acquisitionStartedAtMs: number, now = performance.now()): ReadinessSummary {
45
- const paused = this.platform === 'android' ? pausedReadiness(this.deviceId) : null
45
+ const paused = pausedReadiness(this.deviceId, this.platform)
46
46
  if (paused) { this.summary = paused; return this.summary }
47
47
  const tree = sample as GetUITreeResponse | null | undefined
48
48
  if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
@@ -64,7 +64,7 @@ export class ReadinessTracker {
64
64
  captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
65
65
  sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
66
66
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
67
- limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
67
+ limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
68
68
  }
69
69
  return this.summary
70
70
  }
@@ -0,0 +1,3 @@
1
+ export class TraceError extends Error {
2
+ constructor(public code: string) { super(code) }
3
+ }
@@ -0,0 +1,61 @@
1
+ import { TraceError } from './system-trace-error.js'
2
+ import type { SystemTraceSessions, TraceResult } from './system-trace.js'
3
+ import { IosTraceSessions, type IosTraceResult } from './ios-trace/sessions.js'
4
+ import { IosTraceNative } from './ios-trace/native.js'
5
+ import type { IosTemplate } from './ios-trace/capabilities.js'
6
+
7
+ interface Route { platform: 'android' | 'ios'; device: string; active: boolean; touched: number }
8
+ export class SystemTraceRouter {
9
+ private routes = new Map<string, Route>()
10
+ private pending = new Set<string>()
11
+ private closing = false
12
+ constructor(private android: SystemTraceSessions, private ios = new IosTraceSessions(), private native = new IosTraceNative()) {}
13
+ capabilities(device: string) { return this.android.capabilities(device) }
14
+ iosCapabilities(device: string, appId?: string, template?: IosTemplate) { return this.native.capabilities(device, appId, template) }
15
+ private backend(platform: string) { return platform === 'ios' ? this.ios : this.android }
16
+ private track(result: TraceResult | IosTraceResult) {
17
+ this.routes.set(result.trace_id, { platform: result.device.platform, device: result.device.id,
18
+ active: result.cleanup.status !== 'complete', touched: performance.now() })
19
+ const terminal = [...this.routes.entries()].filter(([, r]) => !r.active)
20
+ for (const [index, [id, r]] of terminal.entries()) if (performance.now() - r.touched > 600000 || index < terminal.length - 100) this.routes.delete(id)
21
+ return result
22
+ }
23
+ async start(device: string, duration?: number, ios?: { appId: string; template?: IosTemplate }) {
24
+ if (this.closing) throw new TraceError('TRACE_BUSY')
25
+ // Refresh automatically finalised reservations without trusting cached state.
26
+ await Promise.all([...this.routes.entries()].filter(([, r]) => r.active).map(async ([id, r]) => {
27
+ try { this.track(await this.backend(r.platform).control(id, 'status')) }
28
+ catch { /* uncertain cleanup continues to reserve capacity */ }
29
+ }))
30
+ const key = (ios ? 'ios:' : 'android:') + device
31
+ const active = [...this.routes.values()].filter(r => r.active)
32
+ if (this.closing || this.pending.has(key) || active.some(r => r.platform + ':' + r.device === key) || active.length + this.pending.size >= 4) throw new TraceError('TRACE_BUSY')
33
+ this.pending.add(key)
34
+ try {
35
+ if (!ios) return this.track(await this.android.start(device, duration))
36
+ const cap = await this.native.capabilities(device, ios.appId, ios.template)
37
+ if (!cap.supported || !cap.device) throw new TraceError('TRACE_UNSUPPORTED')
38
+ if (this.closing) throw new TraceError('TRACE_BUSY')
39
+ return this.track(await this.ios.start(cap.device, ios.appId, duration))
40
+ } finally { this.pending.delete(key) }
41
+ }
42
+ async control(id: string, operation: 'status' | 'stop' | 'cancel' | 'duration') {
43
+ const route = this.routes.get(id)
44
+ if (!route) throw new TraceError('TRACE_NOT_FOUND')
45
+ return this.track(await this.backend(route.platform).control(id, operation))
46
+ }
47
+ async link(id: string, device: { platform: string; id: string }, correlation: string) {
48
+ const route = this.routes.get(id)
49
+ if (!route || route.platform !== device.platform || route.device !== device.id) throw new TraceError('TRACE_NOT_FOUND')
50
+ return this.backend(route.platform).link(id, device, correlation)
51
+ }
52
+ async shutdown() {
53
+ this.closing = true
54
+ this.native.shutdown()
55
+ const results = await Promise.allSettled([this.android.shutdown(), this.ios.shutdown()])
56
+ if (results.some(result => result.status === 'rejected')) throw new TraceError('TRACE_STOP_FAILED')
57
+ const remaining: (TraceResult | IosTraceResult)[] = []
58
+ for (const result of results) if (result.status === 'fulfilled') remaining.push(...result.value)
59
+ return remaining
60
+ }
61
+ }