Initial commit: omp-debug DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
c068fbd58e
10 files changed
+844
No files matched your search
+158
@@ -0,0 +1,158 @@
|
||||
// Debug Adapter Protocol client (stdio or TCP transport, Content-Length framing).
|
||||
import { spawn } from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import net from 'node:net'
|
||||
|
||||
export class DapClient extends EventEmitter {
|
||||
constructor(name) {
|
||||
super()
|
||||
this.name = name
|
||||
this.seq = 0
|
||||
this.pending = new Map()
|
||||
this.buf = Buffer.alloc(0)
|
||||
this.output = []
|
||||
this.outputBytes = 0
|
||||
this.closed = false
|
||||
this.capabilities = {}
|
||||
this.reverse = {} // command -> async handler(args) -> body
|
||||
}
|
||||
|
||||
/** stdio adapter */
|
||||
static spawnStdio(name, command, args, { cwd, env } = {}) {
|
||||
const c = new DapClient(name)
|
||||
c.proc = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
c.proc.stdout.on('data', (d) => c.onData(d))
|
||||
c.proc.stderr.on('data', (d) => c.log('adapter', String(d)))
|
||||
c.proc.on('exit', (code, sig) => c.close(`adapter exited (${sig ?? code})`))
|
||||
c.proc.on('error', (e) => c.close(e.message))
|
||||
c.write = (s) => { if (c.proc.stdin.writable) c.proc.stdin.write(s) }
|
||||
return c
|
||||
}
|
||||
|
||||
/** TCP adapter: optionally spawn a server process, then connect (with retries). */
|
||||
static async connectTcp(name, { host = '127.0.0.1', port, spawnCmd, spawnArgs, cwd, env, timeoutMs = 15000 }) {
|
||||
const c = new DapClient(name)
|
||||
if (spawnCmd) {
|
||||
c.proc = spawn(spawnCmd, spawnArgs, { cwd, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
c.proc.stdout.on('data', (d) => c.log('adapter', String(d)))
|
||||
c.proc.stderr.on('data', (d) => c.log('adapter', String(d)))
|
||||
c.proc.on('exit', (code, sig) => c.close(`adapter exited (${sig ?? code})`))
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
try {
|
||||
c.sock = await new Promise((resolve, reject) => {
|
||||
const s = net.connect({ host, port }, () => resolve(s))
|
||||
s.once('error', reject)
|
||||
})
|
||||
break
|
||||
} catch (e) {
|
||||
if (Date.now() > deadline || c.closed) { c.close(`connect ${host}:${port} failed: ${e.message}`); throw new Error(`${name}: could not connect to ${host}:${port} (${e.message})`) }
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
}
|
||||
}
|
||||
c.sock.on('data', (d) => c.onData(d))
|
||||
c.sock.on('close', () => c.close('socket closed'))
|
||||
c.sock.on('error', () => {})
|
||||
c.write = (s) => { if (!c.sock.destroyed) c.sock.write(s) }
|
||||
return c
|
||||
}
|
||||
|
||||
log(category, text) {
|
||||
if (!text) return
|
||||
this.output.push({ category, text, at: Date.now() })
|
||||
this.outputBytes += text.length
|
||||
while (this.outputBytes > 200_000 && this.output.length > 1) this.outputBytes -= this.output.shift().text.length
|
||||
}
|
||||
|
||||
onData(chunk) {
|
||||
this.buf = Buffer.concat([this.buf, chunk])
|
||||
for (;;) {
|
||||
const sep = this.buf.indexOf('\r\n\r\n')
|
||||
if (sep < 0) return
|
||||
const m = /Content-Length:\s*(\d+)/i.exec(this.buf.subarray(0, sep).toString('ascii'))
|
||||
if (!m) { this.buf = this.buf.subarray(sep + 4); continue }
|
||||
const len = Number(m[1])
|
||||
if (this.buf.length < sep + 4 + len) return
|
||||
const body = this.buf.subarray(sep + 4, sep + 4 + len).toString('utf8')
|
||||
this.buf = this.buf.subarray(sep + 4 + len)
|
||||
let msg
|
||||
try { msg = JSON.parse(body) } catch { continue }
|
||||
this.onMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async onMessage(msg) {
|
||||
if (msg.type === 'response') {
|
||||
const p = this.pending.get(msg.request_seq)
|
||||
if (!p) return
|
||||
this.pending.delete(msg.request_seq)
|
||||
clearTimeout(p.timer)
|
||||
if (msg.success) p.resolve(msg.body ?? {})
|
||||
else p.reject(Object.assign(new Error(msg.body?.error?.format ?? msg.message ?? `${p.command} failed`), { body: msg.body }))
|
||||
return
|
||||
}
|
||||
if (msg.type === 'event') {
|
||||
if (msg.event === 'output') this.log(msg.body?.category ?? 'console', msg.body?.output ?? '')
|
||||
this.emit('event', msg)
|
||||
this.emit(msg.event, msg.body ?? {})
|
||||
return
|
||||
}
|
||||
if (msg.type === 'request') {
|
||||
const h = this.reverse[msg.command]
|
||||
try {
|
||||
const body = h ? await h(msg.arguments ?? {}) : {}
|
||||
this.send({ type: 'response', request_seq: msg.seq, success: !!h, command: msg.command, body, ...(h ? {} : { message: 'not supported' }) })
|
||||
} catch (e) {
|
||||
this.send({ type: 'response', request_seq: msg.seq, success: false, command: msg.command, message: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send(obj) {
|
||||
const s = JSON.stringify({ seq: ++this.seq, ...obj })
|
||||
this.write?.(`Content-Length: ${Buffer.byteLength(s, 'utf8')}\r\n\r\n${s}`)
|
||||
return this.seq
|
||||
}
|
||||
|
||||
request(command, args, timeoutMs = 30000) {
|
||||
if (this.closed) return Promise.reject(new Error(`${this.name}: session closed (${this.closeReason})`))
|
||||
return new Promise((resolve, reject) => {
|
||||
const seq = this.send({ type: 'request', command, arguments: args })
|
||||
const timer = setTimeout(() => { this.pending.delete(seq); reject(new Error(`${this.name}: ${command} timed out after ${timeoutMs}ms`)) }, timeoutMs)
|
||||
this.pending.set(seq, { resolve, reject, timer, command })
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait for an event, optionally filtered; resolves undefined on timeout. */
|
||||
waitFor(event, { timeoutMs = 10000, filter = () => true, signal } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const on = (body) => { if (filter(body)) { done(); resolve(body) } }
|
||||
const onClose = () => { done(); resolve(undefined) }
|
||||
const timer = setTimeout(() => { done(); resolve(undefined) }, timeoutMs)
|
||||
const onAbort = () => { done(); resolve(undefined) }
|
||||
const done = () => { clearTimeout(timer); this.off(event, on); this.off('closed', onClose); signal?.removeEventListener?.('abort', onAbort) }
|
||||
this.on(event, on)
|
||||
this.once('closed', onClose)
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
close(reason = 'closed') {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.closeReason = reason
|
||||
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new Error(`${this.name}: ${reason}`)) }
|
||||
this.pending.clear()
|
||||
try { this.sock?.destroy() } catch {}
|
||||
// Kill the adapter (and anything it spawned) — a lingering adapter keeps the host's event loop busy.
|
||||
const p = this.proc
|
||||
if (p && p.exitCode === null && p.signalCode === null) {
|
||||
try { process.kill(-p.pid, 'SIGTERM') } catch { try { p.kill('SIGTERM') } catch {} }
|
||||
const t = setTimeout(() => { try { process.kill(-p.pid, 'SIGKILL') } catch { try { p.kill('SIGKILL') } catch {} } }, 1500)
|
||||
t.unref?.()
|
||||
}
|
||||
for (const s of [p?.stdin, p?.stdout, p?.stderr]) { try { s?.destroy() } catch {} }
|
||||
this.emit('closed', reason)
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
// Debug session manager: adapter catalog, launch/attach, stop tracking, child sessions.
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { DapClient } from './dap.js'
|
||||
|
||||
const HOME = os.homedir()
|
||||
|
||||
function which(cmd) {
|
||||
if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null
|
||||
for (const d of [...(process.env.PATH || '').split(path.delimiter), path.join(HOME, '.local/bin'), '/usr/local/bin']) {
|
||||
if (!d) continue
|
||||
const p = path.join(d, cmd)
|
||||
try { fs.accessSync(p, fs.constants.X_OK); return p } catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pythonWithDebugpy() {
|
||||
for (const py of ['python3', 'python']) {
|
||||
const p = which(py)
|
||||
if (!p) continue
|
||||
try { execFileSync(p, ['-c', 'import debugpy'], { stdio: 'ignore', timeout: 10000 }); return p } catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function jsDebugServer() {
|
||||
const cands = [process.env.JS_DEBUG_DAP, path.join(HOME, '.local/share/js-debug/js-debug/src/dapDebugServer.js')].filter(Boolean)
|
||||
return cands.find((c) => fs.existsSync(c)) ?? null
|
||||
}
|
||||
|
||||
const freePort = () => new Promise((resolve, reject) => {
|
||||
const s = net.createServer()
|
||||
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) })
|
||||
s.on('error', reject)
|
||||
})
|
||||
|
||||
/**
|
||||
* Adapter catalog. `detect()` returns spawn info or null; `launchArgs`/`attachArgs` build the
|
||||
* DAP request arguments.
|
||||
*/
|
||||
export const ADAPTERS = {
|
||||
debugpy: {
|
||||
languages: ['python'], extensions: ['.py'],
|
||||
detect: () => { const py = pythonWithDebugpy(); return py ? { kind: 'stdio', command: py, args: ['-m', 'debugpy.adapter'] } : null },
|
||||
install: 'pip install --user debugpy',
|
||||
launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ type: 'python', request: 'launch', program, args, cwd, env, stopOnEntry: !!stopOnEntry, console: 'internalConsole', justMyCode: false, redirectOutput: true }),
|
||||
attachArgs: ({ host, port, pid }) => (pid ? { type: 'python', request: 'attach', processId: pid, justMyCode: false } : { type: 'python', request: 'attach', connect: { host: host ?? '127.0.0.1', port }, justMyCode: false }),
|
||||
},
|
||||
node: {
|
||||
languages: ['javascript', 'typescript'], extensions: ['.js', '.mjs', '.cjs', '.ts', '.mts'],
|
||||
detect: () => { const s = jsDebugServer(); return s ? { kind: 'tcp-server', command: process.execPath, scriptArgs: (port) => [s, String(port), '127.0.0.1'] } : null },
|
||||
install: 'download js-debug-dap from github.com/microsoft/vscode-js-debug/releases into ~/.local/share/js-debug',
|
||||
launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ type: 'pwa-node', request: 'launch', program, args, cwd, env, stopOnEntry: !!stopOnEntry, console: 'internalConsole', outputCapture: 'std', runtimeExecutable: which('node') ?? process.execPath, skipFiles: ['<node_internals>/**'] }),
|
||||
attachArgs: ({ host, port, pid }) => (pid ? { type: 'pwa-node', request: 'attach', processId: String(pid) } : { type: 'pwa-node', request: 'attach', address: host ?? '127.0.0.1', port }),
|
||||
},
|
||||
'lldb-dap': {
|
||||
languages: ['c', 'cpp', 'rust', 'swift'], extensions: ['', '.out', '.bin'],
|
||||
detect: () => { const b = which('lldb-dap') ?? which('lldb-vscode'); return b ? { kind: 'stdio', command: b, args: [] } : null },
|
||||
install: 'apt install lldb (provides lldb-dap)',
|
||||
launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ type: 'lldb-dap', request: 'launch', program, args, cwd, env: Object.entries(env ?? {}).map(([k, v]) => `${k}=${v}`), stopOnEntry: !!stopOnEntry }),
|
||||
attachArgs: ({ pid }) => ({ type: 'lldb-dap', request: 'attach', pid }),
|
||||
},
|
||||
gdb: {
|
||||
languages: ['c', 'cpp', 'rust', 'go', 'fortran'], extensions: ['', '.out', '.bin'],
|
||||
detect: () => {
|
||||
const b = which('gdb')
|
||||
if (!b) return null
|
||||
try { const v = execFileSync(b, ['--version'], { encoding: 'utf8' }); const m = /(\d+)\.\d+/.exec(v); if (!m || Number(m[1]) < 14) return null } catch { return null }
|
||||
return { kind: 'stdio', command: b, args: ['--interpreter=dap'] }
|
||||
},
|
||||
install: 'apt install gdb (>= 14 has --interpreter=dap)',
|
||||
launchArgs: ({ program, args, cwd, stopOnEntry }) => ({ request: 'launch', program, args, cwd, stopAtBeginningOfMainSubprogram: !!stopOnEntry }),
|
||||
attachArgs: ({ pid }) => ({ request: 'attach', pid }),
|
||||
},
|
||||
dlv: {
|
||||
languages: ['go'], extensions: ['.go'],
|
||||
detect: () => { const b = which('dlv'); return b ? { kind: 'tcp-server', command: b, scriptArgs: (port) => ['dap', '--listen', `127.0.0.1:${port}`] } : null },
|
||||
install: 'go install github.com/go-delve/delve/cmd/dlv@latest',
|
||||
launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ request: 'launch', mode: program.endsWith('.go') || fs.statSync(program).isDirectory() ? 'debug' : 'exec', program, args, cwd, env, stopOnEntry: !!stopOnEntry }),
|
||||
attachArgs: ({ pid }) => ({ request: 'attach', mode: 'local', processId: pid }),
|
||||
acceptsDirectoryProgram: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function pickAdapter(program, forced) {
|
||||
if (forced) {
|
||||
if (!ADAPTERS[forced]) throw new Error(`unknown adapter ${forced}; available: ${Object.keys(ADAPTERS).join(', ')}`)
|
||||
return forced
|
||||
}
|
||||
const ext = path.extname(program ?? '').toLowerCase()
|
||||
if (['.py'].includes(ext)) return 'debugpy'
|
||||
if (['.js', '.mjs', '.cjs', '.ts', '.mts'].includes(ext)) return 'node'
|
||||
if (ext === '.go' || (program && fs.existsSync(program) && fs.statSync(program).isDirectory() && fs.existsSync(path.join(program, 'go.mod')))) return 'dlv'
|
||||
// native binary: prefer lldb-dap, then gdb
|
||||
for (const a of ['lldb-dap', 'gdb']) if (ADAPTERS[a].detect()) return a
|
||||
return 'lldb-dap'
|
||||
}
|
||||
|
||||
export function adapterStatus() {
|
||||
return Object.entries(ADAPTERS).map(([id, a]) => ({ id, available: !!a.detect(), languages: a.languages, install: a.install }))
|
||||
}
|
||||
|
||||
/** One debug session (root DAP connection plus any child sessions it spawns). */
|
||||
export class DebugSession {
|
||||
constructor(id, adapterId) {
|
||||
this.id = id
|
||||
this.adapterId = adapterId
|
||||
this.state = 'starting'
|
||||
this.stopped = null // { reason, threadId, description, text }
|
||||
this.breakpoints = new Map() // file -> [{line, condition, hitCondition, logMessage}]
|
||||
this.functionBreakpoints = []
|
||||
this.client = null
|
||||
this.children = []
|
||||
this.exitCode = undefined
|
||||
}
|
||||
|
||||
/** The client that currently owns execution (latest child with threads, else root). */
|
||||
get active() { return this.children.findLast((c) => !c.closed) ?? this.client }
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
constructor({ log = () => {} } = {}) {
|
||||
this.sessions = new Map()
|
||||
this.log = log
|
||||
this.seq = 0
|
||||
}
|
||||
|
||||
get current() { return [...this.sessions.values()].findLast((s) => s.state !== 'terminated') }
|
||||
|
||||
async connect(adapterId, cwd, { reusePort } = {}) {
|
||||
const a = ADAPTERS[adapterId]
|
||||
const spec = a.detect()
|
||||
if (!spec) throw new Error(`debug adapter ${adapterId} is not installed (${a.install}).`)
|
||||
if (spec.kind === 'stdio') return DapClient.spawnStdio(adapterId, spec.command, spec.args, { cwd })
|
||||
// Child sessions (js-debug startDebugging) connect to the parent's server, not a new one.
|
||||
if (reusePort) return DapClient.connectTcp(adapterId, { port: reusePort, cwd })
|
||||
const port = await freePort()
|
||||
const c = await DapClient.connectTcp(adapterId, { port, spawnCmd: spec.command, spawnArgs: spec.scriptArgs(port), cwd })
|
||||
c.serverPort = port
|
||||
return c
|
||||
}
|
||||
|
||||
wire(session, client, root = true) {
|
||||
client.on('stopped', async (b) => {
|
||||
session.state = 'stopped'
|
||||
session.stopped = { reason: b.reason, threadId: b.threadId, description: b.description ?? b.text, allThreadsStopped: b.allThreadsStopped, hitBreakpointIds: b.hitBreakpointIds, client }
|
||||
session.activeClient = client
|
||||
})
|
||||
client.on('continued', () => { if (session.stopped?.client === client) { session.state = 'running'; session.stopped = null } })
|
||||
client.on('exited', (b) => { session.exitCode = b.exitCode })
|
||||
client.on('terminated', () => {
|
||||
if (!root) return
|
||||
session.state = 'terminated'
|
||||
// The debuggee is gone: release adapters (children first, then the server-owning root).
|
||||
setTimeout(() => { for (const c of [...session.children, session.client]) c?.close('debuggee terminated') }, 300).unref?.()
|
||||
})
|
||||
client.on('closed', () => { if (root) session.state = 'terminated' })
|
||||
// js-debug: child targets arrive as startDebugging reverse requests.
|
||||
client.reverse.startDebugging = async (args) => {
|
||||
const child = await this.connect(session.adapterId, session.cwd, { reusePort: session.client.serverPort })
|
||||
session.children.push(child)
|
||||
this.wire(session, child, false)
|
||||
await child.request('initialize', initArgs(session.adapterId))
|
||||
const launched = child.request(args.request ?? 'launch', args.configuration ?? {}, 60000)
|
||||
await this.syncBreakpoints(session, child)
|
||||
await child.request('configurationDone', {}).catch(() => {})
|
||||
await launched.catch((e) => client.log('stderr', `child ${args.request}: ${e.message}\n`))
|
||||
return {}
|
||||
}
|
||||
client.reverse.runInTerminal = async (args) => {
|
||||
const { spawn } = await import('node:child_process')
|
||||
const p = spawn(args.args[0], args.args.slice(1), { cwd: args.cwd, env: { ...process.env, ...(args.env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], detached: false })
|
||||
p.stdout.on('data', (d) => client.log('stdout', String(d)))
|
||||
p.stderr.on('data', (d) => client.log('stderr', String(d)))
|
||||
return { processId: p.pid }
|
||||
}
|
||||
}
|
||||
|
||||
async start({ request, adapter, program, args = [], cwd, env, stopOnEntry, host, port, pid, timeoutMs = 30000, breakpoints = [] }) {
|
||||
if (this.current) throw new Error(`a debug session is already active (${this.current.id}, ${this.current.adapterId}); terminate it first`)
|
||||
const adapterId = pickAdapter(program, adapter ?? (request === 'attach' && port && !pid ? 'debugpy' : undefined))
|
||||
const id = `dbg${++this.seq}`
|
||||
const s = new DebugSession(id, adapterId)
|
||||
s.cwd = cwd
|
||||
s.program = program
|
||||
for (const bp of breakpoints) {
|
||||
if (bp.function) s.functionBreakpoints.push({ name: bp.function, condition: bp.condition })
|
||||
else s.breakpoints.set(bp.file, [...(s.breakpoints.get(bp.file) ?? []).filter((b) => b.line !== bp.line), bp])
|
||||
}
|
||||
this.sessions.set(id, s)
|
||||
try {
|
||||
s.client = await this.connect(adapterId, cwd)
|
||||
this.wire(s, s.client, true)
|
||||
s.capabilities = await s.client.request('initialize', initArgs(adapterId), timeoutMs)
|
||||
const initialized = s.client.waitFor('initialized', { timeoutMs })
|
||||
const a = ADAPTERS[adapterId]
|
||||
const reqArgs = request === 'attach' ? a.attachArgs({ host, port, pid }) : a.launchArgs({ program, args, cwd, env, stopOnEntry })
|
||||
const started = s.client.request(request, reqArgs, timeoutMs * 2)
|
||||
started.catch(() => {})
|
||||
await initialized
|
||||
await this.syncBreakpoints(s, s.client)
|
||||
if (s.capabilities?.supportsConfigurationDoneRequest !== false) await s.client.request('configurationDone', {}, timeoutMs).catch(() => {})
|
||||
await started
|
||||
if (s.state === 'starting') s.state = 'running'
|
||||
return s
|
||||
} catch (e) {
|
||||
s.client?.close('start failed')
|
||||
s.state = 'terminated'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async syncBreakpoints(s, client) {
|
||||
for (const [file, bps] of s.breakpoints) {
|
||||
const r = await client.request('setBreakpoints', { source: { path: file }, breakpoints: bps.map((b) => ({ line: b.line, ...(b.condition ? { condition: b.condition } : {}), ...(b.hitCondition ? { hitCondition: b.hitCondition } : {}), ...(b.logMessage ? { logMessage: b.logMessage } : {}) })) }).catch((e) => ({ error: e.message }))
|
||||
s.lastBreakpointResult = r
|
||||
}
|
||||
if (s.functionBreakpoints.length && client.capabilities?.supportsFunctionBreakpoints !== false) {
|
||||
await client.request('setFunctionBreakpoints', { breakpoints: s.functionBreakpoints.map((f) => ({ name: f.name, ...(f.condition ? { condition: f.condition } : {}) })) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async stopAll() {
|
||||
for (const s of this.sessions.values()) {
|
||||
for (const c of [...s.children, s.client]) {
|
||||
if (!c || c.closed) continue
|
||||
await c.request('disconnect', { terminateDebuggee: true }, 3000).catch(() => {})
|
||||
c.close('terminated')
|
||||
}
|
||||
s.state = 'terminated'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initArgs(adapterId) {
|
||||
return {
|
||||
clientID: 'dsh-omp-debug', clientName: 'DSH omp-debug', adapterID: adapterId, locale: 'en',
|
||||
linesStartAt1: true, columnsStartAt1: true, pathFormat: 'path',
|
||||
supportsVariableType: true, supportsVariablePaging: true, supportsRunInTerminalRequest: true,
|
||||
supportsMemoryReferences: true, supportsProgressReporting: false, supportsInvalidatedEvent: true,
|
||||
supportsMemoryEvent: false, supportsStartDebuggingRequest: true, supportsArgsCanBeInterpretedByShell: false,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user