// 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: ['/**'] }), 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, } }