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.
- package/.github/workflows/ci.yml +21 -0
- package/README.md +7 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +4 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/index.js +16 -0
- package/dist/observe/evidence.js +9 -2
- package/dist/observe/readiness-service.js +9 -0
- package/dist/observe/readiness.js +15 -8
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +1 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +6 -1
- package/dist/server/tool-handlers.js +203 -96
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +2 -0
- package/dist/utils/resolve-device.js +2 -0
- package/docs/CHANGELOG.md +4 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/tools/TOOLS.md +3 -1
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +5 -2
- package/docs/tools/observe.md +12 -5
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/index.ts +16 -15
- package/src/observe/evidence.ts +9 -2
- package/src/observe/readiness-service.ts +5 -0
- package/src/observe/readiness.ts +12 -6
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +1 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +6 -1
- package/src/server/tool-handlers.ts +92 -7
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/utils/exec.ts +2 -0
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +2 -0
- package/src/utils/resolve-device.ts +2 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/readiness.test.ts +3 -1
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- 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
|
+
}
|
package/dist/interact/index.js
CHANGED
|
@@ -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
|
}
|
package/dist/observe/evidence.js
CHANGED
|
@@ -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:
|
|
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'
|
|
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
|
+
}
|