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,166 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { SystemTraceSessions, type TraceClock } from '../../../src/observe/system-trace.js'
|
|
5
|
+
import { TRACE_MAX_BYTES, traceConfig, type TraceNative, type TraceCapabilities, type TraceProcess } from '../../../src/observe/system-trace-android.js'
|
|
6
|
+
|
|
7
|
+
function fixture() {
|
|
8
|
+
let now = 1000000
|
|
9
|
+
let failStart = '', failStop = '', failPull = '', failRemove = '', failFind = ''
|
|
10
|
+
let supported = true, empty = false, oversized = false, stuck = false
|
|
11
|
+
let starts = 0, stops = 0, pulls = 0, removes = 0
|
|
12
|
+
const processes = new Map<string, TraceProcess>()
|
|
13
|
+
const pending = new Map<any, () => void>()
|
|
14
|
+
const time: TraceClock = { wall: () => now, mono: () => now, sleep: async ms => { now += ms },
|
|
15
|
+
schedule(fn) { const id = {} as any; pending.set(id, fn); return id }, clear: id => { pending.delete(id) } }
|
|
16
|
+
const native: TraceNative = {
|
|
17
|
+
async capabilities(device) { return { supported, backend: 'perfetto', device: { platform: 'android', id: device }, prerequisites: {}, reasons: [], enabled_sources: ['sched', 'input'], unavailable_categories: [], limitations: [] } as TraceCapabilities },
|
|
18
|
+
async start(_d, remote) { starts++; processes.set(remote, { pid: starts + 100, startTime: String(starts) }); if (failStart) throw new Error(failStart) },
|
|
19
|
+
async find(_d, remote) { if (failFind) throw new Error(failFind); return processes.get(remote) ?? null },
|
|
20
|
+
async stop(_d, remote) { stops++; if (failStop) throw new Error(failStop); if (!stuck) processes.delete(remote) },
|
|
21
|
+
async pull(_d, _r, local) { pulls++; await fs.writeFile(local, empty ? '' : 'perfetto-fixture'); if (oversized) await fs.truncate(local, TRACE_MAX_BYTES + 1); if (failPull) throw new Error(failPull) },
|
|
22
|
+
async remove() { removes++; if (failRemove) throw new Error(failRemove) }
|
|
23
|
+
}
|
|
24
|
+
const sessions = new SystemTraceSessions(native, time)
|
|
25
|
+
const files: string[] = []
|
|
26
|
+
return { sessions, native, time, processes, pending, files,
|
|
27
|
+
tick(ms: number) { now += ms }, get counts() { return { starts, stops, pulls, removes } },
|
|
28
|
+
flags(v: { start?: string; stop?: string; pull?: string; remove?: string; find?: string; supported?: boolean; empty?: boolean; oversized?: boolean; stuck?: boolean }) {
|
|
29
|
+
failStart = v.start ?? ''; failStop = v.stop ?? ''; failPull = v.pull ?? ''; failRemove = v.remove ?? ''; failFind = v.find ?? ''
|
|
30
|
+
supported = v.supported ?? true; empty = v.empty ?? false; oversized = v.oversized ?? false; stuck = v.stuck ?? false
|
|
31
|
+
},
|
|
32
|
+
async cleanup() { await sessions.shutdown(); await Promise.all(files.map(file => fs.rm(path.dirname(file), { recursive: true, force: true }))) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function run() {
|
|
36
|
+
const f = fixture()
|
|
37
|
+
try {
|
|
38
|
+
const a = await f.sessions.start('a', 1001)
|
|
39
|
+
assert.equal(a.state, 'recording')
|
|
40
|
+
assert.equal(a.capture_completed_at_ms, null)
|
|
41
|
+
await assert.rejects(() => f.sessions.start('a'), /TRACE_BUSY/)
|
|
42
|
+
const b = await f.sessions.start('b')
|
|
43
|
+
const end = await f.sessions.link(a.trace_id, { platform: 'android', id: 'a' }, 'journey-a')
|
|
44
|
+
await assert.rejects(() => f.sessions.link(a.trace_id, { platform: 'android', id: 'b' }, 'wrong'), /TRACE_NOT_FOUND/)
|
|
45
|
+
await assert.rejects(() => f.sessions.link(a.trace_id, { platform: 'ios', id: 'a' }, 'wrong'), /TRACE_NOT_FOUND/)
|
|
46
|
+
const [stop, again] = await Promise.all([f.sessions.control(a.trace_id, 'stop'), f.sessions.control(a.trace_id, 'stop')])
|
|
47
|
+
assert.equal(stop.state, 'completed'); assert.deepEqual(stop, again)
|
|
48
|
+
f.files.push(stop.artifact!.path)
|
|
49
|
+
assert.equal((await end()).coverage, 'partial') // another call stopped capture during the linked journey
|
|
50
|
+
assert.deepEqual(stop.correlation_ids, ['journey-a'])
|
|
51
|
+
assert.equal((await fs.stat(stop.artifact!.path)).mode & 0o777, 0o600)
|
|
52
|
+
assert.equal((await f.sessions.control(a.trace_id, 'cancel')).artifact?.path, stop.artifact?.path)
|
|
53
|
+
assert.equal(f.counts.pulls, 1)
|
|
54
|
+
assert.equal((await f.sessions.control(b.trace_id, 'cancel')).state, 'cancelled')
|
|
55
|
+
assert.equal(f.counts.pulls, 1)
|
|
56
|
+
const expired = await f.sessions.start('c', 1000)
|
|
57
|
+
f.tick(1001)
|
|
58
|
+
const duration = await f.sessions.control(expired.trace_id, 'status')
|
|
59
|
+
assert.equal(duration.completion_reason, 'duration_limit'); f.files.push(duration.artifact!.path)
|
|
60
|
+
f.tick(600001)
|
|
61
|
+
await assert.rejects(() => f.sessions.control(a.trace_id, 'status'), /TRACE_NOT_FOUND/)
|
|
62
|
+
await assert.rejects(() => f.sessions.control('previous-server', 'stop'), /TRACE_NOT_FOUND/)
|
|
63
|
+
} finally { await f.cleanup() }
|
|
64
|
+
|
|
65
|
+
for (const failure of ['start', 'stop', 'pull', 'remove', 'find', 'empty', 'oversized'] as const) {
|
|
66
|
+
const f = fixture()
|
|
67
|
+
try {
|
|
68
|
+
if (failure === 'start') f.flags({ start: 'ACTION_TIMEOUT' })
|
|
69
|
+
const a = await f.sessions.start('a')
|
|
70
|
+
if (failure === 'start') {
|
|
71
|
+
assert.equal(a.reason, 'ACTION_TIMEOUT'); assert.equal(a.state, 'failed'); assert.equal(a.cleanup.status, 'complete')
|
|
72
|
+
assert.equal(f.processes.size, 0); continue
|
|
73
|
+
}
|
|
74
|
+
f.flags(failure === 'empty' || failure === 'oversized' ? { [failure]: true } : { [failure]: 'ACTION_TIMEOUT' })
|
|
75
|
+
const stop = await f.sessions.control(a.trace_id, 'stop')
|
|
76
|
+
assert.equal(stop.state, 'failed', failure); assert.equal(stop.cleanup.status, 'failed', failure)
|
|
77
|
+
if (stop.artifact) f.files.push(stop.artifact.path)
|
|
78
|
+
if (failure === 'pull' || failure === 'empty' || failure === 'oversized') assert.equal(stop.artifact, null)
|
|
79
|
+
f.flags({})
|
|
80
|
+
const retry = await f.sessions.control(a.trace_id, 'stop')
|
|
81
|
+
assert.equal(retry.state, 'completed', failure); assert.equal(retry.cleanup.status, 'complete')
|
|
82
|
+
if (!f.files.includes(retry.artifact!.path)) f.files.push(retry.artifact!.path)
|
|
83
|
+
} finally { await f.cleanup() }
|
|
84
|
+
}
|
|
85
|
+
const reuse = fixture()
|
|
86
|
+
try {
|
|
87
|
+
const a = await reuse.sessions.start('a')
|
|
88
|
+
const [remote, process] = [...reuse.processes.entries()][0]
|
|
89
|
+
reuse.processes.set(remote, { ...process, startTime: '999999' })
|
|
90
|
+
const result = await reuse.sessions.control(a.trace_id, 'stop')
|
|
91
|
+
assert.equal(result.reason, 'TRACE_STOP_FAILED'); assert.equal(reuse.counts.stops, 0)
|
|
92
|
+
reuse.processes.delete(remote)
|
|
93
|
+
assert.equal((await reuse.sessions.control(a.trace_id, 'cancel')).state, 'cancelled')
|
|
94
|
+
} finally { await reuse.cleanup() }
|
|
95
|
+
const cap = fixture()
|
|
96
|
+
try {
|
|
97
|
+
cap.flags({ supported: false })
|
|
98
|
+
assert.equal((await cap.sessions.start('a')).reason, 'TRACE_UNSUPPORTED'); assert.equal(cap.counts.starts, 0)
|
|
99
|
+
cap.flags({})
|
|
100
|
+
await Promise.all(['a', 'b', 'c', 'd'].map(d => cap.sessions.start(d)))
|
|
101
|
+
await assert.rejects(() => cap.sessions.start('e'), /TRACE_BUSY/)
|
|
102
|
+
await cap.sessions.shutdown(); assert.equal(cap.processes.size, 0)
|
|
103
|
+
await assert.rejects(() => cap.sessions.start('e'), /TRACE_BUSY/)
|
|
104
|
+
} finally { await cap.cleanup() }
|
|
105
|
+
const timer = fixture()
|
|
106
|
+
try {
|
|
107
|
+
const a = await timer.sessions.start('timer', 1000)
|
|
108
|
+
timer.tick(1000)
|
|
109
|
+
for (const callback of timer.pending.values()) callback()
|
|
110
|
+
const ended = await timer.sessions.control(a.trace_id, 'status')
|
|
111
|
+
assert.equal(ended.state, 'completed'); assert.equal(ended.completion_reason, 'duration_limit')
|
|
112
|
+
timer.files.push(ended.artifact!.path)
|
|
113
|
+
} finally { await timer.cleanup() }
|
|
114
|
+
const stuck = fixture()
|
|
115
|
+
try {
|
|
116
|
+
const a = await stuck.sessions.start('stuck')
|
|
117
|
+
stuck.flags({ stuck: true })
|
|
118
|
+
const failed = await stuck.sessions.control(a.trace_id, 'stop')
|
|
119
|
+
assert.equal(failed.reason, 'ACTION_TIMEOUT'); assert.equal(failed.artifact, null)
|
|
120
|
+
assert.equal(stuck.counts.pulls, 0)
|
|
121
|
+
stuck.flags({})
|
|
122
|
+
assert.equal((await stuck.sessions.control(a.trace_id, 'cancel')).cleanup.status, 'complete')
|
|
123
|
+
} finally { await stuck.cleanup() }
|
|
124
|
+
const natural = fixture()
|
|
125
|
+
try {
|
|
126
|
+
const a = await natural.sessions.start('natural')
|
|
127
|
+
natural.native.stop = async (_device, remote) => { natural.processes.delete(remote); throw new Error('PROCESS_ALREADY_EXITED') }
|
|
128
|
+
assert.equal((await natural.sessions.control(a.trace_id, 'cancel')).state, 'cancelled')
|
|
129
|
+
} finally { await natural.cleanup() }
|
|
130
|
+
const interrupted = fixture()
|
|
131
|
+
try {
|
|
132
|
+
const a = await interrupted.sessions.start('journey')
|
|
133
|
+
const finish = await interrupted.sessions.link(a.trace_id, { platform: 'android', id: 'journey' }, 'j')
|
|
134
|
+
interrupted.flags({ find: 'DEVICE_DISCONNECTED' })
|
|
135
|
+
assert.equal((await finish()).coverage, 'partial')
|
|
136
|
+
interrupted.flags({})
|
|
137
|
+
} finally { await interrupted.cleanup() }
|
|
138
|
+
const racing = fixture()
|
|
139
|
+
let release!: () => void
|
|
140
|
+
const gate = new Promise<void>(resolve => { release = resolve })
|
|
141
|
+
const originalCapabilities = racing.native.capabilities
|
|
142
|
+
racing.native.capabilities = async device => { await gate; return originalCapabilities(device) }
|
|
143
|
+
const starting = racing.sessions.start('race')
|
|
144
|
+
const closing = racing.sessions.shutdown()
|
|
145
|
+
release()
|
|
146
|
+
assert.equal((await starting).reason, 'TRACE_BUSY')
|
|
147
|
+
assert.deepEqual(await closing, [])
|
|
148
|
+
assert.equal(racing.counts.starts, 0)
|
|
149
|
+
const retention = fixture()
|
|
150
|
+
try {
|
|
151
|
+
const first = await retention.sessions.start('retention')
|
|
152
|
+
await retention.sessions.control(first.trace_id, 'cancel')
|
|
153
|
+
for (let n = 0; n < 100; n++) {
|
|
154
|
+
const next = await retention.sessions.start('retention')
|
|
155
|
+
await retention.sessions.control(next.trace_id, 'cancel')
|
|
156
|
+
}
|
|
157
|
+
await assert.rejects(() => retention.sessions.control(first.trace_id, 'status'), /TRACE_NOT_FOUND/)
|
|
158
|
+
} finally { await retention.cleanup() }
|
|
159
|
+
// Wire bytes decode independently to protect actual proto field numbers/limits.
|
|
160
|
+
const config = traceConfig(1001, ['sched', 'input'])
|
|
161
|
+
assert.ok(config.includes(Buffer.from('linux.ftrace')))
|
|
162
|
+
assert.ok(config.includes(Buffer.from([0x18, 0xe9, 0x07]))) // TraceConfig.duration_ms = 3
|
|
163
|
+
assert.ok(config.includes(Buffer.from([0x50, 0x80, 0x80, 0x80, 0x10]))) // max_file_size_bytes = 10
|
|
164
|
+
console.log('system trace session lifecycle: PASS')
|
|
165
|
+
}
|
|
166
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
|
|
3
|
+
import { systemTraces } from '../../../src/observe/system-trace.js'
|
|
4
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
|
|
5
|
+
import { AndroidManage } from '../../../src/manage/index.js'
|
|
6
|
+
import { withEvidence, bindSystemTrace, bindEvidenceDevice } from '../../../src/observe/evidence.js'
|
|
7
|
+
|
|
8
|
+
const parse = (r: any) => JSON.parse(r.content[0].text)
|
|
9
|
+
async function run() {
|
|
10
|
+
const originalLink = systemTraces.link, originalStart = systemTraces.start, originalControl = systemTraces.control
|
|
11
|
+
const originalApp = AndroidManage.prototype.startApp
|
|
12
|
+
const id = '62a51510-c303-4a1b-af0e-f97cc976d740'
|
|
13
|
+
let dispatched = 0, starts = 0
|
|
14
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [{ id: 'trace-device', platform: 'android', osVersion: '16', simulator: true, model: 'fixture' }] })
|
|
15
|
+
try {
|
|
16
|
+
for (const name of ['get_system_trace_capabilities', 'start_system_trace', 'get_system_trace_status', 'stop_system_trace', 'cancel_system_trace']) {
|
|
17
|
+
const tool = toolDefinitions.find(t => t.name === name)
|
|
18
|
+
assert.ok(tool); assert.equal(tool.inputSchema.additionalProperties, false)
|
|
19
|
+
}
|
|
20
|
+
assert.equal(toolDefinitions.find(t => t.name === 'run_journey').inputSchema.properties.systemTraceId.format, 'uuid')
|
|
21
|
+
for (const args of [ {}, { platform: 'android', extra: true }, { platform: 'android', expectedState: {} }, { platform: 'android', durationMs: 0 }, { platform: 'android', durationMs: 1000.5 }, { platform: 'android', durationMs: 120001 } ]) {
|
|
22
|
+
const r = await handleToolCall('start_system_trace', args)
|
|
23
|
+
assert.equal(r.isError, true); assert.equal(parse(r).error.code, 'INVALID_ARGUMENT')
|
|
24
|
+
}
|
|
25
|
+
systemTraces.start = async () => { starts++; throw new Error('must not run') }
|
|
26
|
+
const ios = await handleToolCall('start_system_trace', { platform: 'ios' })
|
|
27
|
+
assert.equal(parse(ios).error.code, 'UNSUPPORTED_PLATFORM'); assert.equal(starts, 0)
|
|
28
|
+
assert.equal(parse(await handleToolCall('get_system_trace_capabilities', { platform: 'ios' })).supported, false)
|
|
29
|
+
for (const name of ['stop_system_trace', 'cancel_system_trace', 'get_system_trace_status']) {
|
|
30
|
+
assert.equal(parse(await handleToolCall(name, { traceId: id, deviceId: 'wrong' })).error.code, 'INVALID_ARGUMENT')
|
|
31
|
+
assert.equal(parse(await handleToolCall(name, { traceId: id })).error.code, 'TRACE_NOT_FOUND')
|
|
32
|
+
}
|
|
33
|
+
AndroidManage.prototype.startApp = async () => { dispatched++; return { appStarted: true } as any }
|
|
34
|
+
const journey = { platform: 'android', deviceId: 'trace-device', systemTraceId: id, steps: [{ id: 'start', type: 'start_app', appId: 'com.android.settings', verificationMode: 'none' }] }
|
|
35
|
+
const rejected = await handleToolCall('run_journey', journey)
|
|
36
|
+
assert.equal(rejected.isError, true); assert.equal(dispatched, 0)
|
|
37
|
+
let correlation = ''
|
|
38
|
+
systemTraces.link = async (trace, device, cid) => {
|
|
39
|
+
assert.equal(trace, id); assert.equal(device.id, 'trace-device'); correlation = cid
|
|
40
|
+
return async () => ({ trace_id: trace, coverage: 'covered', clock_alignment: 'unknown' })
|
|
41
|
+
}
|
|
42
|
+
const result = parse(await handleToolCall('run_journey', journey))
|
|
43
|
+
assert.equal(result.success, true, JSON.stringify(result)); assert.equal(dispatched, 1)
|
|
44
|
+
assert.equal(result.evidence.trace_context.trace_id, id)
|
|
45
|
+
assert.equal(result.evidence.trace_context.supported, true)
|
|
46
|
+
assert.equal(result.evidence.correlation_id, correlation)
|
|
47
|
+
assert.equal(result.steps[0].result.evidence.trace_context.trace_id, id)
|
|
48
|
+
assert.equal(result.steps[0].result.evidence.correlation_id, correlation)
|
|
49
|
+
assert.equal(result.system_trace.coverage, 'covered')
|
|
50
|
+
const plain = parse(await handleToolCall('run_journey', { ...journey, systemTraceId: undefined }))
|
|
51
|
+
assert.deepEqual(plain.evidence.trace_context, { supported: false, trace_id: null, markers: [] })
|
|
52
|
+
const [linked, independent] = await Promise.all([
|
|
53
|
+
withEvidence({ journey: true }, async () => { bindSystemTrace(id); bindEvidenceDevice({ platform: 'android', id: 'trace-device' }); return withEvidence({ action: true }, async () => {}) }),
|
|
54
|
+
withEvidence({ journey: true }, async () => {})
|
|
55
|
+
])
|
|
56
|
+
assert.equal(linked.result.evidence.trace_context.trace_id, id)
|
|
57
|
+
assert.equal(independent.evidence.trace_context.trace_id, null)
|
|
58
|
+
console.log('system trace tool contracts and journey evidence: PASS')
|
|
59
|
+
} finally {
|
|
60
|
+
systemTraces.link = originalLink; systemTraces.start = originalStart; systemTraces.control = originalControl
|
|
61
|
+
AndroidManage.prototype.startApp = originalApp; _resetDeviceListersForTests()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import com.sun.jdi.*;
|
|
2
|
+
import com.sun.jdi.connect.*;
|
|
3
|
+
import com.sun.jdi.event.*;
|
|
4
|
+
import com.sun.jdi.request.*;
|
|
5
|
+
import java.io.*;
|
|
6
|
+
import java.nio.charset.StandardCharsets;
|
|
7
|
+
import java.util.*;
|
|
8
|
+
import java.util.concurrent.*;
|
|
9
|
+
|
|
10
|
+
/** Standalone JDI bridge. No debuggee method invocation, evaluation or mutation. */
|
|
11
|
+
public final class DebuggerWorker {
|
|
12
|
+
static final int MAX_LINE = 1048576;
|
|
13
|
+
final Object lock = new Object();
|
|
14
|
+
VirtualMachine vm;
|
|
15
|
+
final Map<String, Map<String,Object>> breaks = new LinkedHashMap<>();
|
|
16
|
+
final Map<String, StackFrame> frames = new HashMap<>();
|
|
17
|
+
final Map<String, ThreadReference> threads = new HashMap<>();
|
|
18
|
+
final List<EventSet> held = new ArrayList<>();
|
|
19
|
+
String stopId;
|
|
20
|
+
long attachedAt, pausedAt, activityAt;
|
|
21
|
+
boolean closed;
|
|
22
|
+
StepRequest step;
|
|
23
|
+
final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"debugger-watchdog"); t.setDaemon(true); return t; });
|
|
24
|
+
static Map<String,Object> obj(Object... pairs) { Map<String,Object> m=new LinkedHashMap<>(); for(int i=0;i<pairs.length;i+=2)m.put((String)pairs[i],pairs[i+1]);return m; }
|
|
25
|
+
static synchronized void send(Object o) { System.out.println(Json.write(o)); System.out.flush(); }
|
|
26
|
+
static String str(Map<String,Object> m,String k) { Object v=m.get(k); return v==null?"":v.toString(); }
|
|
27
|
+
static int num(Map<String,Object> m,String k,int d) { Object v=m.get(k);return v instanceof Number?((Number)v).intValue():d; }
|
|
28
|
+
static RuntimeException fail(String code) { return new IllegalStateException(code); }
|
|
29
|
+
void active() { if(vm==null || closed)throw fail("DEBUGGER_DISCONNECTED"); }
|
|
30
|
+
void paused(Map<String,Object> a) { active();if(stopId==null)throw fail("DEBUGGER_NOT_PAUSED");if(!stopId.equals(str(a,"stopId")))throw fail("DEBUGGER_STALE_REFERENCE"); }
|
|
31
|
+
void invalidate() { stopId=null;frames.clear();threads.clear();pausedAt=0; }
|
|
32
|
+
void cancelStep() { if(step!=null){try{vm.eventRequestManager().deleteEventRequest(step);}catch(RuntimeException ignored){}step=null;} }
|
|
33
|
+
void release() { for(EventSet s:held)s.resume();held.clear();invalidate(); }
|
|
34
|
+
Map<String,Object> location(Location l) { String source=null;try{source=l.sourceName();}catch(AbsentInformationException ignored){}return obj("class_name",l.declaringType().name(),"method",l.method().name(),"source",source,"line",l.lineNumber(),"code_index",l.codeIndex(),"class_loader_id",l.declaringType().classLoader()==null?"bootstrap":String.valueOf(l.declaringType().classLoader().uniqueID())); }
|
|
35
|
+
String thread(ThreadReference t) { String id=stopId+":"+t.uniqueID();threads.put(id,t);return id; }
|
|
36
|
+
Map<String,Object> state() {return obj("state",closed?"detached":stopId==null?"running":"paused","stop_id",stopId,"breakpoints",new ArrayList<>(breaks.values()));}
|
|
37
|
+
void bind(Map<String,Object> b,ReferenceType type) {
|
|
38
|
+
try {
|
|
39
|
+
List<Location> ls=type.locationsOfLine(((Number)b.get("line")).intValue());
|
|
40
|
+
if(ls.isEmpty()){ if(!"verified".equals(b.get("status")))b.put("status","rejected"); b.put("reason","DEBUGGER_LOCATION_UNAVAILABLE");return; }
|
|
41
|
+
@SuppressWarnings("unchecked") List<Object> locations=(List<Object>)b.get("locations");
|
|
42
|
+
for(Location l:ls){
|
|
43
|
+
Map<String,Object> loc=location(l); if(locations.contains(loc))continue;
|
|
44
|
+
BreakpointRequest req=vm.eventRequestManager().createBreakpointRequest(l);req.putProperty("id",b.get("breakpoint_id"));req.setSuspendPolicy(EventRequest.SUSPEND_ALL);req.enable();locations.add(loc);
|
|
45
|
+
}
|
|
46
|
+
b.put("status","verified");b.remove("reason");
|
|
47
|
+
} catch(AbsentInformationException e){if(!"verified".equals(b.get("status")))b.put("status","rejected");b.put("reason","DEBUGGER_LOCATION_UNAVAILABLE");}
|
|
48
|
+
}
|
|
49
|
+
void eventLoop() {
|
|
50
|
+
try {
|
|
51
|
+
while(!closed){
|
|
52
|
+
EventSet set=vm.eventQueue().remove(200);
|
|
53
|
+
if(set==null)continue;
|
|
54
|
+
synchronized(lock){
|
|
55
|
+
if(closed)break;
|
|
56
|
+
List<Object> stops=new ArrayList<>();ThreadReference trigger=null;Set<ReferenceType> prepared=new HashSet<>();
|
|
57
|
+
for(Event event:set){
|
|
58
|
+
if(event instanceof VMDeathEvent || event instanceof VMDisconnectEvent){finish("process_exit");return;}
|
|
59
|
+
if(event instanceof ClassPrepareEvent c && prepared.add(c.referenceType())){ for(Map<String,Object>b:breaks.values())if(!"removed".equals(b.get("status")) && b.get("className").equals(c.referenceType().name())){bind(b,c.referenceType());send(obj("event","breakpoint","breakpoint",b));} }
|
|
60
|
+
if(event instanceof BreakpointEvent b){trigger=b.thread();stops.add(obj("reason","breakpoint","location",location(b.location()),"breakpoint_id",b.request().getProperty("id")));}
|
|
61
|
+
if(event instanceof StepEvent s){trigger=s.thread();stops.add(obj("reason","step","location",location(s.location())));}
|
|
62
|
+
}
|
|
63
|
+
if(!stops.isEmpty()){
|
|
64
|
+
cancelStep();held.add(set);
|
|
65
|
+
// Additional simultaneously suspended sets belong to the same epoch.
|
|
66
|
+
if(stopId==null){stopId=UUID.randomUUID().toString();pausedAt=System.nanoTime();}
|
|
67
|
+
send(obj("event","stopped","state","paused","stop_id",stopId,"thread_id",thread(trigger),"stops",stops));
|
|
68
|
+
}else set.resume();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}catch(InterruptedException e){Thread.currentThread().interrupt();}catch(RuntimeException e){synchronized(lock){finish("transport_lost");}}
|
|
72
|
+
}
|
|
73
|
+
void finish(String reason) {
|
|
74
|
+
if(closed)return;
|
|
75
|
+
boolean clean=true;
|
|
76
|
+
try{if(vm!=null){cancelStep();vm.dispose();}}catch(VMDisconnectedException ignored){}catch(RuntimeException e){clean=false;}
|
|
77
|
+
closed=true;held.clear();invalidate();
|
|
78
|
+
send(obj("event","terminated","reason",reason,"cleanup_complete",clean,"execution_state","unknown"));
|
|
79
|
+
}
|
|
80
|
+
Object command(Map<String,Object> a) throws Exception {
|
|
81
|
+
String cmd=str(a,"command");activityAt=System.nanoTime();
|
|
82
|
+
if(cmd.equals("attach")){
|
|
83
|
+
if(vm!=null)throw fail("DEBUGGER_BUSY");
|
|
84
|
+
AttachingConnector c=Bootstrap.virtualMachineManager().attachingConnectors().stream().filter(x->x.name().equals("com.sun.jdi.SocketAttach")).findFirst().orElseThrow();
|
|
85
|
+
Map<String,Connector.Argument> args=c.defaultArguments();args.get("hostname").setValue("127.0.0.1");args.get("port").setValue(str(a,"port"));args.get("timeout").setValue(str(a,"timeoutMs"));
|
|
86
|
+
vm=c.attach(args);attachedAt=System.nanoTime();
|
|
87
|
+
Thread t=new Thread(this::eventLoop,"debugger-events");t.setDaemon(true);t.start();
|
|
88
|
+
return state();
|
|
89
|
+
}
|
|
90
|
+
if(cmd.equals("detach")){finish("requested");return state();}
|
|
91
|
+
active();
|
|
92
|
+
if(cmd.equals("heartbeat"))return obj("alive",true);
|
|
93
|
+
if(cmd.equals("state"))return state();
|
|
94
|
+
if(cmd.equals("set")){
|
|
95
|
+
if(breaks.size()>=100)throw fail("DEBUGGER_BUSY");
|
|
96
|
+
String id=UUID.randomUUID().toString();Map<String,Object>b=obj("breakpoint_id",id,"className",str(a,"className"),"line",num(a,"line",0),"status","pending","locations",new ArrayList<>());
|
|
97
|
+
breaks.put(id,b);
|
|
98
|
+
ClassPrepareRequest req=vm.eventRequestManager().createClassPrepareRequest();req.addClassFilter(str(a,"className"));req.putProperty("id",id);req.setSuspendPolicy(EventRequest.SUSPEND_ALL);req.enable();
|
|
99
|
+
for(ReferenceType type:vm.classesByName(str(a,"className")))bind(b,type);
|
|
100
|
+
return b;
|
|
101
|
+
}
|
|
102
|
+
if(cmd.equals("remove")){
|
|
103
|
+
String id=str(a,"breakpointId");
|
|
104
|
+
if(!breaks.containsKey(id))throw fail("DEBUGGER_NOT_FOUND");
|
|
105
|
+
for(EventRequest r:new ArrayList<EventRequest>(vm.eventRequestManager().breakpointRequests()))if(id.equals(r.getProperty("id")))vm.eventRequestManager().deleteEventRequest(r);
|
|
106
|
+
for(EventRequest r:new ArrayList<EventRequest>(vm.eventRequestManager().classPrepareRequests()))if(id.equals(r.getProperty("id")))vm.eventRequestManager().deleteEventRequest(r);
|
|
107
|
+
breaks.get(id).put("status","removed");return obj("removed",true);
|
|
108
|
+
}
|
|
109
|
+
paused(a);
|
|
110
|
+
if(cmd.equals("stack")){
|
|
111
|
+
ThreadReference t=threads.get(str(a,"threadId"));if(t==null)throw fail("DEBUGGER_STALE_REFERENCE");
|
|
112
|
+
int count=Math.min(t.frameCount(),1000),offset=num(a,"offset",0),end=Math.min(count,offset+num(a,"limit",20));List<Object> out=new ArrayList<>();
|
|
113
|
+
for(int i=offset;i<end;i++){StackFrame f=t.frame(i);String id=stopId+":"+t.uniqueID()+":"+i;if(!frames.containsKey(id)&&frames.size()>=1000)throw fail("DEBUGGER_BUSY");frames.put(id,f);out.add(obj("frame_id",id,"index",i,"location",location(f.location())));}
|
|
114
|
+
return obj("frames",out,"next_offset",end<count?end:null,"truncated",t.frameCount()>1000);
|
|
115
|
+
}
|
|
116
|
+
if(cmd.equals("locals")){
|
|
117
|
+
StackFrame f=frames.get(str(a,"frameId"));if(f==null)throw fail("DEBUGGER_STALE_REFERENCE");List<Object> out=new ArrayList<>();
|
|
118
|
+
try{List<LocalVariable> vars=f.visibleVariables();if(vars.isEmpty())return obj("availability","unknown","reason","EMPTY_OR_UNAVAILABLE_VARIABLE_METADATA","locals",out,"truncated",false);for(LocalVariable v:vars.subList(0,Math.min(vars.size(),100))){Value value=f.getValue(v);Object display=null;boolean truncated=false;
|
|
119
|
+
if(value instanceof StringReference s){String text=s.value();truncated=text.length()>256;display=text.substring(0,Math.min(text.length(),256));}
|
|
120
|
+
else if(value instanceof PrimitiveValue)display=value.toString();
|
|
121
|
+
else if(value instanceof ObjectReference o)display=obj("object_id",String.valueOf(o.uniqueID()),"type",o.referenceType().name());
|
|
122
|
+
out.add(obj("name",v.name(),"type",v.typeName(),"value",display,"truncated",truncated));}
|
|
123
|
+
return obj("availability","available","locals",out,"truncated",vars.size()>100);
|
|
124
|
+
}catch(AbsentInformationException e){return obj("availability","debug_metadata_unavailable","locals",out,"truncated",false);}
|
|
125
|
+
}
|
|
126
|
+
if(cmd.equals("resume") || cmd.equals("step")){
|
|
127
|
+
if(cmd.equals("step")){
|
|
128
|
+
ThreadReference t=threads.get(str(a,"threadId"));if(t==null)throw fail("DEBUGGER_STALE_REFERENCE");cancelStep();
|
|
129
|
+
int depth=switch(str(a,"kind")){case "into"->StepRequest.STEP_INTO;case "over"->StepRequest.STEP_OVER;case "out"->StepRequest.STEP_OUT;default->throw fail("INVALID_ARGUMENT");};
|
|
130
|
+
step=vm.eventRequestManager().createStepRequest(t,StepRequest.STEP_LINE,depth);step.addCountFilter(1);step.setSuspendPolicy(EventRequest.SUSPEND_ALL);step.enable();
|
|
131
|
+
}else cancelStep();
|
|
132
|
+
release();return state();
|
|
133
|
+
}
|
|
134
|
+
throw fail("INVALID_ARGUMENT");
|
|
135
|
+
}
|
|
136
|
+
void run() throws IOException {
|
|
137
|
+
watchdog.scheduleAtFixedRate(()->{synchronized(lock){if(vm==null||closed)return;long now=System.nanoTime();String reason=pausedAt!=0&&now-pausedAt>=TimeUnit.SECONDS.toNanos(60)?"pause_limit":now-activityAt>=TimeUnit.MINUTES.toNanos(5)?"idle_limit":now-attachedAt>=TimeUnit.MINUTES.toNanos(30)?"session_limit":null;if(reason!=null)finish(reason);}},1,1,TimeUnit.SECONDS);
|
|
138
|
+
Runtime.getRuntime().addShutdownHook(new Thread(()->{synchronized(lock){finish("worker_shutdown");}}));
|
|
139
|
+
try(Reader reader=new InputStreamReader(System.in,StandardCharsets.UTF_8)){
|
|
140
|
+
StringBuilder line=new StringBuilder();int ch;
|
|
141
|
+
while((ch=reader.read())!=-1){if(ch!='\n'){line.append((char)ch);if(line.length()>MAX_LINE)throw new IOException("IPC_LIMIT");continue;}
|
|
142
|
+
Object id=null;
|
|
143
|
+
try{@SuppressWarnings("unchecked") Map<String,Object>a=(Map<String,Object>)new Json(line.toString()).value();id=a.get("id");synchronized(lock){Object result=command(a);send(obj("id",id,"result",result));}}
|
|
144
|
+
catch(Exception e){String msg=e.getMessage();String code=msg!=null&&msg.matches("(?:DEBUGGER|INVALID|PROCESS|DEVICE)_[A-Z_]+")?msg:e instanceof VMDisconnectedException?"DEBUGGER_DISCONNECTED":"DEBUGGER_WORKER_FAILED";send(obj("id",id,"error",obj("code",code)));}
|
|
145
|
+
line.setLength(0);
|
|
146
|
+
}
|
|
147
|
+
}finally{synchronized(lock){finish("parent_closed");}watchdog.shutdownNow();}
|
|
148
|
+
}
|
|
149
|
+
public static void main(String[] args) throws Exception {new DebuggerWorker().run();}
|
|
150
|
+
|
|
151
|
+
// Small strict JSON codec for the bounded, whitelisted bridge protocol.
|
|
152
|
+
static final class Json {
|
|
153
|
+
final String s;int p;Json(String s){this.s=s;}
|
|
154
|
+
void ws(){while(p<s.length()&&Character.isWhitespace(s.charAt(p)))p++;}
|
|
155
|
+
Object value(){Object v=read();ws();if(p!=s.length())throw fail("INVALID_ARGUMENT");return v;}
|
|
156
|
+
Object read(){ws();char c=s.charAt(p++);if(c=='"')return string();if(c=='{'){Map<String,Object>m=new LinkedHashMap<>();ws();if(s.charAt(p)=='}'){p++;return m;}do{ws();if(s.charAt(p++)!='"')throw fail("INVALID_ARGUMENT");String k=string();ws();if(s.charAt(p++)!=':')throw fail("INVALID_ARGUMENT");m.put(k,read());ws();c=s.charAt(p++);}while(c==',');if(c!='}')throw fail("INVALID_ARGUMENT");return m;}if(c=='['){List<Object>l=new ArrayList<>();ws();if(s.charAt(p)==']'){p++;return l;}do{l.add(read());ws();c=s.charAt(p++);}while(c==',');if(c!=']')throw fail("INVALID_ARGUMENT");return l;}p--;for(String lit:List.of("true","false","null"))if(s.startsWith(lit,p)){p+=lit.length();return lit.equals("null")?null:Boolean.valueOf(lit);}int start=p;while(p<s.length()&&"-+0123456789.eE".indexOf(s.charAt(p))>=0)p++;String number=s.substring(start,p);if(number.indexOf('.')<0&&number.indexOf('e')<0&&number.indexOf('E')<0)return Long.valueOf(number);return Double.valueOf(number);}
|
|
157
|
+
String string(){StringBuilder b=new StringBuilder();while(p<s.length()){char c=s.charAt(p++);if(c=='"')return b.toString();if(c=='\\'){c=s.charAt(p++);c=switch(c){case 'n'->'\n';case 'r'->'\r';case 't'->'\t';case 'b'->'\b';case 'f'->'\f';case 'u'->{char u=(char)Integer.parseInt(s.substring(p,p+4),16);p+=4;yield u;}case '"','\\','/'->c;default->throw fail("INVALID_ARGUMENT");};}else if(c<32)throw fail("INVALID_ARGUMENT");b.append(c);}throw fail("INVALID_ARGUMENT");}
|
|
158
|
+
static String write(Object o){if(o==null)return "null";if(o instanceof String s){StringBuilder b=new StringBuilder("\"");for(char c:s.toCharArray()){switch(c){case '"'->b.append("\\\"");case '\\'->b.append("\\\\");case '\n'->b.append("\\n");case '\r'->b.append("\\r");case '\t'->b.append("\\t");default->{if(c<32||Character.isSurrogate(c))b.append(String.format("\\u%04x",(int)c));else b.append(c);}}}return b.append('"').toString();}if(o instanceof Map<?,?>m){List<String>l=new ArrayList<>();m.forEach((k,v)->l.add(write(k.toString())+":"+write(v)));return "{"+String.join(",",l)+"}";}if(o instanceof Collection<?>l){List<String>a=new ArrayList<>();l.forEach(v->a.add(write(v)));return "["+String.join(",",a)+"]";}return o.toString();}
|
|
159
|
+
}
|
|
160
|
+
}
|