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.
- package/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +86 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -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 +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +253 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +36 -16
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- package/dist/manage/ios.js +2 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +244 -0
- package/dist/observe/readiness-service.js +1 -1
- package/dist/observe/readiness.js +4 -4
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- package/dist/server/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/tool-handlers.js +19 -9
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server-core.js +1 -1
- package/dist/server.js +4 -4
- package/dist/utils/ios/utils.js +3 -0
- package/docs/CHANGELOG.md +23 -1
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +138 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
- package/docs/tools/TOOLS.md +2 -2
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/system-traces.md +81 -14
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- package/src/manage/ios.ts +2 -0
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +204 -0
- package/src/observe/readiness-service.ts +1 -1
- package/src/observe/readiness.ts +4 -4
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- package/src/server/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/tool-handlers.ts +20 -9
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server-core.ts +1 -1
- package/src/server.ts +3 -3
- package/src/utils/ios/utils.ts +3 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/fixtures/contracts/tool-definitions.json +192 -32
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +88 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -0
- package/test/unit/server/system-trace.test.ts +12 -3
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { atStage, classify, diagnostic } from './diagnostics.js';
|
|
2
2
|
import { registerDebuggerPauseProvider } from './guard.js';
|
|
3
|
+
import { IOSDebugNative } from './ios.js';
|
|
4
|
+
import { createIOSWorker, prepareIOSWorker, iosCapabilities, iosLimitations } from './ios-worker.js';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { AndroidDebugNative } from './android.js';
|
|
5
7
|
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining } from './worker.js';
|
|
6
8
|
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
7
9
|
evaluation: false, mutation: false, coroutines: false, native: false };
|
|
8
|
-
const
|
|
10
|
+
export const androidLimitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
|
|
9
11
|
export function debuggerExpiry(created, activity, pausedAt, now) {
|
|
10
12
|
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
|
|
11
13
|
}
|
|
@@ -13,13 +15,17 @@ export class DebuggerSessions {
|
|
|
13
15
|
native;
|
|
14
16
|
workerFactory;
|
|
15
17
|
prerequisite;
|
|
18
|
+
options;
|
|
16
19
|
sessions = new Map();
|
|
17
20
|
closing = false;
|
|
18
|
-
constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker) {
|
|
21
|
+
constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker, options = {}) {
|
|
19
22
|
this.native = native;
|
|
20
23
|
this.workerFactory = workerFactory;
|
|
21
24
|
this.prerequisite = prerequisite;
|
|
25
|
+
this.options = options;
|
|
22
26
|
}
|
|
27
|
+
get platform() { return this.options.platform ?? 'android'; }
|
|
28
|
+
get workerResource() { return this.platform === 'ios' ? 'lldb_worker' : 'jdi_worker'; }
|
|
23
29
|
prune() {
|
|
24
30
|
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
|
|
25
31
|
for (const s of terminal)
|
|
@@ -42,9 +48,10 @@ export class DebuggerSessions {
|
|
|
42
48
|
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
|
|
43
49
|
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
|
|
44
50
|
return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false, diagnostic: s.cleanup.status === 'failed' ? diagnostic('cleanup', 'operation_failed') : s.diagnostic ?? diagnostic('unknown', 'unknown') } } : {}), debug_session_id: s.id,
|
|
45
|
-
device: { platform:
|
|
46
|
-
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
51
|
+
device: { platform: this.platform, id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
52
|
+
capabilities: this.options.capabilities ?? debuggerCapabilities, limitations: this.options.limitations ?? androidLimitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
47
53
|
}
|
|
54
|
+
owns(id) { return this.sessions.has(id); }
|
|
48
55
|
summary(id) { return this.result(this.get(id)); }
|
|
49
56
|
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
|
|
50
57
|
paused(device) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
|
|
@@ -77,13 +84,13 @@ export class DebuggerSessions {
|
|
|
77
84
|
s.events.shift();
|
|
78
85
|
}
|
|
79
86
|
async capabilities(device, app, pid) {
|
|
80
|
-
const prerequisites = { jdk: false, device: false, process: false };
|
|
87
|
+
const prerequisites = { [this.platform === 'ios' ? 'lldb' : 'jdk']: false, device: false, process: false };
|
|
81
88
|
let candidates = [];
|
|
82
89
|
const reasons = [];
|
|
83
90
|
const diagnostics = [];
|
|
84
91
|
try {
|
|
85
92
|
await atStage('prerequisite', 'DEBUGGER_UNSUPPORTED', () => this.prerequisite());
|
|
86
|
-
prerequisites.jdk = true;
|
|
93
|
+
prerequisites[this.platform === 'ios' ? 'lldb' : 'jdk'] = true;
|
|
87
94
|
}
|
|
88
95
|
catch (e) {
|
|
89
96
|
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
|
|
@@ -106,9 +113,9 @@ export class DebuggerSessions {
|
|
|
106
113
|
reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
|
|
107
114
|
diagnostics.push(classify(e, 'target_resolution', 'DEVICE_UNAVAILABLE').diagnostic ?? diagnostic('target_resolution', 'unknown'));
|
|
108
115
|
}
|
|
109
|
-
return { success: true, debug_session_id: null, device: { platform:
|
|
116
|
+
return { success: true, debug_session_id: null, device: { platform: this.platform, id: device }, target: null, state: null,
|
|
110
117
|
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
111
|
-
...(diagnostics.length ? { diagnostics } : {}), capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
118
|
+
...(diagnostics.length ? { diagnostics } : {}), capabilities: this.options.capabilities ?? debuggerCapabilities, reasons, limitations: this.options.limitations ?? androidLimitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
112
119
|
}
|
|
113
120
|
async attach(device, app, pid, timeout = 10000) {
|
|
114
121
|
this.prune();
|
|
@@ -144,16 +151,20 @@ export class DebuggerSessions {
|
|
|
144
151
|
if (candidates.length > 1)
|
|
145
152
|
throw new DebuggerError('PROCESS_AMBIGUOUS');
|
|
146
153
|
s.target = candidates[0];
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
if (this.platform === 'android') {
|
|
155
|
+
stage = 'forwarding';
|
|
156
|
+
s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target.pid));
|
|
157
|
+
remaining();
|
|
158
|
+
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
|
|
159
|
+
}
|
|
151
160
|
stage = 'worker_start';
|
|
152
161
|
s.worker = await atStage('worker_start', 'DEBUGGER_ATTACH_FAILED', () => this.workerFactory(event => this.event(s, event)));
|
|
153
162
|
remaining();
|
|
154
|
-
s.cleanup.remaining_resources.push(
|
|
163
|
+
s.cleanup.remaining_resources.push(this.workerResource);
|
|
164
|
+
if (this.platform === 'ios')
|
|
165
|
+
await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target));
|
|
155
166
|
stage = 'attach';
|
|
156
|
-
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining()));
|
|
167
|
+
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker.call('attach', { port: s.port, ...(this.platform === 'ios' ? { pid: s.target.pid, executablePath: s.target.executable_path } : {}), timeoutMs: remaining() }, remaining()));
|
|
157
168
|
stage = 'target_resolution';
|
|
158
169
|
await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target));
|
|
159
170
|
remaining();
|
|
@@ -204,7 +215,7 @@ export class DebuggerSessions {
|
|
|
204
215
|
s.worker = undefined;
|
|
205
216
|
}
|
|
206
217
|
catch {
|
|
207
|
-
remaining.push(
|
|
218
|
+
remaining.push(this.workerResource);
|
|
208
219
|
}
|
|
209
220
|
}
|
|
210
221
|
if (s.port !== undefined && s.target) {
|
|
@@ -252,6 +263,8 @@ export class DebuggerSessions {
|
|
|
252
263
|
assertCommandTarget(s, command, args) {
|
|
253
264
|
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
|
|
254
265
|
throw new DebuggerError('DEBUGGER_DISCONNECTED', s.diagnostic ?? diagnostic('command', 'unavailable'));
|
|
266
|
+
if (command === 'set' && (this.platform === 'ios' ? typeof args.sourceFile !== 'string' || args.className !== undefined : typeof args.className !== 'string' || args.sourceFile !== undefined))
|
|
267
|
+
throw new DebuggerError('INVALID_ARGUMENT');
|
|
255
268
|
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
256
269
|
if (args.stopId !== s.stop?.stop_id)
|
|
257
270
|
throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
|
|
@@ -268,6 +281,7 @@ export class DebuggerSessions {
|
|
|
268
281
|
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
|
|
269
282
|
return this.result(s);
|
|
270
283
|
this.assertCommandTarget(s, command, args);
|
|
284
|
+
const previous = { state: s.state, stop: s.stop, pausedAt: s.pausedAt };
|
|
271
285
|
if (command === 'step' || command === 'resume') {
|
|
272
286
|
s.state = 'running';
|
|
273
287
|
s.stop = null;
|
|
@@ -278,6 +292,8 @@ export class DebuggerSessions {
|
|
|
278
292
|
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
|
|
279
293
|
}
|
|
280
294
|
catch (e) {
|
|
295
|
+
if (['step', 'resume'].includes(command) && s.state === 'running' && s.stop === null && e instanceof DebuggerError && ['INVALID_ARGUMENT', 'DEBUGGER_STALE_REFERENCE'].includes(e.code))
|
|
296
|
+
Object.assign(s, previous);
|
|
281
297
|
return this.handleCommandError(s, command, classify(e, 'command', 'DEBUGGER_WORKER_FAILED'));
|
|
282
298
|
}
|
|
283
299
|
}
|
|
@@ -301,4 +317,5 @@ export class DebuggerSessions {
|
|
|
301
317
|
async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)); }
|
|
302
318
|
}
|
|
303
319
|
export const debuggerSessions = new DebuggerSessions();
|
|
304
|
-
|
|
320
|
+
export const iosDebuggerSessions = new DebuggerSessions(new IOSDebugNative(), createIOSWorker, prepareIOSWorker, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations });
|
|
321
|
+
registerDebuggerPauseProvider((device, platform = 'android') => (platform === 'ios' ? iosDebuggerSessions : debuggerSessions).paused(device));
|
package/dist/debugger/worker.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function prepareWorker() {
|
|
|
63
63
|
return compiled;
|
|
64
64
|
}
|
|
65
65
|
function commandStage(command) { return command === 'attach' ? 'attach' : command === 'detach' ? 'cleanup' : 'command'; }
|
|
66
|
-
export class
|
|
66
|
+
export class DebuggerProcess {
|
|
67
67
|
onEvent;
|
|
68
68
|
child;
|
|
69
69
|
pending = new Map();
|
|
@@ -72,9 +72,9 @@ export class JdiWorker {
|
|
|
72
72
|
failure;
|
|
73
73
|
replied = false;
|
|
74
74
|
exited;
|
|
75
|
-
constructor(java, directory, onEvent) {
|
|
75
|
+
constructor(java, directory, onEvent, launch) {
|
|
76
76
|
this.onEvent = onEvent;
|
|
77
|
-
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
77
|
+
this.child = spawn(java, launch?.args ?? ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'], ...(launch ? { env: launch.env } : {}) });
|
|
78
78
|
let buffer = '';
|
|
79
79
|
this.exited = new Promise(resolve => { this.child.once('exit', () => { this.fail('process_exited'); resolve(); }); this.child.once('error', () => { this.fail('spawn_failed'); resolve(); }); });
|
|
80
80
|
this.child.stdin.on('error', () => this.fail('write_failed'));
|
|
@@ -172,5 +172,7 @@ export class JdiWorker {
|
|
|
172
172
|
}
|
|
173
173
|
export async function createWorker(onEvent) {
|
|
174
174
|
const { java, directory } = await prepareWorker();
|
|
175
|
-
return new
|
|
175
|
+
return new DebuggerProcess(java, directory, onEvent);
|
|
176
176
|
}
|
|
177
|
+
// Compatibility export for existing JDI transport callers/tests.
|
|
178
|
+
export { DebuggerProcess as JdiWorker };
|
package/dist/interact/index.js
CHANGED
|
@@ -105,11 +105,21 @@ export class ToolsInteract {
|
|
|
105
105
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
106
106
|
return await interact.tap(x, y, resolved.id, timeoutMs);
|
|
107
107
|
}
|
|
108
|
+
static _getResolvedElement(elementId, platform, deviceId) {
|
|
109
|
+
const resolved = ToolsInteract._resolvedUiElements.get(elementId);
|
|
110
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId))
|
|
111
|
+
return null;
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
static getResolvedElementTarget(elementId, platform, deviceId) {
|
|
115
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId);
|
|
116
|
+
return resolved ? { platform: resolved.platform, deviceId: resolved.deviceId } : null;
|
|
117
|
+
}
|
|
108
118
|
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }) {
|
|
109
119
|
const actionType = 'tap_element';
|
|
110
120
|
const selector = { elementId };
|
|
111
|
-
const resolved = ToolsInteract.
|
|
112
|
-
if (!resolved
|
|
121
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId);
|
|
122
|
+
if (!resolved) {
|
|
113
123
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null);
|
|
114
124
|
}
|
|
115
125
|
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
package/dist/manage/ios.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js';
|
|
1
2
|
import { buildIOS } from './ios/build.js';
|
|
2
3
|
import { evidenceDispatch } from '../observe/evidence.js';
|
|
3
4
|
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
@@ -156,6 +157,7 @@ export class iOSManage {
|
|
|
156
157
|
}
|
|
157
158
|
async function installWithIdb(toInstall, deviceId) {
|
|
158
159
|
await new Promise((resolve, reject) => {
|
|
160
|
+
checkDebuggerGuard(true);
|
|
159
161
|
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', deviceId]);
|
|
160
162
|
let stderr = '';
|
|
161
163
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
@@ -0,0 +1,63 @@
|
|
|
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 } from './process.js';
|
|
6
|
+
export const IOS_TRACE_MAX_BYTES = 256 * 1024 * 1024;
|
|
7
|
+
export async function traceSize(root, secure = false) {
|
|
8
|
+
const stat = await fs.lstat(root);
|
|
9
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory()))
|
|
10
|
+
throw new Error('UNSAFE_ARTIFACT');
|
|
11
|
+
if (secure)
|
|
12
|
+
await fs.chmod(root, stat.isDirectory() ? 0o700 : 0o600);
|
|
13
|
+
if (stat.isFile())
|
|
14
|
+
return stat.size;
|
|
15
|
+
let size = 0;
|
|
16
|
+
for (const name of await fs.readdir(root)) {
|
|
17
|
+
size += await traceSize(path.join(root, name), secure);
|
|
18
|
+
if (size > IOS_TRACE_MAX_BYTES)
|
|
19
|
+
throw new Error('STORAGE_LIMIT');
|
|
20
|
+
}
|
|
21
|
+
return size;
|
|
22
|
+
}
|
|
23
|
+
export function validateToc(xml, device, app) {
|
|
24
|
+
if (XMLValidator.validate(xml) !== true)
|
|
25
|
+
throw new Error('INVALID_TOC');
|
|
26
|
+
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
|
|
27
|
+
const raw = parsed['trace-toc']?.run;
|
|
28
|
+
if (!raw || Array.isArray(raw))
|
|
29
|
+
throw new Error('INVALID_TOC');
|
|
30
|
+
const target = raw.info?.target;
|
|
31
|
+
if (target?.device?.['@_uuid'] !== device || Number(target.process?.['@_pid']) !== app.pid || raw.info?.summary?.['template-name'] !== 'Time Profiler')
|
|
32
|
+
throw new Error('ARTIFACT_TARGET_MISMATCH');
|
|
33
|
+
const processes = raw.processes?.process;
|
|
34
|
+
if (!(Array.isArray(processes) ? processes : [processes]).some(p => Number(p?.['@_pid']) === app.pid && p?.['@_path'] === app.executable))
|
|
35
|
+
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'))
|
|
38
|
+
throw new Error('SAMPLE_SCHEMA_MISSING');
|
|
39
|
+
}
|
|
40
|
+
export async function validateArtifact(file, device, app, spawn = spawnTrace) {
|
|
41
|
+
const size = await traceSize(file, true);
|
|
42
|
+
if (size <= 0 || size > IOS_TRACE_MAX_BYTES)
|
|
43
|
+
throw new Error('STORAGE_LIMIT');
|
|
44
|
+
const toc = path.join(path.dirname(file), 'toc.xml'), samples = path.join(path.dirname(file), 'samples.xml');
|
|
45
|
+
try {
|
|
46
|
+
await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--toc', '--output', toc], 15000, spawn);
|
|
47
|
+
if ((await fs.stat(toc)).size > 1024 * 1024)
|
|
48
|
+
throw new Error('INVALID_TOC');
|
|
49
|
+
validateToc(await fs.readFile(toc, 'utf8'), device, app);
|
|
50
|
+
await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--xpath', '/trace-toc/run[@number="1"]/data/table[@schema="time-sample"]', '--output', samples], 15000, spawn);
|
|
51
|
+
if ((await fs.stat(samples)).size > IOS_TRACE_MAX_BYTES)
|
|
52
|
+
throw new Error('STORAGE_LIMIT');
|
|
53
|
+
const xml = await fs.readFile(samples, 'utf8');
|
|
54
|
+
if (XMLValidator.validate(xml) !== true || !xml.includes('<trace-query-result>'))
|
|
55
|
+
throw new Error('INVALID_SAMPLES');
|
|
56
|
+
const sample_rows = (xml.match(/<row(?:\s|>)/g) ?? []).length;
|
|
57
|
+
return { path: file, format: 'instruments', kind: (await fs.stat(file)).isDirectory() ? 'directory' : 'file',
|
|
58
|
+
size_bytes: size, validation: { readable: true, schema: 'time-sample', sample_rows } };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await Promise.all([toc, samples].map(p => fs.rm(p, { force: true })));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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, spawnTrace } from './process.js';
|
|
6
|
+
import { prepareNotification } from './notification.js';
|
|
7
|
+
export const IOS_TEMPLATES = ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'];
|
|
8
|
+
export async function resolveIosApp(device, appId, spawn = spawnTrace) {
|
|
9
|
+
const run = (args) => traceCommand(getXcrunCmd(), args, 10000, spawn);
|
|
10
|
+
const container = await run(['simctl', 'get_app_container', device, appId, 'app']);
|
|
11
|
+
if (!path.isAbsolute(container) || !container.endsWith('.app'))
|
|
12
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
13
|
+
const output = await run(['simctl', 'spawn', device, 'launchctl', 'list']);
|
|
14
|
+
const ids = output.split('\n').filter(line => line.includes(`UIKitApplication:${appId}[`))
|
|
15
|
+
.map(line => Number(line.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1);
|
|
16
|
+
if (ids.length !== 1)
|
|
17
|
+
throw new Error('APP_NOT_RUNNING');
|
|
18
|
+
const executable = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'comm='], 10000, spawn);
|
|
19
|
+
if (!executable.startsWith(container + '/') || executable.includes('\n'))
|
|
20
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
21
|
+
const started_at = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'lstart='], 10000, spawn);
|
|
22
|
+
if (!started_at || started_at.includes('\n'))
|
|
23
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
24
|
+
return { app_id: appId, pid: ids[0], executable, started_at };
|
|
25
|
+
}
|
|
26
|
+
export async function iosCapabilities(device, appId, template = 'Time Profiler', spawn = spawnTrace, host = process.platform) {
|
|
27
|
+
const result = {
|
|
28
|
+
supported: false, backend: 'xctrace', device: null, xcode_version: null,
|
|
29
|
+
prerequisites: { host: host === 'darwin' ? 'available' : 'missing', permissions: 'unknown', symbols: 'unknown' },
|
|
30
|
+
reasons: [], recovery: [], discovered_templates: [], templates: [], app: null,
|
|
31
|
+
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.']
|
|
32
|
+
};
|
|
33
|
+
if (host !== 'darwin') {
|
|
34
|
+
result.reasons.push('MACOS_REQUIRED');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
const probe = async (key, fn, recovery) => {
|
|
38
|
+
try {
|
|
39
|
+
await fn();
|
|
40
|
+
result.prerequisites[key] = 'available';
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
result.prerequisites[key] = 'missing';
|
|
44
|
+
result.reasons.push(error instanceof Error && ['APP_NOT_RUNNING', 'APP_IDENTITY_UNAVAILABLE'].includes(error.message) ? error.message : key.toUpperCase() + '_UNAVAILABLE');
|
|
45
|
+
result.recovery.push(recovery);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await probe('xcode', async () => { result.xcode_version = await traceCommand(getXcrunCmd(), ['xcodebuild', '-version'], 10000, spawn); }, 'Select a full Xcode installation with xcode-select.');
|
|
49
|
+
await probe('templates', async () => {
|
|
50
|
+
const output = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'templates'], 10000, spawn);
|
|
51
|
+
result.discovered_templates = output.split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('=='));
|
|
52
|
+
}, 'Install/select Xcode with Instruments and run xcrun xctrace list templates.');
|
|
53
|
+
await probe('instruments_targets', async () => {
|
|
54
|
+
const targets = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'devices'], 15000, spawn);
|
|
55
|
+
if (!targets.includes(`(${device})`))
|
|
56
|
+
throw new Error('TARGET_UNAVAILABLE');
|
|
57
|
+
}, 'Check xcrun xctrace list devices; use a simulator visible to Instruments.');
|
|
58
|
+
await probe('target', async () => {
|
|
59
|
+
const data = JSON.parse(await traceCommand(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], 10000, spawn));
|
|
60
|
+
const entries = Object.entries(data.devices).flatMap(([runtime, values]) => values.map(value => ({ ...value, runtime })));
|
|
61
|
+
const target = entries.find(d => d.udid === device);
|
|
62
|
+
if (!target || !target.isAvailable || target.state !== 'Booted')
|
|
63
|
+
throw new Error('TARGET_UNAVAILABLE');
|
|
64
|
+
result.device = { platform: 'ios', id: target.udid, simulator: true, model: target.name, osVersion: target.runtime.split('iOS-')[1]?.replaceAll('-', '.') ?? target.runtime };
|
|
65
|
+
}, 'Use an explicit booted simulator UDID. Physical devices and the Mac host are unsupported.');
|
|
66
|
+
await probe('notification_helper', async () => {
|
|
67
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-trace-probe-'));
|
|
68
|
+
try {
|
|
69
|
+
await prepareNotification(directory, spawn);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}, 'Allow Xcode clang to compile the local Darwin-notification listener.');
|
|
75
|
+
if (appId && result.device)
|
|
76
|
+
await probe('app', async () => { result.app = await resolveIosApp(device, appId, spawn); }, 'Start the requested development app on the selected simulator before tracing.');
|
|
77
|
+
result.templates = IOS_TEMPLATES.map(name => ({ name,
|
|
78
|
+
supported: name === 'Time Profiler' && result.discovered_templates.includes(name) && result.device !== null,
|
|
79
|
+
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',
|
|
80
|
+
reason: name === 'Time Profiler' ? null : 'TEMPLATE_TARGET_UNSUPPORTED'
|
|
81
|
+
}));
|
|
82
|
+
if (!result.templates.find(t => t.name === template)?.supported)
|
|
83
|
+
result.reasons.push('TEMPLATE_TARGET_UNSUPPORTED');
|
|
84
|
+
result.supported = result.reasons.length === 0;
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 } from './capabilities.js';
|
|
6
|
+
import { prepareNotification, listenNotification } from './notification.js';
|
|
7
|
+
import { spawnTrace, stopTraceChild, classifyTraceFailure } from './process.js';
|
|
8
|
+
import { traceSize, validateArtifact, IOS_TRACE_MAX_BYTES } from './artifact.js';
|
|
9
|
+
export class IosTraceNative {
|
|
10
|
+
spawn;
|
|
11
|
+
children = new Set();
|
|
12
|
+
recorders = new Set();
|
|
13
|
+
closing = false;
|
|
14
|
+
constructor(spawn = spawnTrace) {
|
|
15
|
+
this.spawn = (command, args, timeout) => {
|
|
16
|
+
if (this.closing)
|
|
17
|
+
throw new Error('TRACE_BUSY');
|
|
18
|
+
const child = spawn(command, args, timeout);
|
|
19
|
+
this.children.add(child);
|
|
20
|
+
if (args.includes('xctrace') && args.includes('record'))
|
|
21
|
+
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)
|
|
29
|
+
child.signal('SIGINT');
|
|
30
|
+
const helpers = setTimeout(() => { for (const child of this.children)
|
|
31
|
+
if (!this.recorders.has(child))
|
|
32
|
+
child.signal('SIGKILL'); }, 1000);
|
|
33
|
+
const recorders = setTimeout(() => { for (const child of this.recorders)
|
|
34
|
+
child.signal('SIGKILL'); }, 8000);
|
|
35
|
+
helpers.unref();
|
|
36
|
+
recorders.unref();
|
|
37
|
+
}
|
|
38
|
+
capabilities(device, appId, template) { return iosCapabilities(device, appId, template, this.spawn); }
|
|
39
|
+
resolve(device, app) { return resolveIosApp(device, app, this.spawn); }
|
|
40
|
+
async storage(directory) {
|
|
41
|
+
const stat = await fs.statfs(directory);
|
|
42
|
+
if (stat.bavail * stat.bsize < 1024 * 1024 * 1024)
|
|
43
|
+
throw new Error('STORAGE_LIMIT');
|
|
44
|
+
}
|
|
45
|
+
async start(device, app, directory, duration, own) {
|
|
46
|
+
await this.storage(directory);
|
|
47
|
+
const binary = await prepareNotification(directory, this.spawn);
|
|
48
|
+
return this.startRecorder(device, app, directory, duration, binary, own);
|
|
49
|
+
}
|
|
50
|
+
async startRecorder(device, app, directory, duration, binary, own) {
|
|
51
|
+
const name = 'tools.mobiledebug.trace.' + randomUUID();
|
|
52
|
+
const listener = await listenNotification(binary, name, this.spawn);
|
|
53
|
+
let recording;
|
|
54
|
+
try {
|
|
55
|
+
const current = await this.resolve(device, app.app_id);
|
|
56
|
+
if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at)
|
|
57
|
+
throw new Error('APP_IDENTITY_CHANGED');
|
|
58
|
+
const file = path.join(directory, 'capture.trace');
|
|
59
|
+
const recorder = this.spawn(getXcrunCmd(), ['xctrace', 'record', '--template', 'Time Profiler', '--device', device,
|
|
60
|
+
'--attach', String(app.pid), '--time-limit', `${duration}ms`, '--output', file, '--notify-tracing-started', name], duration + 65000);
|
|
61
|
+
recording = { recorder, file, ready_at_ms: 0 };
|
|
62
|
+
own(recording);
|
|
63
|
+
await Promise.race([listener.line('READY', 35000), recorder.done.then(result => { throw new Error(classifyTraceFailure(result)); })]);
|
|
64
|
+
if (!recorder.alive())
|
|
65
|
+
throw new Error('RECORDER_EXITED');
|
|
66
|
+
recording.ready_at_ms = Date.now();
|
|
67
|
+
return recording;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (recording)
|
|
71
|
+
await stopTraceChild(recording.recorder).catch(() => { });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await stopTraceChild(listener, 1000).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stop(recording, timeout) { return stopTraceChild(recording.recorder, timeout); }
|
|
79
|
+
async monitor(device, app, recording) {
|
|
80
|
+
const current = await this.resolve(device, app.app_id);
|
|
81
|
+
if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at)
|
|
82
|
+
throw new Error('APP_IDENTITY_CHANGED');
|
|
83
|
+
try {
|
|
84
|
+
if (await traceSize(recording.file) > IOS_TRACE_MAX_BYTES)
|
|
85
|
+
throw new Error('STORAGE_LIMIT');
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
validate(recording, device, app) { return validateArtifact(recording.file, device, app, this.spawn); }
|
|
93
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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 } from './process.js';
|
|
5
|
+
// A private per-session listener avoids requiring a third-party notification utility.
|
|
6
|
+
const source = `#include <notify.h>
|
|
7
|
+
#include <dispatch/dispatch.h>
|
|
8
|
+
#include <stdio.h>
|
|
9
|
+
#include <stdlib.h>
|
|
10
|
+
int main(int argc, char **argv) {
|
|
11
|
+
if (argc != 2) return 2;
|
|
12
|
+
int token;
|
|
13
|
+
if (notify_register_dispatch(argv[1], &token, dispatch_get_main_queue(), ^(int t) {
|
|
14
|
+
puts("READY"); fflush(stdout); notify_cancel(t); exit(0);
|
|
15
|
+
}) != NOTIFY_STATUS_OK) return 3;
|
|
16
|
+
puts("ARMED"); fflush(stdout); dispatch_main();
|
|
17
|
+
}`;
|
|
18
|
+
export async function prepareNotification(directory, spawn = spawnTrace) {
|
|
19
|
+
const file = path.join(directory, 'notification.c'), binary = path.join(directory, 'notification');
|
|
20
|
+
await fs.writeFile(file, source, { mode: 0o600 });
|
|
21
|
+
await traceCommand(getXcrunCmd(), ['clang', '-fblocks', file, '-o', binary], 30000, spawn);
|
|
22
|
+
await fs.chmod(binary, 0o700);
|
|
23
|
+
return binary;
|
|
24
|
+
}
|
|
25
|
+
export async function listenNotification(binary, name, spawn = spawnTrace) {
|
|
26
|
+
const listener = spawn(binary, [name], 40000);
|
|
27
|
+
try {
|
|
28
|
+
await listener.line('ARMED', 5000);
|
|
29
|
+
return listener;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await stopTraceChild(listener, 1000).catch(() => { });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
3
|
+
export const spawnTrace = (command, args, timeout) => {
|
|
4
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
5
|
+
let output = '', settled = false, timedOut = false;
|
|
6
|
+
const waiters = new Set();
|
|
7
|
+
let force;
|
|
8
|
+
const terminate = () => {
|
|
9
|
+
if (settled)
|
|
10
|
+
return;
|
|
11
|
+
child.kill('SIGTERM');
|
|
12
|
+
force ??= setTimeout(() => { if (!settled)
|
|
13
|
+
child.kill('SIGKILL'); }, 1000);
|
|
14
|
+
force.unref();
|
|
15
|
+
};
|
|
16
|
+
const timer = setTimeout(() => { timedOut = true; terminate(); }, timeout);
|
|
17
|
+
timer.unref();
|
|
18
|
+
const done = new Promise(resolve => {
|
|
19
|
+
const finish = (code) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
if (force)
|
|
25
|
+
clearTimeout(force);
|
|
26
|
+
for (const wake of waiters)
|
|
27
|
+
wake();
|
|
28
|
+
resolve({ code, output, timedOut });
|
|
29
|
+
};
|
|
30
|
+
child.on('error', () => finish(null));
|
|
31
|
+
child.on('close', finish);
|
|
32
|
+
const data = (chunk) => {
|
|
33
|
+
output = (output + chunk.toString()).slice(-MAX_OUTPUT);
|
|
34
|
+
for (const wake of waiters)
|
|
35
|
+
wake();
|
|
36
|
+
};
|
|
37
|
+
child.stdout?.on('data', data);
|
|
38
|
+
child.stderr?.on('data', data);
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
done, alive: () => !settled && child.exitCode === null && child.signalCode === null,
|
|
42
|
+
signal: signal => { if (!settled)
|
|
43
|
+
child.kill(signal); },
|
|
44
|
+
line: (text, ms) => new Promise((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)) {
|
|
48
|
+
clearTimeout(deadline);
|
|
49
|
+
waiters.delete(check);
|
|
50
|
+
resolve();
|
|
51
|
+
}
|
|
52
|
+
else if (settled) {
|
|
53
|
+
clearTimeout(deadline);
|
|
54
|
+
waiters.delete(check);
|
|
55
|
+
reject(new Error('RECORDER_EXITED'));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
waiters.add(check);
|
|
59
|
+
check();
|
|
60
|
+
})
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
export async function traceCommand(command, args, timeout = 10000, spawn = spawnTrace) {
|
|
64
|
+
const result = await spawn(command, args, timeout).done;
|
|
65
|
+
if (result.code !== 0 || result.timedOut)
|
|
66
|
+
throw new Error(result.timedOut ? 'ACTION_TIMEOUT' : 'COMMAND_FAILED');
|
|
67
|
+
return result.output.trim();
|
|
68
|
+
}
|
|
69
|
+
export async function stopTraceChild(child, timeout = 30000) {
|
|
70
|
+
if (child.alive())
|
|
71
|
+
child.signal('SIGINT');
|
|
72
|
+
let timer;
|
|
73
|
+
const result = await Promise.race([
|
|
74
|
+
child.done,
|
|
75
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(null), timeout); })
|
|
76
|
+
]);
|
|
77
|
+
if (timer)
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
if (result)
|
|
80
|
+
return result;
|
|
81
|
+
child.signal('SIGKILL');
|
|
82
|
+
let forceTimer;
|
|
83
|
+
await Promise.race([child.done, new Promise(resolve => { forceTimer = setTimeout(resolve, 1000); })]);
|
|
84
|
+
if (forceTimer)
|
|
85
|
+
clearTimeout(forceTimer);
|
|
86
|
+
throw new Error('ACTION_TIMEOUT');
|
|
87
|
+
}
|
|
88
|
+
export function classifyTraceFailure(result) {
|
|
89
|
+
if (result.timedOut)
|
|
90
|
+
return 'ACTION_TIMEOUT';
|
|
91
|
+
if (/permission|not permitted|authoriz|privacy|access denied/i.test(result.output))
|
|
92
|
+
return 'PROFILING_PERMISSION_REQUIRED';
|
|
93
|
+
if (/cannot find process|process.*not.*found/i.test(result.output))
|
|
94
|
+
return 'APP_NOT_RUNNING';
|
|
95
|
+
if (/disconnect|device.*unavailable/i.test(result.output))
|
|
96
|
+
return 'TARGET_DISCONNECTED';
|
|
97
|
+
return 'RECORDER_EXITED';
|
|
98
|
+
}
|