mobile-debug-mcp 0.33.0 → 0.34.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 (87) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/README.md +7 -0
  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/README.md +4 -0
  8. package/agent-plugin/mcp.json +1 -1
  9. package/agent-plugin/plugin.json +1 -1
  10. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
  11. package/dist/debugger/android.js +78 -0
  12. package/dist/debugger/guard.js +30 -0
  13. package/dist/debugger/sessions.js +262 -0
  14. package/dist/debugger/worker.js +165 -0
  15. package/dist/interact/index.js +16 -0
  16. package/dist/observe/evidence.js +9 -2
  17. package/dist/observe/readiness-service.js +9 -0
  18. package/dist/observe/readiness.js +15 -8
  19. package/dist/observe/system-trace-android.js +135 -0
  20. package/dist/observe/system-trace.js +285 -0
  21. package/dist/server/debugger-handlers.js +38 -0
  22. package/dist/server/debugger-schema.js +42 -0
  23. package/dist/server/journey-schema.js +1 -0
  24. package/dist/server/system-trace-schema.js +17 -0
  25. package/dist/server/tool-definitions.js +6 -1
  26. package/dist/server/tool-handlers.js +203 -96
  27. package/dist/server-core.js +3 -3
  28. package/dist/server.js +34 -0
  29. package/dist/system/index.js +5 -4
  30. package/dist/utils/exec.js +2 -0
  31. package/dist/utils/java.js +14 -12
  32. package/dist/utils/operation-budget.js +2 -0
  33. package/dist/utils/resolve-device.js +2 -0
  34. package/docs/CHANGELOG.md +4 -0
  35. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  36. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  37. package/docs/specs/023-android-system-traces/intent.md +40 -0
  38. package/docs/specs/023-android-system-traces/plan.md +117 -0
  39. package/docs/specs/023-android-system-traces/spec.md +120 -0
  40. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  41. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  42. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  43. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  44. package/docs/tools/TOOLS.md +3 -1
  45. package/docs/tools/debugger.md +104 -0
  46. package/docs/tools/evidence.md +5 -2
  47. package/docs/tools/observe.md +12 -5
  48. package/docs/tools/system-traces.md +126 -0
  49. package/package.json +1 -1
  50. package/src/debugger/android.ts +65 -0
  51. package/src/debugger/guard.ts +26 -0
  52. package/src/debugger/sessions.ts +168 -0
  53. package/src/debugger/worker.ts +122 -0
  54. package/src/interact/index.ts +16 -15
  55. package/src/observe/evidence.ts +9 -2
  56. package/src/observe/readiness-service.ts +5 -0
  57. package/src/observe/readiness.ts +12 -6
  58. package/src/observe/system-trace-android.ts +133 -0
  59. package/src/observe/system-trace.ts +218 -0
  60. package/src/server/debugger-handlers.ts +29 -0
  61. package/src/server/debugger-schema.ts +37 -0
  62. package/src/server/journey-schema.ts +1 -0
  63. package/src/server/system-trace-schema.ts +17 -0
  64. package/src/server/tool-definitions.ts +6 -1
  65. package/src/server/tool-handlers.ts +92 -7
  66. package/src/server-core.ts +3 -3
  67. package/src/server.ts +24 -0
  68. package/src/system/index.ts +7 -5
  69. package/src/utils/exec.ts +2 -0
  70. package/src/utils/java.ts +13 -12
  71. package/src/utils/operation-budget.ts +2 -0
  72. package/src/utils/resolve-device.ts +2 -0
  73. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  74. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  75. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  76. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  77. package/test/fixtures/debugger-app/README.md +13 -0
  78. package/test/fixtures/debugger-app/build.ts +29 -0
  79. package/test/helpers/perfetto.ts +59 -0
  80. package/test/unit/debugger/guards.test.ts +46 -0
  81. package/test/unit/debugger/native.test.ts +33 -0
  82. package/test/unit/debugger/sessions.test.ts +83 -0
  83. package/test/unit/observe/readiness.test.ts +3 -1
  84. package/test/unit/observe/system-trace-android.test.ts +52 -0
  85. package/test/unit/observe/system-trace.test.ts +166 -0
  86. package/test/unit/server/system-trace.test.ts +64 -0
  87. package/worker/DebuggerWorker.java +160 -0
@@ -0,0 +1,165 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { readFile, mkdtemp, chmod, rm } from 'node:fs/promises';
5
+ import { createHash } from 'node:crypto';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { detectJavaHome } from '../utils/java.js';
10
+ const exec = promisify(execFile);
11
+ const operations = new AsyncLocalStorage();
12
+ export function withDebuggerBudget(timeout, signal, operation) {
13
+ return operations.run({ deadline: performance.now() + timeout, signal }, operation);
14
+ }
15
+ export function debuggerRemaining(fallback) {
16
+ const context = operations.getStore();
17
+ if (context?.signal?.aborted)
18
+ throw new DebuggerError('DEBUGGER_CANCELLED');
19
+ const remaining = Math.floor((context?.deadline ?? Infinity) - performance.now());
20
+ if (remaining <= 0)
21
+ throw new DebuggerError('DEBUGGER_TIMEOUT');
22
+ return Math.min(fallback, remaining);
23
+ }
24
+ export function debuggerSignal() { return operations.getStore()?.signal; }
25
+ export function nativeError(error, fallback) {
26
+ if (error instanceof DebuggerError)
27
+ return error;
28
+ if (debuggerSignal()?.aborted)
29
+ return new DebuggerError('DEBUGGER_CANCELLED');
30
+ if (error?.killed)
31
+ return new DebuggerError('DEBUGGER_TIMEOUT');
32
+ return new DebuggerError(fallback);
33
+ }
34
+ export class DebuggerError extends Error {
35
+ code;
36
+ constructor(code) {
37
+ super(code);
38
+ this.code = code;
39
+ }
40
+ }
41
+ export const workerSource = fileURLToPath(new URL('../../worker/DebuggerWorker.java', import.meta.url));
42
+ let compiled;
43
+ export async function prepareWorker() {
44
+ compiled ??= (async () => {
45
+ const home = await detectJavaHome({ quiet: true });
46
+ const java = home ? path.join(home, 'bin', 'java') : 'java';
47
+ const javac = home ? path.join(home, 'bin', 'javac') : 'javac';
48
+ const version = await exec(java, ['-version'], { timeout: debuggerRemaining(5000), signal: debuggerSignal() });
49
+ const major = Number(/version "(\d+)/.exec(version.stderr)?.[1]);
50
+ if (!(major >= 17))
51
+ throw new DebuggerError('DEBUGGER_UNSUPPORTED');
52
+ const source = await readFile(workerSource);
53
+ const key = createHash('sha256').update(source).update(version.stderr).digest('hex').slice(0, 16);
54
+ const directory = await mkdtemp(path.join(os.tmpdir(), `mobile-debug-jdi-${key}-`));
55
+ await chmod(directory, 0o700);
56
+ try {
57
+ await exec(javac, ['--release', '17', '--add-modules', 'jdk.jdi', '-d', directory, workerSource], { timeout: debuggerRemaining(15000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 });
58
+ }
59
+ catch (e) {
60
+ await rm(directory, { recursive: true, force: true });
61
+ throw nativeError(e, 'DEBUGGER_UNSUPPORTED');
62
+ }
63
+ return { java, directory };
64
+ })().catch(error => { compiled = undefined; throw error; });
65
+ return compiled;
66
+ }
67
+ export class JdiWorker {
68
+ child;
69
+ pending = new Map();
70
+ sequence = 0;
71
+ dead = false;
72
+ exited;
73
+ constructor(java, directory, onEvent) {
74
+ this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] });
75
+ let buffer = '';
76
+ const fail = () => {
77
+ if (this.dead)
78
+ return;
79
+ this.dead = true;
80
+ for (const request of this.pending.values()) {
81
+ clearTimeout(request.timer);
82
+ request.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'));
83
+ }
84
+ this.pending.clear();
85
+ onEvent({ event: 'worker_failed' });
86
+ };
87
+ this.exited = new Promise(resolve => { this.child.once('exit', () => { fail(); resolve(); }); this.child.once('error', () => { fail(); resolve(); }); });
88
+ this.child.stdin.on('error', fail);
89
+ this.child.stderr.resume(); // Never copy worker data or VM diagnostics into MCP stdout/logs.
90
+ this.child.stdout.setEncoding('utf8');
91
+ this.child.stdout.on('data', (chunk) => {
92
+ buffer += chunk;
93
+ if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) {
94
+ fail();
95
+ this.child.kill('SIGKILL');
96
+ return;
97
+ }
98
+ let end;
99
+ while ((end = buffer.indexOf('\n')) >= 0) {
100
+ const line = buffer.slice(0, end);
101
+ buffer = buffer.slice(end + 1);
102
+ try {
103
+ if (Buffer.byteLength(line) > 1024 * 1024)
104
+ throw new Error('IPC_LIMIT');
105
+ const msg = JSON.parse(line);
106
+ if (msg.event)
107
+ onEvent(msg);
108
+ else {
109
+ const request = this.pending.get(msg.id);
110
+ if (!request)
111
+ continue;
112
+ clearTimeout(request.timer);
113
+ this.pending.delete(msg.id);
114
+ if (msg.error)
115
+ request.reject(new DebuggerError(msg.error.code ?? 'DEBUGGER_WORKER_FAILED'));
116
+ else
117
+ request.resolve(msg.result);
118
+ }
119
+ }
120
+ catch {
121
+ fail();
122
+ this.child.kill('SIGKILL');
123
+ return;
124
+ }
125
+ }
126
+ });
127
+ }
128
+ call(command, args = {}, timeout = 10000) {
129
+ if (this.dead)
130
+ return Promise.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'));
131
+ try {
132
+ timeout = Math.min(timeout, debuggerRemaining(timeout));
133
+ }
134
+ catch (e) {
135
+ return Promise.reject(e);
136
+ }
137
+ const id = ++this.sequence;
138
+ const message = JSON.stringify({ ...args, command, id }) + '\n';
139
+ if (Buffer.byteLength(message) > 1024 * 1024)
140
+ return Promise.reject(new DebuggerError('INVALID_ARGUMENT'));
141
+ return new Promise((resolve, reject) => {
142
+ const signal = debuggerSignal();
143
+ const abort = () => { this.pending.delete(id); clearTimeout(timer); reject(new DebuggerError('DEBUGGER_CANCELLED')); };
144
+ const done = () => signal?.removeEventListener('abort', abort);
145
+ const timer = setTimeout(() => { this.pending.delete(id); done(); reject(new DebuggerError('DEBUGGER_TIMEOUT')); }, timeout);
146
+ this.pending.set(id, { resolve: value => { done(); resolve(value); }, reject: error => { done(); reject(error); }, timer });
147
+ signal?.addEventListener('abort', abort, { once: true });
148
+ this.child.stdin.write(message);
149
+ });
150
+ }
151
+ async close() {
152
+ this.child.stdin.end();
153
+ const kill = setTimeout(() => this.child.kill('SIGKILL'), 2000);
154
+ try {
155
+ await this.exited;
156
+ }
157
+ finally {
158
+ clearTimeout(kill);
159
+ }
160
+ }
161
+ }
162
+ export async function createWorker(onEvent) {
163
+ const { java, directory } = await prepareWorker();
164
+ return new JdiWorker(java, directory, onEvent);
165
+ }
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js';
1
2
  import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js';
2
3
  import { readinessDelay } from '../observe/readiness-service.js';
3
4
  import { isConditionBlocked } from './conditional-action.js';
@@ -1912,6 +1913,7 @@ export class ToolsInteract {
1912
1913
  }
1913
1914
  }
1914
1915
  catch (e) {
1916
+ checkDebuggerGuard();
1915
1917
  console.error('Error scoring element:', e);
1916
1918
  }
1917
1919
  }
@@ -1921,6 +1923,7 @@ export class ToolsInteract {
1921
1923
  }
1922
1924
  }
1923
1925
  catch (e) {
1926
+ checkDebuggerGuard();
1924
1927
  console.error('Error fetching UI tree:', e);
1925
1928
  }
1926
1929
  if (shouldStop || Date.now() > deadline)
@@ -2023,6 +2026,7 @@ export class ToolsInteract {
2023
2026
  }
2024
2027
  }
2025
2028
  catch (e) {
2029
+ checkDebuggerGuard();
2026
2030
  console.error('Error resolving ancestor:', e);
2027
2031
  }
2028
2032
  if (!best)
@@ -2280,6 +2284,7 @@ export class ToolsInteract {
2280
2284
  }
2281
2285
  }
2282
2286
  catch (e) {
2287
+ checkDebuggerGuard();
2283
2288
  // Deadline expiry does not invalidate the last successfully observed state.
2284
2289
  if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
2285
2290
  throw e;
@@ -2323,6 +2328,7 @@ export class ToolsInteract {
2323
2328
  }
2324
2329
  }
2325
2330
  catch (err) {
2331
+ checkDebuggerGuard();
2326
2332
  if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT')
2327
2333
  throw err;
2328
2334
  if (readinessTracker && isDeviceUnavailable(err))
@@ -2397,12 +2403,14 @@ export class ToolsInteract {
2397
2403
  continue;
2398
2404
  }
2399
2405
  catch (e) {
2406
+ checkDebuggerGuard();
2400
2407
  console.error('Error confirming fingerprint:', e);
2401
2408
  continue;
2402
2409
  }
2403
2410
  }
2404
2411
  }
2405
2412
  catch (e) {
2413
+ checkDebuggerGuard();
2406
2414
  console.error('Error getting screen fingerprint:', e);
2407
2415
  }
2408
2416
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
@@ -2519,6 +2527,7 @@ export class ToolsInteract {
2519
2527
  }
2520
2528
  }
2521
2529
  catch (error) {
2530
+ checkDebuggerGuard();
2522
2531
  if (readinessTracker && !String(error).includes('READINESS_'))
2523
2532
  readinessTracker.unavailable();
2524
2533
  if (readinessTracker && isDeviceUnavailable(error))
@@ -3039,6 +3048,7 @@ export class ToolsInteract {
3039
3048
  }
3040
3049
  }
3041
3050
  catch (err) {
3051
+ checkDebuggerGuard();
3042
3052
  try {
3043
3053
  console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err));
3044
3054
  }
@@ -3093,10 +3103,12 @@ export class ToolsInteract {
3093
3103
  }
3094
3104
  }
3095
3105
  catch (err) {
3106
+ checkDebuggerGuard();
3096
3107
  console.error('waitForUI(ui) find error:', err);
3097
3108
  }
3098
3109
  }
3099
3110
  catch (err) {
3111
+ checkDebuggerGuard();
3100
3112
  console.error('waitForUI(ui) outer error:', err);
3101
3113
  }
3102
3114
  }
@@ -3130,6 +3142,7 @@ export class ToolsInteract {
3130
3142
  }
3131
3143
  }
3132
3144
  catch (err) {
3145
+ checkDebuggerGuard();
3133
3146
  console.error('waitForUI(log) error:', err);
3134
3147
  }
3135
3148
  }
@@ -3158,6 +3171,7 @@ export class ToolsInteract {
3158
3171
  }
3159
3172
  }
3160
3173
  catch (err) {
3174
+ checkDebuggerGuard();
3161
3175
  console.error('waitForUI(screen) error:', err);
3162
3176
  }
3163
3177
  }
@@ -3182,6 +3196,7 @@ export class ToolsInteract {
3182
3196
  }
3183
3197
  }
3184
3198
  catch (err) {
3199
+ checkDebuggerGuard();
3185
3200
  console.error('waitForUI(idle) error:', err);
3186
3201
  }
3187
3202
  }
@@ -3199,6 +3214,7 @@ export class ToolsInteract {
3199
3214
  snapshot = await Obs.ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId });
3200
3215
  }
3201
3216
  catch (err) {
3217
+ checkDebuggerGuard();
3202
3218
  snapshot = { error: err instanceof Error ? err.message : String(err) };
3203
3219
  }
3204
3220
  }
@@ -52,6 +52,13 @@ export function validateActionRef(value) {
52
52
  throw new Error('INVALID_ARGUMENT: invalid actionRef');
53
53
  return { sessionId: ref.sessionId, actionId: ref.actionId };
54
54
  }
55
+ export function evidenceSessionId() { return sessionId; }
56
+ export function currentEvidenceCorrelationId() { return context.getStore()?.correlationId; }
57
+ export function bindSystemTrace(traceId) {
58
+ const current = context.getStore();
59
+ if (current)
60
+ current.systemTraceId = traceId;
61
+ }
55
62
  export function currentEvidenceActionId() { return context.getStore()?.action?.id; }
56
63
  export function bindEvidenceDevice(device) {
57
64
  const current = context.getStore();
@@ -222,7 +229,7 @@ function assemble(current) {
222
229
  dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
223
230
  dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
224
231
  verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
225
- observations, trace_context: { supported: false, trace_id: null, markers: [] }
232
+ observations, trace_context: { supported: !!current.systemTraceId, trace_id: current.systemTraceId ?? null, markers: [] }
226
233
  };
227
234
  }
228
235
  export async function withEvidence(options, operation) {
@@ -231,7 +238,7 @@ export async function withEvidence(options, operation) {
231
238
  const id = randomUUID();
232
239
  const record = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
233
240
  dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null;
234
- const current = { correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
241
+ const current = { systemTraceId: parent?.systemTraceId, correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
235
242
  action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action };
236
243
  if (options.action && record)
237
244
  active.set(id, record);
@@ -1,3 +1,4 @@
1
+ import { pausedReadiness } from '../debugger/guard.js';
1
2
  import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js';
2
3
  import { remainingBudget, runWithBudget } from '../utils/operation-budget.js';
3
4
  import { ToolsObserve } from './index.js';
@@ -32,6 +33,7 @@ export async function getAppReadiness(args, wait = false) {
32
33
  let device = null;
33
34
  let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '');
34
35
  let failureCode;
36
+ let debugPause = null;
35
37
  await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
36
38
  try {
37
39
  device = await resolve(args);
@@ -41,6 +43,11 @@ export async function getAppReadiness(args, wait = false) {
41
43
  let count = 0;
42
44
  while (wait || count < 2) {
43
45
  remainingBudget();
46
+ debugPause = device.platform === 'android' ? pausedReadiness(device.id) : null;
47
+ if (debugPause) {
48
+ failureCode = wait ? 'DEBUGGER_PAUSED' : undefined;
49
+ return;
50
+ }
44
51
  const captureStarted = Date.now();
45
52
  try {
46
53
  const tree = await ToolsObserve.getUITreeHandler({ platform: device.platform, deviceId: device.id });
@@ -78,6 +85,8 @@ export async function getAppReadiness(args, wait = false) {
78
85
  failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE';
79
86
  }
80
87
  });
88
+ if (debugPause)
89
+ return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) };
81
90
  const state = tracker.summary.state;
82
91
  const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown');
83
92
  return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
@@ -1,7 +1,8 @@
1
+ import { pausedReadiness } from '../debugger/guard.js';
1
2
  import { AsyncLocalStorage } from 'node:async_hooks';
2
3
  import { remainingBudget } from '../utils/operation-budget.js';
3
4
  import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js';
4
- const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED', 'DEBUGGER_SIGNAL_UNSUPPORTED'];
5
+ const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED'];
5
6
  const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false };
6
7
  export function isDeviceUnavailable(error) {
7
8
  return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error));
@@ -12,20 +13,21 @@ export class ReadinessTracker {
12
13
  stabilityMs;
13
14
  signature = null;
14
15
  stableSince = 0;
15
- summary = {
16
- state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
17
- sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
18
- evidence: [], limitations: [...baselineLimitations, 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities }
19
- };
16
+ summary;
20
17
  constructor(platform, deviceId, stabilityMs = 500) {
21
18
  this.platform = platform;
22
19
  this.deviceId = deviceId;
23
20
  this.stabilityMs = stabilityMs;
21
+ this.summary = {
22
+ state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
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' }
25
+ };
24
26
  }
25
27
  unavailable(code = 'OBSERVATION_FAILED') {
26
28
  this.signature = null;
27
29
  this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
28
- sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, code],
30
+ sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
29
31
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) };
30
32
  return this.summary;
31
33
  }
@@ -34,6 +36,11 @@ export class ReadinessTracker {
34
36
  return this.summary;
35
37
  }
36
38
  observe(tree, acquisitionStartedAtMs, now = performance.now()) {
39
+ const paused = this.platform === 'android' ? pausedReadiness(this.deviceId) : null;
40
+ if (paused) {
41
+ this.summary = paused;
42
+ return this.summary;
43
+ }
37
44
  if (tree?.error)
38
45
  return this.unavailable('OBSERVATION_FAILED');
39
46
  if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
@@ -60,7 +67,7 @@ export class ReadinessTracker {
60
67
  captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
61
68
  sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
62
69
  evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
63
- limitations: [...baselineLimitations, ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities }
70
+ limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
64
71
  };
65
72
  return this.summary;
66
73
  }
@@ -0,0 +1,135 @@
1
+ import { execCmd } from '../utils/exec.js';
2
+ import { getAdbCmd } from '../utils/android/utils.js';
3
+ export const TRACE_MAX_BYTES = 32 * 1024 * 1024;
4
+ export const TRACE_CATEGORIES = ['sched', 'freq', 'idle', 'am', 'wm', 'gfx', 'view', 'input'];
5
+ // Encode only this fixed preset using the public Perfetto proto field numbers:
6
+ // https://github.com/google/perfetto/tree/main/protos/perfetto/config
7
+ // Avoid --txt (explicitly unstable), arbitrary configuration and extra tooling.
8
+ function varint(n) {
9
+ const bytes = [];
10
+ do {
11
+ const byte = n % 128;
12
+ n = Math.floor(n / 128);
13
+ bytes.push(byte | (n ? 128 : 0));
14
+ } while (n);
15
+ return Buffer.from(bytes);
16
+ }
17
+ const uint = (field, n) => Buffer.concat([varint(field * 8), varint(n)]);
18
+ const message = (field, value) => {
19
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
20
+ return Buffer.concat([varint(field * 8 + 2), varint(bytes.length), bytes]);
21
+ };
22
+ export function traceConfig(duration, categories) {
23
+ const ftrace = Buffer.concat(categories.map(cat => message(2, cat)));
24
+ const source = Buffer.concat([message(1, 'linux.ftrace'), message(100, ftrace)]);
25
+ return Buffer.concat([
26
+ message(1, Buffer.concat([uint(1, 16384), uint(4, 1)])),
27
+ message(2, message(1, source)), uint(3, duration), uint(8, 1), uint(10, TRACE_MAX_BYTES)
28
+ ]);
29
+ }
30
+ const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
31
+ function ownedPath(remote) {
32
+ if (!/^\/data\/misc\/perfetto-traces\/mcp-[0-9a-f-]{36}\.perfetto-trace$/.test(remote))
33
+ throw new Error('Invalid owned trace path');
34
+ }
35
+ export class AndroidTraceNative {
36
+ execute;
37
+ constructor(execute = execCmd) {
38
+ this.execute = execute;
39
+ }
40
+ async command(device, args, timeout = 10000) {
41
+ const result = await this.execute(getAdbCmd(), ['-s', device, ...args], { timeout });
42
+ if (result.exitCode !== 0)
43
+ throw new Error(result.stderr === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : 'ADB_TRACE_COMMAND_FAILED');
44
+ return result.stdout;
45
+ }
46
+ shell(device, script, timeout) {
47
+ return this.command(device, ['shell', 'sh', '-c', quote(script)], timeout);
48
+ }
49
+ async capabilities(deviceId) {
50
+ const checks = {};
51
+ const probe = async (name, fn) => { try {
52
+ checks[name] = await fn();
53
+ }
54
+ catch {
55
+ checks[name] = false;
56
+ } };
57
+ let help = '', categories = '';
58
+ await probe('android_api_29', async () => Number(await this.command(deviceId, ['shell', 'getprop', 'ro.build.version.sdk'])) >= 29);
59
+ await probe('perfetto', async () => { const result = await this.execute(getAdbCmd(), ['-s', deviceId, 'shell', 'perfetto', '--help'], { timeout: 10000 }); help = result.stdout + result.stderr; return [0, 1].includes(result.exitCode) && help.includes('Usage: perfetto') && help.includes('--background-wait') && help.includes('--config'); });
60
+ await probe('tracing_service', async () => { await this.command(deviceId, ['shell', 'perfetto', '--query']); return true; });
61
+ await probe('writable_storage', async () => { await this.shell(deviceId, 'test -w /data/misc/perfetto-traces'); return true; });
62
+ await probe('process_identity', async () => { await this.shell(deviceId, 'command -v pidof >/dev/null && test -r /proc/self/stat && test -r /proc/self/cmdline'); return true; });
63
+ await probe('base64', async () => (await this.shell(deviceId, "printf YQ== | base64 -d")) === 'a');
64
+ await probe('required_categories', async () => {
65
+ categories = await this.command(deviceId, ['shell', 'atrace', '--list_categories']);
66
+ return ['sched', 'input'].every(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories));
67
+ });
68
+ const enabled = TRACE_CATEGORIES.filter(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories));
69
+ return { supported: Object.values(checks).every(Boolean), backend: 'perfetto', device: { platform: 'android', id: deviceId },
70
+ prerequisites: checks, reasons: Object.keys(checks).filter(k => !checks[k]), enabled_sources: enabled,
71
+ unavailable_categories: TRACE_CATEGORIES.filter(c => !enabled.includes(c)), limitations: [
72
+ 'Device-wide scheduling and system annotations may contain sensitive information.',
73
+ 'Host times are not aligned with device trace clocks; source availability varies.',
74
+ 'A server crash can leave a bounded trace file on the device for manual removal.'
75
+ ] };
76
+ }
77
+ async start(device, remote, duration, categories) {
78
+ ownedPath(remote);
79
+ const encoded = traceConfig(duration, categories).toString('base64');
80
+ await this.shell(device, `umask 077; printf %s ${quote(encoded)} | base64 -d | perfetto --background-wait -c - -o ${quote(remote)}`, 35000);
81
+ }
82
+ // Scan only shell-owned readable processes. Exact output argv + executable name
83
+ // binds ownership even if start acknowledgement/PID output was lost.
84
+ async find(device, remote) {
85
+ ownedPath(remote);
86
+ const output = await this.shell(device, `pids=$(pidof perfetto); rc=$?
87
+ [ "$rc" -le 1 ] || exit 1
88
+ for pid in $pids; do
89
+ p=/proc/$pid
90
+ [ -d "$p" ] || continue
91
+ [ -r "$p/cmdline" ] || { [ ! -d "$p" ] && continue; exit 1; }
92
+ cmd=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
93
+ printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || continue
94
+ first=$(printf '%s\\n' "$cmd" | head -n 1)
95
+ case "$first" in perfetto|/system/bin/perfetto) ;; *) continue ;; esac
96
+ stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
97
+ rest=\${stat##*) }
98
+ state=$(printf '%s\\n' "$rest" | awk '{print $1}')
99
+ [ "$state" = Z ] && continue
100
+ start=$(printf '%s\\n' "$rest" | awk '{print $20}')
101
+ printf '%s %s\\n' "\${p##*/}" "$start"
102
+ done`);
103
+ if (!output)
104
+ return null;
105
+ const match = /^(\d+) (\d+)$/.exec(output);
106
+ if (!match)
107
+ throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE');
108
+ return { pid: Number(match[1]), startTime: match[2] };
109
+ }
110
+ async stop(device, remote, owner) {
111
+ ownedPath(remote);
112
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1 || !/^\d+$/.test(owner.startTime))
113
+ throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE');
114
+ // Recheck start time and argv in the same shell immediately before signal.
115
+ await this.shell(device, `p=/proc/${owner.pid}
116
+ [ -d "$p" ] || exit 0
117
+ stat=$(cat "$p/stat") || exit 1
118
+ rest=\${stat##*) }
119
+ [ "$(printf '%s\\n' "$rest" | awk '{print $20}')" = ${quote(owner.startTime)} ] || exit 1
120
+ cmd=$(tr '\\000' '\\n' < "$p/cmdline") || exit 1
121
+ first=$(printf '%s\\n' "$cmd" | head -n 1)
122
+ case "$first" in perfetto|/system/bin/perfetto) ;; *) exit 1 ;; esac
123
+ printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || exit 1
124
+ kill -TERM ${owner.pid}`);
125
+ }
126
+ async pull(device, remote, local) {
127
+ ownedPath(remote);
128
+ // Reject oversized output before initiating transfer as well as afterwards.
129
+ const size = Number(await this.command(device, ['shell', 'stat', '-c', '%s', remote]));
130
+ if (!Number.isSafeInteger(size) || size <= 0 || size > TRACE_MAX_BYTES)
131
+ throw new Error('TRACE_ARTIFACT_SIZE_INVALID');
132
+ await this.command(device, ['pull', remote, local], 30000);
133
+ }
134
+ async remove(device, remote) { ownedPath(remote); await this.command(device, ['shell', 'rm', '-f', remote]); }
135
+ }