dsh-ris-ide 0.0.0-stage → 1.6.9
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/LICENSE +21 -0
- package/README.md +124 -2
- package/bin/run-with-log.ps1 +173 -0
- package/bin/run-with-log.sh +136 -0
- package/cordis.patch.yml +25 -0
- package/lib/client-codemirror.js +29822 -0
- package/lib/client.js +10772 -0
- package/lib/debug/engine.js +537 -0
- package/lib/debug/vendor/LICENSE +21 -0
- package/lib/debug/vendor/README.md +36 -0
- package/lib/debug/vendor/UPSTREAM_COMMIT +1 -0
- package/lib/debug/vendor/jdwp/client.js +1104 -0
- package/lib/debug/vendor/jdwp/constants.js +309 -0
- package/lib/debug/vendor/jdwp/protocol.js +253 -0
- package/lib/debug/vendor/src/jdwp/client.ts +1368 -0
- package/lib/debug/vendor/src/jdwp/constants.ts +310 -0
- package/lib/debug/vendor/src/jdwp/protocol.ts +326 -0
- package/lib/index.js +6112 -0
- package/lib/standalone/vendor/react-dom.js +267 -0
- package/lib/standalone/vendor/react.js +31 -0
- package/lib/standalone/window.html +248 -0
- package/package.json +96 -4
- package/skills/dsh-ris-ide-debug/SKILL.md +158 -0
- package/test/client-viewer.test.mjs +3557 -0
- package/test/debug-engine.test.mjs +351 -0
- package/test/harness.test.mjs +93 -0
- package/test/host-nogit.test.mjs +48 -0
- package/test/host-tail.test.mjs +2846 -0
- package/test/host-window.test.mjs +119 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug engine for the local-instance panel: one JDWP session per program.
|
|
3
|
+
*
|
|
4
|
+
* The wire protocol lives in the vendored `jvm-debug-mcp` engine
|
|
5
|
+
* (`./vendor/jdwp/client.js`, MIT, see `./vendor/README.md`); this module owns
|
|
6
|
+
* the *lifecycle*: JDWP launch argument, free-port allocation, attach/retry,
|
|
7
|
+
* the bounded event ring, and JSON-safe projections for the routes and the
|
|
8
|
+
* `ris_dev_debug` tool.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately no third-party runtime dependency: the engine only needs
|
|
11
|
+
* `node:net`/`node:events`, and everything here is plain JS.
|
|
12
|
+
*/
|
|
13
|
+
import net from 'node:net'
|
|
14
|
+
import { open, stat } from 'node:fs/promises'
|
|
15
|
+
import { JDWPClient } from './vendor/jdwp/client.js'
|
|
16
|
+
|
|
17
|
+
/** JDWP agent argument for a loopback-only debugging listener. */
|
|
18
|
+
export function jdwpAgentArg(port, suspend = false) {
|
|
19
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error(`invalid debug port: ${port}`)
|
|
20
|
+
return `-agentlib:jdwp=transport=dt_socket,server=y,suspend=${suspend === true ? 'y' : 'n'},address=127.0.0.1:${port}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* JSON-safe projection: JDWP ids are BigInt and `JSON.stringify` refuses them.
|
|
25
|
+
* @param value - anything the engine returned.
|
|
26
|
+
* @returns a value with BigInt → decimal string.
|
|
27
|
+
*/
|
|
28
|
+
export function toJsonSafe(value) {
|
|
29
|
+
if (typeof value === 'bigint') return value.toString()
|
|
30
|
+
if (value === undefined) return null
|
|
31
|
+
if (value === null || typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value
|
|
32
|
+
if (Array.isArray(value)) return value.map(toJsonSafe)
|
|
33
|
+
if (value instanceof Map) {
|
|
34
|
+
const out = {}
|
|
35
|
+
for (const [key, item] of value) out[String(key)] = toJsonSafe(item)
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'object') {
|
|
39
|
+
const out = {}
|
|
40
|
+
for (const [key, item] of Object.entries(value)) out[key] = toJsonSafe(item)
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
return String(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A TCP port free on 127.0.0.1 right now (the JVM binds it a moment later). */
|
|
47
|
+
export function allocateDebugPort() {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const server = net.createServer()
|
|
50
|
+
server.unref()
|
|
51
|
+
server.once('error', reject)
|
|
52
|
+
server.listen(0, '127.0.0.1', () => {
|
|
53
|
+
const address = server.address()
|
|
54
|
+
const port = address !== null && typeof address === 'object' ? address.port : undefined
|
|
55
|
+
server.close(() => {
|
|
56
|
+
if (port === undefined) reject(new Error('could not allocate a debug port'))
|
|
57
|
+
else resolve(port)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wait until something accepts a TCP connection on 127.0.0.1:port. */
|
|
64
|
+
export function waitForTcp(port, timeoutMs = 60000) {
|
|
65
|
+
const deadline = Date.now() + timeoutMs
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const attempt = () => {
|
|
68
|
+
const socket = net.connect({ host: '127.0.0.1', port })
|
|
69
|
+
const retry = () => {
|
|
70
|
+
socket.destroy()
|
|
71
|
+
if (Date.now() >= deadline) reject(new Error(`debug port ${port} did not open within ${timeoutMs}ms`))
|
|
72
|
+
else setTimeout(attempt, 200)
|
|
73
|
+
}
|
|
74
|
+
socket.setTimeout(1000)
|
|
75
|
+
socket.once('connect', () => {
|
|
76
|
+
socket.destroy()
|
|
77
|
+
resolve(true)
|
|
78
|
+
})
|
|
79
|
+
socket.once('timeout', retry)
|
|
80
|
+
socket.once('error', retry)
|
|
81
|
+
}
|
|
82
|
+
attempt()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the last `bytes` of a text file (a log may be large by the time this runs).
|
|
88
|
+
* @param path - absolute log path.
|
|
89
|
+
* @param bytes - tail window.
|
|
90
|
+
* @returns the tail text, or `''` when the file is missing.
|
|
91
|
+
*/
|
|
92
|
+
async function readTailText(path, bytes = 16 * 1024) {
|
|
93
|
+
let handle
|
|
94
|
+
try {
|
|
95
|
+
handle = await open(path, 'r')
|
|
96
|
+
const info = await handle.stat()
|
|
97
|
+
const length = Math.min(bytes, info.size)
|
|
98
|
+
const buffer = Buffer.alloc(length)
|
|
99
|
+
await handle.read(buffer, 0, length, info.size - length)
|
|
100
|
+
return buffer.toString('utf8')
|
|
101
|
+
} catch {
|
|
102
|
+
return ''
|
|
103
|
+
} finally {
|
|
104
|
+
await handle?.close().catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wait for the JVM to announce its JDWP listener in its own log.
|
|
110
|
+
*
|
|
111
|
+
* **Do not** probe a JDWP port with a plain TCP connect before attaching: JDWP
|
|
112
|
+
* serves a single debugger session, so a throwaway connect consumes it and the
|
|
113
|
+
* real attach then gets ECONNRESET. The JVM prints
|
|
114
|
+
* `Listening for transport dt_socket at address: <port>` once the listener is up.
|
|
115
|
+
* @param logPath - the program log the wrapper writes.
|
|
116
|
+
* @param port - the JDWP port we chose.
|
|
117
|
+
* @param timeoutMs - how long to wait (the wrapper runs `mvn compile` first).
|
|
118
|
+
* @returns `true` when the line was seen.
|
|
119
|
+
* @throws {Error} with the log tail when it never appears.
|
|
120
|
+
*/
|
|
121
|
+
export async function waitForJdwpListening(logPath, port, timeoutMs = 90000) {
|
|
122
|
+
const deadline = Date.now() + timeoutMs
|
|
123
|
+
const needle = new RegExp(`Listening for transport dt_socket at address: (?:127\\.0\\.0\\.1:)?${port}\\b`)
|
|
124
|
+
let tail = ''
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
tail = await readTailText(logPath)
|
|
127
|
+
if (needle.test(tail)) return true
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
129
|
+
}
|
|
130
|
+
const lines = tail.split('\n').filter((line) => line.trim() !== '')
|
|
131
|
+
const preview = lines.slice(Math.max(0, lines.length - 8)).join('\n')
|
|
132
|
+
throw new Error(`JDWP 端口 ${port} 没有在 ${timeoutMs}ms 内就绪${preview === '' ? '' : `;日志尾部:\n${preview}`}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One machine-readable failure (mapped to an HTTP status by the route). */
|
|
136
|
+
export class DebugError extends Error {
|
|
137
|
+
constructor(code, message) {
|
|
138
|
+
super(message)
|
|
139
|
+
this.name = 'DebugError'
|
|
140
|
+
this.code = code
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const DEBUG_ERROR_STATUS = {
|
|
145
|
+
'not-attached': 409,
|
|
146
|
+
'bad-argument': 400,
|
|
147
|
+
'unknown-program': 404,
|
|
148
|
+
'debug-failed': 502,
|
|
149
|
+
'no-jdwp': 409,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** HTTP status for one {@link DebugError} code. */
|
|
153
|
+
export function debugErrorStatus(code) {
|
|
154
|
+
return DEBUG_ERROR_STATUS[code] ?? 500
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The event names the engine emits, mapped to what the UI/tool reports. */
|
|
158
|
+
const EVENT_TYPES = [
|
|
159
|
+
'breakpoint', 'step', 'classprepare', 'vmstart', 'vmdeath', 'threadstart', 'threaddeath',
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
/** The breakpoint suspend policies the engine accepts. */
|
|
163
|
+
export const SUSPEND_POLICIES = new Set(['thread', 'all'])
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Create the per-plugin debug session registry.
|
|
167
|
+
* @param options - `{ clientFactory?, eventLimit?, connectRetry? }` (tests inject a fake client).
|
|
168
|
+
* @returns the session API (all methods resolve JSON-safe values).
|
|
169
|
+
*/
|
|
170
|
+
export function createDebugSessions(options = {}) {
|
|
171
|
+
const clientFactory = options.clientFactory ?? (() => new JDWPClient())
|
|
172
|
+
const eventLimit = Number.isInteger(options.eventLimit) ? options.eventLimit : 200
|
|
173
|
+
const connectRetry = Number.isInteger(options.connectRetry) ? options.connectRetry : 50
|
|
174
|
+
const connectTimeoutMs = Number.isInteger(options.connectTimeoutMs) ? options.connectTimeoutMs : 30000
|
|
175
|
+
/** logPath → session */
|
|
176
|
+
const sessions = new Map()
|
|
177
|
+
/**
|
|
178
|
+
* Breakpoints set BEFORE a debug session exists, keyed by program log path.
|
|
179
|
+
* `startProgramDebug`/`attach` installs them as soon as the JVM connects, so a
|
|
180
|
+
* gutter click on a stopped program is not lost ("提前打断点").
|
|
181
|
+
* @type {Map<string, Array<{className: string, line: number, suspendPolicy: string}>>}
|
|
182
|
+
*/
|
|
183
|
+
const pendingBreakpoints = new Map()
|
|
184
|
+
|
|
185
|
+
/** The pre-set breakpoints of one program (empty when none). */
|
|
186
|
+
const pendingOf = (logPath) => pendingBreakpoints.get(logPath) ?? []
|
|
187
|
+
/** How many pre-set breakpoints wait for one program (0 when none). */
|
|
188
|
+
const pendingCount = (logPath) => pendingOf(logPath).length
|
|
189
|
+
/** Store one pre-set breakpoint (dedupes className+line). */
|
|
190
|
+
const addPending = (logPath, className, line, suspendPolicy) => {
|
|
191
|
+
const policy = SUSPEND_POLICIES.has(suspendPolicy) ? suspendPolicy : 'thread'
|
|
192
|
+
const list = pendingOf(logPath).filter((entry) => !(entry.className === className && entry.line === line))
|
|
193
|
+
list.push({ className, line, suspendPolicy: policy })
|
|
194
|
+
pendingBreakpoints.set(logPath, list)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Install every pre-set breakpoint of one program into a fresh session.
|
|
199
|
+
* Failures are recorded on the session (the UI shows them) and the rest go on.
|
|
200
|
+
*/
|
|
201
|
+
const installPending = async (session) => {
|
|
202
|
+
const list = pendingOf(session.logPath)
|
|
203
|
+
if (list.length === 0) return 0
|
|
204
|
+
pendingBreakpoints.delete(session.logPath)
|
|
205
|
+
let installed = 0
|
|
206
|
+
for (const entry of list) {
|
|
207
|
+
try {
|
|
208
|
+
await session.client.setBreakpoint(entry.className, entry.line, entry.suspendPolicy)
|
|
209
|
+
installed += 1
|
|
210
|
+
} catch (error) {
|
|
211
|
+
session.lastError = `预置断点 ${entry.className}:${entry.line} 未能生效:${error.message ?? error}`
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return installed
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sessionOf = (logPath) => {
|
|
218
|
+
const session = sessions.get(logPath)
|
|
219
|
+
if (session === undefined) {
|
|
220
|
+
throw new DebugError('not-attached', `no debug session for ${logPath}; start it with action "start"`)
|
|
221
|
+
}
|
|
222
|
+
return session
|
|
223
|
+
}
|
|
224
|
+
const bigintId = (value, label) => {
|
|
225
|
+
const text = String(value ?? '')
|
|
226
|
+
if (!/^\d+$/.test(text)) throw new DebugError('bad-argument', `${label} must be a numeric id (got ${JSON.stringify(value)})`)
|
|
227
|
+
try {
|
|
228
|
+
return BigInt(text)
|
|
229
|
+
} catch {
|
|
230
|
+
throw new DebugError('bad-argument', `${label} is not a valid id`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const recordEvent = (session, type, payload) => {
|
|
234
|
+
session.seq += 1
|
|
235
|
+
session.events.push({ seq: session.seq, at: Date.now(), type, payload: toJsonSafe(payload) })
|
|
236
|
+
while (session.events.length > eventLimit) session.events.shift()
|
|
237
|
+
}
|
|
238
|
+
const snapshot = (session) => ({
|
|
239
|
+
attached: session.client.isConnected === true,
|
|
240
|
+
port: session.port,
|
|
241
|
+
attachedAt: session.attachedAt,
|
|
242
|
+
vmSuspended: session.client.vmSuspended === true,
|
|
243
|
+
suspendedThreads: toJsonSafe(session.client.allSuspendedThreadIds ?? []),
|
|
244
|
+
// The thread a breakpoint/step stopped (EventThread policies suspend only that
|
|
245
|
+
// one); the IDE reads frames/variables from it instead of guessing threads[0].
|
|
246
|
+
currentThreadId: toJsonSafe(session.client.currentThreadId ?? null),
|
|
247
|
+
breakpoints: toJsonSafe(session.client.getBreakpoints() ?? []),
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
/** Connect to a JVM that was started with {@link jdwpAgentArg}. */
|
|
251
|
+
const attach = async (program, port) => {
|
|
252
|
+
if (sessions.has(program.logPath)) await detach(program.logPath)
|
|
253
|
+
// A failed connect leaves the JDWP client's socket in an unusable state, so
|
|
254
|
+
// each attempt gets a fresh client.
|
|
255
|
+
let client
|
|
256
|
+
let lastError
|
|
257
|
+
const deadline = Date.now() + connectTimeoutMs
|
|
258
|
+
for (let attempt = 1; attempt <= connectRetry; attempt += 1) {
|
|
259
|
+
client = clientFactory()
|
|
260
|
+
try {
|
|
261
|
+
await client.connect('127.0.0.1', port)
|
|
262
|
+
lastError = undefined
|
|
263
|
+
break
|
|
264
|
+
} catch (error) {
|
|
265
|
+
lastError = error
|
|
266
|
+
client = undefined
|
|
267
|
+
if (Date.now() >= deadline) break
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(250 * attempt, 2000)))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (client === undefined) client = clientFactory()
|
|
272
|
+
if (lastError !== undefined) {
|
|
273
|
+
throw new DebugError('debug-failed', `cannot attach to 127.0.0.1:${port}: ${lastError.message ?? lastError}`)
|
|
274
|
+
}
|
|
275
|
+
const session = {
|
|
276
|
+
logPath: program.logPath,
|
|
277
|
+
programId: program.id,
|
|
278
|
+
programName: program.name,
|
|
279
|
+
port,
|
|
280
|
+
client,
|
|
281
|
+
attachedAt: Date.now(),
|
|
282
|
+
seq: 0,
|
|
283
|
+
events: [],
|
|
284
|
+
}
|
|
285
|
+
for (const type of EVENT_TYPES) client.on(type, (payload) => recordEvent(session, type, payload))
|
|
286
|
+
client.on('error', (error) => {
|
|
287
|
+
session.lastError = String(error && error.message ? error.message : error)
|
|
288
|
+
recordEvent(session, 'error', { message: session.lastError })
|
|
289
|
+
})
|
|
290
|
+
client.on('close', () => {
|
|
291
|
+
sessions.delete(program.logPath)
|
|
292
|
+
})
|
|
293
|
+
sessions.set(program.logPath, session)
|
|
294
|
+
recordEvent(session, 'attach', { port })
|
|
295
|
+
// Anything the user clicked before the session existed now becomes real.
|
|
296
|
+
// (With `suspend=y` the JVM is still waiting at startup here, so a breakpoint
|
|
297
|
+
// in `main` — e.g. on `SpringApplication.run(...)` — is installed BEFORE that
|
|
298
|
+
// line runs; with `suspend=n` it would be too late.)
|
|
299
|
+
const installedPending = await installPending(session)
|
|
300
|
+
return Object.assign(
|
|
301
|
+
{ logPath: program.logPath, programId: program.id, name: program.name, installedPending },
|
|
302
|
+
snapshot(session),
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Disconnect (the JVM resumes if it was suspended by a breakpoint). */
|
|
307
|
+
const detach = async (logPath) => {
|
|
308
|
+
const session = sessions.get(logPath)
|
|
309
|
+
if (session === undefined) return { detached: false }
|
|
310
|
+
// Keep the user's breakpoints for the NEXT attach: JDWP event requests die with
|
|
311
|
+
// the connection, and a debug RESTART is detach+attach — without this the dots
|
|
312
|
+
// vanished on every restart. `clearBreakpoints` empties this list on purpose.
|
|
313
|
+
try {
|
|
314
|
+
for (const breakpoint of session.client.getBreakpoints() ?? []) {
|
|
315
|
+
if (typeof breakpoint.className !== 'string' || !Number.isFinite(Number(breakpoint.line))) continue
|
|
316
|
+
addPending(logPath, breakpoint.className, Number(breakpoint.line), breakpoint.suspendPolicy)
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
// a dead connection cannot be asked: the restart then starts empty
|
|
320
|
+
}
|
|
321
|
+
sessions.delete(logPath)
|
|
322
|
+
try {
|
|
323
|
+
await session.client.disconnect()
|
|
324
|
+
} catch {
|
|
325
|
+
// the VM may already be gone: detaching is best-effort
|
|
326
|
+
}
|
|
327
|
+
return { detached: true }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Detach every session (plugin teardown). */
|
|
331
|
+
const detachAll = async () => {
|
|
332
|
+
const paths = [...sessions.keys()]
|
|
333
|
+
for (const logPath of paths) await detach(logPath)
|
|
334
|
+
return { detached: paths.length }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One session's status, or a detached stub (no throw — the panel polls it). */
|
|
338
|
+
const status = (logPath) => {
|
|
339
|
+
const session = sessions.get(logPath)
|
|
340
|
+
if (session === undefined) {
|
|
341
|
+
return { attached: false, logPath, breakpoints: pendingOf(logPath).map((entry) => Object.assign({ requestId: 'pending', pending: true }, entry)) }
|
|
342
|
+
}
|
|
343
|
+
return Object.assign({ logPath }, snapshot(session), { lastError: session.lastError ?? null })
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Session state + events newer than `since` (one poll request for the UI). */
|
|
347
|
+
const poll = (logPath, since = 0) => {
|
|
348
|
+
const session = sessions.get(logPath)
|
|
349
|
+
if (session === undefined) {
|
|
350
|
+
return {
|
|
351
|
+
attached: false,
|
|
352
|
+
logPath,
|
|
353
|
+
events: [],
|
|
354
|
+
seq: 0,
|
|
355
|
+
vmSuspended: false,
|
|
356
|
+
breakpoints: pendingOf(logPath).map((entry) => Object.assign({ requestId: 'pending', pending: true }, entry)),
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Tag breakpoint events with the breakpoint they belong to. Several consumers
|
|
360
|
+
// (the IDE, an agent session) share ONE JDWP connection, so "a breakpoint was
|
|
361
|
+
// hit" is not enough: each side must be able to tell WHICH one hit, otherwise
|
|
362
|
+
// one party's breakpoint can be mistaken for its own.
|
|
363
|
+
const hitByRequest = new Map((session.client.getBreakpoints() ?? []).map((entry) => [String(entry.requestId), entry]))
|
|
364
|
+
const events = session.events
|
|
365
|
+
.filter((event) => event.seq > Number(since || 0))
|
|
366
|
+
.map((event) => {
|
|
367
|
+
if (event.type !== 'breakpoint' && event.type !== 'step') return event
|
|
368
|
+
const requestId = event.payload === null || event.payload === undefined ? undefined : event.payload.requestId
|
|
369
|
+
const breakpoint = requestId === undefined ? undefined : hitByRequest.get(String(requestId))
|
|
370
|
+
if (breakpoint === undefined) return event
|
|
371
|
+
return Object.assign({}, event, {
|
|
372
|
+
payload: Object.assign({}, event.payload, { className: breakpoint.className, line: breakpoint.line }),
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
return Object.assign({ logPath }, snapshot(session), {
|
|
376
|
+
lastError: session.lastError ?? null,
|
|
377
|
+
seq: session.seq,
|
|
378
|
+
events,
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Every live session, so a client that shows ONE program can still notice a
|
|
384
|
+
* breakpoint in another (the IDE auto-switches to the hit project).
|
|
385
|
+
* @returns {object[]} `[{logPath, attached, vmSuspended, suspendedThreads, seq, port}]`
|
|
386
|
+
*/
|
|
387
|
+
const listSessions = () => toJsonSafe([...sessions.entries()].map(([logPath, session]) => Object.assign(
|
|
388
|
+
{ logPath },
|
|
389
|
+
snapshot(session),
|
|
390
|
+
{ seq: session.seq, lastError: session.lastError ?? null },
|
|
391
|
+
)))
|
|
392
|
+
|
|
393
|
+
const listBreakpoints = (logPath) => {
|
|
394
|
+
const session = sessions.get(logPath)
|
|
395
|
+
if (session === undefined) {
|
|
396
|
+
return toJsonSafe(pendingOf(logPath).map((entry) => Object.assign({ requestId: 'pending', pending: true }, entry)))
|
|
397
|
+
}
|
|
398
|
+
return toJsonSafe(session.client.getBreakpoints() ?? [])
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const addBreakpoint = async (logPath, className, line, suspendPolicy = 'thread') => {
|
|
402
|
+
if (typeof className !== 'string' || className.trim() === '') throw new DebugError('bad-argument', 'className is required')
|
|
403
|
+
const lineNumber = Number(line)
|
|
404
|
+
if (!Number.isInteger(lineNumber) || lineNumber <= 0) throw new DebugError('bad-argument', 'line must be a positive integer')
|
|
405
|
+
const policy = SUSPEND_POLICIES.has(suspendPolicy) ? suspendPolicy : 'thread'
|
|
406
|
+
const session = sessions.get(logPath)
|
|
407
|
+
if (session === undefined) {
|
|
408
|
+
// No session yet: remember it and install on attach ("提前打断点").
|
|
409
|
+
addPending(logPath, className.trim(), lineNumber, policy)
|
|
410
|
+
return { requestId: 'pending', className: className.trim(), line: lineNumber, suspendPolicy: policy, pending: true }
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const breakpoint = await session.client.setBreakpoint(className.trim(), lineNumber, policy)
|
|
414
|
+
return toJsonSafe(breakpoint)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throw new DebugError('debug-failed', `cannot set breakpoint ${className}:${lineNumber}: ${error.message ?? error}`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const removeBreakpoint = async (logPath, requestId, className, line) => {
|
|
421
|
+
const session = sessions.get(logPath)
|
|
422
|
+
if (session === undefined) {
|
|
423
|
+
// A pre-set breakpoint: match it by className+line (requestId is a marker).
|
|
424
|
+
const targetLine = Number(line)
|
|
425
|
+
const next = pendingOf(logPath).filter((entry) => !(entry.className === className && entry.line === targetLine))
|
|
426
|
+
if (next.length === 0) pendingBreakpoints.delete(logPath)
|
|
427
|
+
else pendingBreakpoints.set(logPath, next)
|
|
428
|
+
return { removed: true, requestId: 'pending' }
|
|
429
|
+
}
|
|
430
|
+
// Live session: a request id is the fast path, but callers (agents!) often only
|
|
431
|
+
// know the class+line they set — resolve the request id for them.
|
|
432
|
+
let id = Number(requestId)
|
|
433
|
+
if (!Number.isInteger(id)) {
|
|
434
|
+
const match = (session.client.getBreakpoints() ?? []).find(
|
|
435
|
+
(entry) => entry.className === className && Number(entry.line) === Number(line),
|
|
436
|
+
)
|
|
437
|
+
if (match === undefined) throw new DebugError('bad-argument', 'requestId (or className + line) is required')
|
|
438
|
+
id = Number(match.requestId)
|
|
439
|
+
}
|
|
440
|
+
if (!Number.isInteger(id)) throw new DebugError('bad-argument', 'requestId must be an integer')
|
|
441
|
+
await session.client.removeBreakpoint(id)
|
|
442
|
+
return { removed: true, requestId: id }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const clearBreakpoints = async (logPath) => {
|
|
446
|
+
pendingBreakpoints.delete(logPath)
|
|
447
|
+
const session = sessions.get(logPath)
|
|
448
|
+
if (session !== undefined) await session.client.clearAllBreakpoints()
|
|
449
|
+
return { cleared: true }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const threads = async (logPath) => toJsonSafe(await sessionOf(logPath).client.getAllThreads())
|
|
453
|
+
|
|
454
|
+
const stack = async (logPath, threadId, start = 0, max = 50) => {
|
|
455
|
+
const session = sessionOf(logPath)
|
|
456
|
+
const id = bigintId(threadId, 'threadId')
|
|
457
|
+
const frames = await session.client.getFrames(id, Number(start) || 0, Number(max) || 50)
|
|
458
|
+
return toJsonSafe(frames)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const variables = async (logPath, threadId, frameIndex = 0) => {
|
|
462
|
+
const session = sessionOf(logPath)
|
|
463
|
+
const id = bigintId(threadId, 'threadId')
|
|
464
|
+
const frames = await session.client.getFrames(id, 0, Number(frameIndex) + 1)
|
|
465
|
+
const frame = frames[Number(frameIndex)]
|
|
466
|
+
if (frame === undefined) throw new DebugError('bad-argument', `frameIndex ${frameIndex} is out of range (${frames.length} frame(s))`)
|
|
467
|
+
return toJsonSafe(await session.client.getFrameVariables(id, frame.frameId, frame.location))
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Expand one object/array value (one click in the IDE's variable tree).
|
|
472
|
+
*
|
|
473
|
+
* The JDWP layer resolves the object's runtime class, then returns either its
|
|
474
|
+
* fields or its first 100 array entries; nested objects keep `objectId` so the
|
|
475
|
+
* client can expand them in turn.
|
|
476
|
+
* @param {string} logPath - the program's log path (session key).
|
|
477
|
+
* @param {string} objectId - a decimal JDWP object id from a variable entry.
|
|
478
|
+
*/
|
|
479
|
+
const expand = async (logPath, objectId) => {
|
|
480
|
+
const session = sessionOf(logPath)
|
|
481
|
+
if (typeof objectId !== 'string' || !/^\d+$/.test(objectId)) {
|
|
482
|
+
throw new DebugError('bad-argument', 'objectId must be a decimal JDWP object id')
|
|
483
|
+
}
|
|
484
|
+
return toJsonSafe(await session.client.getObjectDetails(BigInt(objectId)))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const step = async (logPath, kind, threadId) => {
|
|
488
|
+
const session = sessionOf(logPath)
|
|
489
|
+
const candidates = typeof threadId === 'string' && threadId !== ''
|
|
490
|
+
? [bigintId(threadId, 'threadId')]
|
|
491
|
+
: (session.client.allSuspendedThreadIds ?? []).map((id) => BigInt(id))
|
|
492
|
+
const target = candidates[0]
|
|
493
|
+
if (target === undefined) throw new DebugError('not-attached', 'no suspended thread to step; set a breakpoint first or pass threadId')
|
|
494
|
+
if (kind === 'into') await session.client.stepInto(target)
|
|
495
|
+
else if (kind === 'out') await session.client.stepOut(target)
|
|
496
|
+
else if (kind === 'over') await session.client.stepOver(target)
|
|
497
|
+
else throw new DebugError('bad-argument', 'kind must be over | into | out')
|
|
498
|
+
return { stepped: kind, threadId: target.toString() }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const resume = async (logPath, threadId) => {
|
|
502
|
+
const session = sessionOf(logPath)
|
|
503
|
+
if (typeof threadId === 'string' && threadId !== '') {
|
|
504
|
+
await session.client.resumeThread(bigintId(threadId, 'threadId'))
|
|
505
|
+
return { resumed: 'thread', threadId }
|
|
506
|
+
}
|
|
507
|
+
await session.client.resumeVM()
|
|
508
|
+
return { resumed: 'all' }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const pause = async (logPath) => {
|
|
512
|
+
await sessionOf(logPath).client.suspendVM()
|
|
513
|
+
return { paused: true }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
attach,
|
|
518
|
+
detach,
|
|
519
|
+
detachAll,
|
|
520
|
+
status,
|
|
521
|
+
poll,
|
|
522
|
+
pendingCount,
|
|
523
|
+
listSessions,
|
|
524
|
+
listBreakpoints,
|
|
525
|
+
addBreakpoint,
|
|
526
|
+
removeBreakpoint,
|
|
527
|
+
clearBreakpoints,
|
|
528
|
+
threads,
|
|
529
|
+
stack,
|
|
530
|
+
variables,
|
|
531
|
+
expand,
|
|
532
|
+
step,
|
|
533
|
+
resume,
|
|
534
|
+
pause,
|
|
535
|
+
get size() { return sessions.size },
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Riku Kano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# vendor/jdwp — jvm-debug-mcp 的 JDWP 引擎(内嵌)
|
|
2
|
+
|
|
3
|
+
`lib/debug/vendor/jdwp/*.js` 是 [Riku-KANO/jvm-debug-mcp](https://github.com/Riku-KANO/jvm-debug-mcp) 的
|
|
4
|
+
**JDWP 引擎**编译产物(`src/jdwp/*` 三个文件),**不是**它的 MCP 层。
|
|
5
|
+
|
|
6
|
+
- 上游版本:1.0.0,commit `d51f66b`(见 `UPSTREAM_COMMIT`)
|
|
7
|
+
- 许可:MIT(见 `LICENSE`),版权归 Riku Kano
|
|
8
|
+
- 体积:`client.js` 40.5KB + `constants.js` 17.1KB + `protocol.js` 7.4KB = **64.9KB**
|
|
9
|
+
- 运行时依赖:**0**(只用 `node:net`、`node:events`;上游的 `@modelcontextprotocol/sdk` 与 `zod` 只在 `dist/index.js` 那层用到,本项目不引用)
|
|
10
|
+
- 源码:`src/jdwp/*.ts` 一并放在这里(约 2004 行 TS),方便审计与修改
|
|
11
|
+
|
|
12
|
+
## 为什么内嵌而不是跑 MCP 子进程
|
|
13
|
+
|
|
14
|
+
| | 内嵌引擎 | MCP 子进程 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 磁盘 | 64.9KB | 65KB + 10.3MB(MCP SDK 4.7MB + zod 5.6MB) |
|
|
17
|
+
| 进程 | 0(跑在 DSH 宿主里) | +1 个 Node 进程(~40–60MB) |
|
|
18
|
+
| 内存 | +5MB RSS(实测) | ~40–60MB |
|
|
19
|
+
| 集成 | 面板/agent/路由共用同一会话 | 工具在 `mcp__jvm-debug__*`,面板需另建通道 |
|
|
20
|
+
|
|
21
|
+
## 重新构建(上游有更新或改了 TS 源码时)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/Riku-KANO/jvm-debug-mcp.git /tmp/jvm-debug-mcp
|
|
25
|
+
cd /tmp/jvm-debug-mcp && pnpm install && npx tsc
|
|
26
|
+
# 只取引擎三件套(MCP 层不要)
|
|
27
|
+
cp dist/jdwp/{client,constants,protocol}.js <本目录>/jdwp/
|
|
28
|
+
cp src/jdwp/{client,constants,protocol}.ts <本目录>/src/jdwp/
|
|
29
|
+
cp LICENSE <本目录>/LICENSE
|
|
30
|
+
git rev-parse --short HEAD > <本目录>/UPSTREAM_COMMIT
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 已知能力边界(引擎本身)
|
|
34
|
+
|
|
35
|
+
能:行断点(类名+行)、方法断点、线程/全 VM 挂起、单步 over/into/out、多线程、调用栈、局部变量、对象字段、数组、String 值。
|
|
36
|
+
不能:表达式求值(evaluate)、条件断点、日志断点、热替换(redefineClasses)——这些需要 DAP/JDI 级别实现,本插件 Phase 1 不做。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
d51f66b
|