前言
在 VS Code 里,按下 F5 看起来只是一个很普通的动作:侧边栏切到 Run and Debug,程序启动,断点变红,停住后出现调用栈和变量。
但如果把这个动作拆开,会发现 F5 并不是"直接启动一个调试器",而是触发了一条跨进程、跨协议、跨 UI 状态的调试链路。
这篇文章基于 mini-vscode 的实现,沿着真实 VS Code 调试系统的核心分层,把这条链路拆开看一遍:renderer 负责命令和界面状态,preload 负责安全桥接,main 进程负责启动 debug adapter 和建立连接,DebugSession 负责用 DAP 和 adapter 通信,adapter 最后再去和真正的运行时打交道。
所以这篇不会一上来讲抽象概念,而是围绕一个具体问题往下追:按下 F5 后,到底是谁收到命令?launch 配置和断点怎么传到 main?DAP 为什么要用 Content-Length + JSON?initialize、launch、setBreakpoints、configurationDone 分别在干什么?js-debug 为什么还会反过来要求 IDE 创建 child session?程序停住之后,调用栈、变量、Watch 和 Debug Console 又是怎么显示到页面上的?
按下 F5 的第一瞬间,触发的是哪一个命令?
typescript
register(
'workbench.action.debug.startOrContinue',
'Start / Continue Debugging',
'Debug',
() => {
if (debugService.status === 'inactive') {
layoutService.setActiveView('debug')
layoutService.setSidebarVisible(true)
void debugService.start()
} else if (debugService.status === 'stopped') {
void debugService.continue()
}
},
'f5'
)
触发的是workbench.action.debug.startOrContinue,回调函数会判断当前是否处于调试状态中:
- 如果处于非调试状态,侧边栏切换到调试窗口;并调用
debugService.start()开始调试 - 如果处于调试下的
stopped状态,相当于走下一步continue
为什么 F5 不是直接启动调试器,而是先进入 renderer 的 command / debug service?
启动调试器的过程:在 workbench 层调用debugService.start(),然后通过preload层调用 main 进程的 debug service 的 start 方法。然后会新建进程打开 node 调试程序,并且与 node 调试程序建立 socket 连接。
这些操作都涉及到 node 底层 api 的调用,render 层不允许直接调用。
renderer 进程为什么不能自己启动 js-debug 或连接 socket?
renderer 层是基于 chrome 的渲染进程,而启动 js-debug 需要用到child_process,以及连接 socket 需要用到 net.creatconnection等 API,这些 API 渲染进程是没有权限调用的。必须经过 preload 作为 API 的中转站,这是对安全的一种保护
renderer 是通过哪条 IPC 通道把调试请求交给 main 进程的?
IPC 的全称是 Inter process communication,即进程之间的交流协议。
renderer 是渲染进程,通过下面的代码发 main 进程发起通知的。
typescript
// services/debug/debugServices.ts
const result = await window.electronAPI.debug.start(launchConfig, breakpoints)
而 main 进程的接收事件的代码是:
javascript
// main/ipc-routers.ts
ipcMain.handle(IPC_CHANNELS.DEBUG_START, (_e, config: unknown, breakpoints: unknown) => {
const win = this.windowManager.getMainWindow()
if (win)
return this.debugService.start(
win,
config as { type?: string; request?: 'launch' | 'attach'; program?: string },
breakpoints as { path: string; lines: number[] }[]
)
})
在这里,会调用debugService.start,并传入三个参数:
- win,渲染进程的窗口对象
- config,调试程序的配置,包含调试类型,请求方式,以及哪个程序之类的
- breakpoints,当前 workspace 中包含的所有的断点
然后debugService.start会使用上面三个参数发起调试的初始化
javascript
class DebugService {
async start(win: BrowserWindow, config: LaunchConfig, breakpoints: BreakpointSet[]): Promise<DebugStartResult> {
// 启动一个node debug server,或者根据配置找到对应的debug server,
// 然后返回server得host,port相关信息
const adapter = await this.resolveAdapter(config)
this.activeAdapter = adapter
// 初始化一个连接对象
const session = new DebugSession(adapter)
this.rootSession = session
this.registerSession(session, true)
try{
// 新建一个与debug server 调试的socket连接
await session.start()
// 一系列调试初始化的操作
// ...
// launch 阶段,发送breakpoints给adapter
const breakpointSets: BreakpointSyncResult[] = []
for (const bp of breakpoints) {
// initialized 之后,IDE 才把当前断点配置发给 adapter。
// adapter 会返回 verified 状态,renderer 用它决定红点是否实心/空心。
const body = await session.request('setBreakpoints', {
source: { path: bp.path, name: path.basename(bp.path) },
breakpoints: bp.lines.map((line) => ({ line })),
})
breakpointSets.push({
path: bp.path,
breakpoints: normalizeBreakpoints(body, bp.lines)
})
}
// ...
}catch{
}
}
private registerSession(session: DebugSession, active: boolean): void {
this.sessions.add(session)
if (active) this.session = session
// 注册session的回调事件
this.sessionDisposables.push(
session.onEvent((event, body) => this.forwardEvent(event, body)),
session.onRequest(request => this.handleAdapterRequest(request))
)
}
}
main 进程里的 DebugService 到底负责什么?
承接 render 层的请求,转发给DebugSession
javascript
// main/ipc-router.ts
ipcMain.handle(IPC_CHANNELS.DEBUG_REQUEST, (_e, command: string, args: unknown) =>
this.debugService.request(command, args)
)
// main/service/debug-service.ts
class DebugService {
request(command: string, args?: unknown): Promise<unknown> {
// renderer 的 continue/next/stackTrace/scopes/variables/evaluate 都会走这里
if (!this.session) return Promise.resolve(undefined)
return this.session.request(command, args)
}
}
renderer 层的请求,通过 IPC 协议,发送到 main 层,main 层调用DebugService.request转发给DebugSession
将 adapter 的请求转发给 renderer 层
typescript
// main/service/debug-service.ts
class DebugService {
async start(win: BrowserWindow, config: LaunchConfig, breakpoints: BreakpointSet[]): Promise<DebugStartResult> {
const session = new DebugSession(adapter)
this.rootSession = session
this.registerSession(session, true)
}
private registerSession(session: DebugSession, active: boolean): void {
// 每个 DebugSession 都会把 DAP event 推给 renderer。
// active=true 表示 UI 的 step/continue/evaluate 默认操作它。
this.sessions.add(session)
if (active) this.session = session
this.sessionDisposables.push(
session.onEvent((event, body) => this.forwardEvent(event, body)),
session.onRequest(request => this.handleAdapterRequest(request))
)
}
private forwardEvent(event: string, body: unknown): void {
// DAP event 是 adapter -> main -> renderer 的单向推送。
// 这里不要用 ipcRenderer.invoke,因为 event 没有"一问一答"的返回值。
if (!this.win || this.win.isDestroyed()) return
this.win.webContents.send('debug:event', { event, body })
}
}
在 start 的时候,会通过registerSession 向 session 回话中注册事件处理函数forwardEvent,在forwardEvent中,将 session 的的信息传递给 renderer 层
有个例外:adapter 层发出的
startDebugging请求是需要建立 child session,这个请求处理是 main 进程处理的
启动 debugSession,启动 debug server
javascript
// main/service/debug-service.ts
class DebugService {
async start(win: BrowserWindow, config: LaunchConfig, breakpoints: BreakpointSet[]): Promise<DebugStartResult> {
// 开启debug server
const adapter = await this.resolveAdapter(config)
// 创建session对象
const session = new DebugSession(adapter)
this.rootSession = session
this.registerSession(session, true)
//初始化session, 开启socket连接
await session.start()
}
下面是 session 中建立 socket 连接的方法:
javascript
// /main/debug/dap-session.ts
export class DebugSession {
constructor(private readonly adapter: DapAdapter) {}
async start(): Promise<void> {
await this.startTcp()
}
// 建立socket 连接
private async startTcp(): Promise<void> {
const { host, port } = this.adapter as DapTcpAdapter
const socket = await connectTcpSocket(host, port)
// ...
}
}
负责 adapter 初始化的工作 root session + child session
在启动 js debug 的时候,会建立两个 session,第一个是 root session,用来和 adapter 建立"启动/协调会话"。它接收初始配置,但 js-debug 可能要根据内部逻辑再创建真正的目标会话,这就是 child session,也就是真正承接 UI 操作的 session。有下面这些操作:
- continue
- next
- stackTrace
- scopes
- variables
- evaluate
- setBreakpoints
对于 root session 初始化上面的代码有提到,下面就讲讲 child session。这是一条 adapter 初始化完成后,反向要求 IDE 层创建的一条 session。
先在 session 代码中接收对应的请求事件:
typescript
class DebugSession {
private async startTcp(): Promise<void>{
const { host, port } = this.adapter as DapTcpAdapter
const socket = await connectTcpSocket(host, port)
// 监听adapter发送过来的data
socket.on('data', (d) => this.onData(d))
}
private onData(chunk: Buffer): void {
try {
// body 是完整 JSON 后,才进入 DAP 语义层。
this.handleMessage(JSON.parse(body) as DapMessage)
} catch {
// Ignore malformed adapter frames; the session remains alive.
}
}
private handleMessage(msg: DapMessage): void {
// DAP 消息有三类:
// response:回答我们之前的 request;
// event:adapter 主动通知状态变化;
// request:adapter 反过来要求 IDE 做事。
if (msg.type === 'response') {
// ...
} else if (msg.type === 'event') {
// ...
} else if (msg.type === 'request') {
void this.handleAdapterRequest(msg as DapRequest)
}
}
// 处理adapter 层发来的请求
private async handleAdapterRequest(msg: DapRequest): Promise<void> {
const request: DapIncomingRequest = {
seq: msg.seq,
command: msg.command,
arguments: msg.arguments
}
try {
// 遍历并调用 adapter请求的监听者
for (const listener of this.requestListeners) {
const result = await listener(request)
}
}catch{
//...
}
}
上面的 debugSession 代码完整地展示了,session 中如何从一开始监听 adapter 的事件,并且又是如何处理 adapter 的请求的。
在最后遍历并调用 adapter请求的监听者,那么这些监听者是从哪里来的呢?
typescript
class DebugSession {
onRequest(
listener: (request: DapIncomingRequest) => Promise<DapRequestResult | undefined> | DapRequestResult | undefined
): () => void {
this.requestListeners.add(listener)
return () => this.requestListeners.delete(listener)
}
}
DebugSession会暴露一个方法,这个方法可以供调用者注入requestListeners。
而在DebugService中,就会注入requestListeners:
javascript
// /main/services/debug-service.ts
class DebugService{
private registerSession(session: DebugSession, active: boolean): void {
// 每个 DebugSession 都会把 DAP event 推给 renderer。
// active=true 表示 UI 的 step/continue/evaluate 默认操作它。
this.sessions.add(session)
if (active) this.session = session
this.sessionDisposables.push(
session.onEvent((event, body) => this.forwardEvent(event, body)),
session.onRequest(request => this.handleAdapterRequest(request))
)
}
private async handleAdapterRequest(request: DapIncomingRequest): Promise<DapRequestResult | undefined> {
// js-debug standalone 的常见流程:
// root session 先接收 launch 配置,然后反向请求客户端 startDebugging;
// 客户端再创建 child session,child session 才是真正连 Node Inspector 的那条。
if (request.command !== 'startDebugging') return undefined
await this.startChildDebugSession(request.arguments)
return { body: {} }
}
}
handleAdapterRequest中会调用this.startChildDebugSession,从而创建 child session。
mini-vscode 目前只处理
startDebugging请求类型
DebugSession 是什么?它和 DebugService 的区别是什么?
DebugSession主要负责和 adapter 通信,解析 DAP 协议。DebugService 是调度 session,开启 js debug server,承接 UI层和 adapter 的交互通信。
DAP 是怎么通信的?为什么不是直接传 JSON,而是 Content-Length + JSON?
通过 socket 连接进行通信
- socket.write 向 socket 写东西
- socket.onData 监听 socket 的传输
因为这是 socket 传递信息是零碎的,onData 接收的消息是半条,有可能是多条。也就是半包或粘包。这个时候需要确定两个事情,一个是包的边界,一个是包的大小。
Dap 的格式是:
javascript
Content-Length: 58\r\n
\r\n
{"seq":1,"type":"request","command":"initialize"}
包的边界:\r\n\r\n
包的大小:58
数据包内容:从包的边界往后数 58 字节数,即indexOf('/r/n/r/n'), indexOf('/r/n/r/n') + 58
initialize -> launch -> setBreakpoints -> configurationDone 这几个请求分别在干什么?
initialize:先对暗号,我是 client,1-absed 行号,用 path 表示文件,你是否支持
javascript
await session.request('initialize', {
adapterID: dapAdapterID(config),
linesStartAt1: true,
columnsStartAt1: true,
pathFormat: 'path'
}, 15000)
launch,会根据 type,progrem,启动对应的程序(Node / attach Node Inspector)
javascript
const dapConfig = toDapLaunchConfig(config)
const startCommand = config.request === 'attach' ? 'attach' : 'launch'
const started = session.request(startCommand, dapConfig, 30000)
setBreakPoints:我希望这些行有断点,帮我看看这些断点能不能绑定。adapter 会返回 verified 状态:- verified: true -> 断点有效
- verified: false -> 断点暂时无效,可能是空行、source map 映射不到等
javascript
const breakpointSets: BreakpointSyncResult[] = []
for (const bp of breakpoints) {
// initialized 之后,IDE 才把当前断点配置发给 adapter。
// adapter 会返回 verified 状态,renderer 用它决定红点是否实心/空心。
const body = await session.request('setBreakpoints', {
source: { path: bp.path, name: path.basename(bp.path) },
breakpoints: bp.lines.map((line) => ({ line })),
})
breakpointSets.push({
path: bp.path,
breakpoints: normalizeBreakpoints(body, bp.lines)
})
}
setBreakPoints 只是配置断点,并不是真的可以让程序停住
configurationDone:告诉 adapter, 配置结束了,可以开始跑了
javascript
// 告诉 adapter:配置阶段结束,可以真正开始运行/继续被调试程序。
await session.request('configurationDone', {}, 15000)
// 等前面的lauch阶段结束
await started
js-debug 为什么会发 startDebugging?为什么还要创建 child session?
js-debug 发startDebugging,是因为他要让 IDE 再开一个真正调试目标的 session;root session 更像启动协调者,child session 才是真正的连 Node Inspector,收 stopped/stackTrace/variables 的调试通道
支持多目标调试
Node 调试不一定只有一个进程。可能有主进程、child_process、worker、auto attach。每一个目标都可以是一个独立的 debug session
让 IDE 管理 session 生命周期
adapter 可以请求"请帮我开一个 session",但真正创建 session、显示 UI、停止 session、切换 active session,这些应该由 IDE 管理。vscode 也是这个模型
把"启动协调"和"真实调试目标"拆开
root session 负责接受初始配置、触发 js-debug 内部启动逻辑。child session 负责真正的 launch/attach Node、设置断点、接受 stopped、拉调用栈和变量
当程序停在断点后,调用栈、变量、Watch、Debug Console 是怎么一步步显示到 UI 上的?
调用栈
停在了断点后,是 IDE 向 adapter 发送获取调用栈信息的请求:
javascript
class Debugservice implements IDebugService {
private async _onEvent(event: string, body: unknown): Promise<void> {
if (event === 'stopped') {
// stopped 只是告诉我们"某个线程停住了",还不包含完整调用栈。
// 所以收到 stopped 后,要主动再发 stackTrace 请求。
const b = body as { threadId?: number }
this._threadId = b.threadId ?? 1
this._status = 'stopped'
// 向adapter发送获取调用栈的请求
const st = (await window.electronAPI.debug.request('stackTrace', {
threadId: this._threadId
})) as { stackFrames?: StackFrame[] }
this._callStack = st?.stackFrames ?? []
const top = this._callStack[0]
// 栈顶帧的位置就是编辑器黄色箭头要高亮的位置。
this._activeFrameId = top?.id ?? null
this._stopLocation = top?.source?.path ? { path: top.source.path, line: top.line } : null
// 告诉所有依赖状态的UI重新render
this._onDidChangeState.fire()
}
}
}
在拿到调用栈信息之后,设置一些状态变量后,就出发 UI 更新了。依赖状态的 UI 有两个地方:Editor、DebugView(即侧边栏显示调用栈的地方)
Editor 的变化
javascript
// /renderer/components/editor/MonacoEditor.tsx
function MonacoEditor(){
const debugService = useService(IDebugService)
// 断点 / 当前行变化 → 重画装订线装饰
useEffect(() => {
const d2 = debugService.onDidChangeState(applyDebugDecorations)
return () => {
d2.dispose()
}
}, [debugService, applyDebugDecorations])
// 重画断点(装订线红点)+ 当前停靠行高亮
const applyDebugDecorations = useCallback((): void => {
const col = debugDecorations.current
if (!col) return
const decos: editor.IModelDeltaDecoration[] = debugService
.getBreakpoints(path)
.map(bp => ({
range: new monaco.Range(bp.line, 1, bp.line, 1),
options: {
glyphMarginClassName: bp.verified === false ? 'debug-bp-glyph-unverified' : 'debug-bp-glyph',
stickiness: 1
}
}))
// 找到当前的停止的行,并设置对应的行高亮
const loc = debugService.stopLocation
if (loc && loc.path === path) {
decos.push({
range: new monaco.Range(loc.line, 1, loc.line, 1),
options: {
isWholeLine: true,
className: 'debug-stop-line',
glyphMarginClassName: 'debug-stop-glyph'
}
})
}
col.set(decos)
}, [debugService, path])
return <>...</>
}
代码中的debugService.stopLocation就是在获取栈信息后立马设置的。
DebugView 侧边栏的变化
javascript
// renderer/components/debug/DebugView.tsx
function DebugView(){
const debug = useService(IDebugService);
// 调用栈信息
const stack = debug.callStack;
return <>...</>
}
debug.callStack就是对应着 debugService 中 this._callStack 状态,这个值也是在上面获取到了栈信息之后立马设置的。
Debug Console 的变化
如果在停住之后,adapter 有数据要在 console 中发出来,就会发送 type="event"的消息:
javascript
// /renderer/services/debug/debuServices.ts
private async _onEvent(event: string, body: unknown): Promise<void> {
if (event === 'output') {
// adapter/runtime 输出展示到 Debug Console。
const b = body as { output?: string; category?: string }
this._addConsoleEntry(b.category === 'stderr' ? 'error' : 'output', b.output ?? '')
}
}
private _addConsoleEntry(kind: DebugConsoleEntry['kind'], text: string): void {
// immutable 更新,保证 useEvent/useService 能感知引用变化并重新渲染。
if (!text) return
this._consoleEntries = [...this._consoleEntries, { id: ++this._consoleSeq, kind, text }]
this._onDidChangeState.fire()
}
this._addConsoleEntry对应的就是 debug console 的输出内容
总结
按下 F5 后,mini-vscode 并不是直接去调试代码,而是先触发 renderer 里的调试命令,由 renderer DebugService 收集 launch 配置和断点,再通过 preload 暴露的 IPC 能力交给 main 进程。main DebugService 负责选择并启动 debug adapter,然后创建 DebugSession,通过 DAP 的 Content-Length + JSON 协议和 adapter 通信。启动阶段会依次完成 initialize -> launch -> setBreakpoints -> configurationDone,真实 js-debug 还可能通过 startDebugging 要求 IDE 创建 child session。程序停住后,adapter 发出 stopped event,renderer 再主动拉取调用栈、变量和表达式结果,最后更新 DebugView、Monaco 当前行高亮、断点图标和 Debug Console。