💡【调试】按下 F5 后,mini-vscode 到底做了什么

前言

在 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 + JSONinitializelaunchsetBreakpointsconfigurationDone 分别在干什么?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 的时候,会通过registerSessionsession 回话中注册事件处理函数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 这几个请求分别在干什么?

  1. initialize:先对暗号,我是 client,1-absed 行号,用 path 表示文件,你是否支持
javascript 复制代码
await session.request('initialize', {
  adapterID: dapAdapterID(config),
  linesStartAt1: true,
  columnsStartAt1: true,
  pathFormat: 'path'
}, 15000)
  1. 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)
  1. setBreakPoints:我希望这些行有断点,帮我看看这些断点能不能绑定。adapter 会返回 verified 状态:
    1. verified: true -> 断点有效
    2. 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 只是配置断点,并不是真的可以让程序停住

  1. configurationDone:告诉 adapter, 配置结束了,可以开始跑了
javascript 复制代码
// 告诉 adapter:配置阶段结束,可以真正开始运行/继续被调试程序。
await session.request('configurationDone', {}, 15000)
// 等前面的lauch阶段结束
await started

js-debug 为什么会发 startDebugging?为什么还要创建 child session?

js-debug 发startDebugging,是因为他要让 IDE 再开一个真正调试目标的 sessionroot 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就是对应着 debugServicethis._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。

相关推荐
gyratesky3 分钟前
支持独立部署的地图方案
前端·gis
a1117764 分钟前
图片转3D模型 img2threejs 开源
前端·开源
老王以为5 分钟前
走进AI Agent第三篇:让 Agent 记住你
前端·人工智能·机器学习
全栈技术负责人13 分钟前
大模型流式输出核心技术:智能 Markdown 渲染引擎方案
前端·javascript·vue.js
码云之上14 分钟前
让聊天机器人学会用工具,星悟接 MCP 的实践
前端·人工智能·前端框架
用户9210802628619 分钟前
从单线程到事件循环:彻底理解 JS 的同步、异步、微任务和宏任务
前端
不一样的少年_28 分钟前
明明做了很多事,为什么简历看起来还是没含金量?
前端·后端·招聘
mashang12345678931 分钟前
mac安装SnailGitLite并配置Beyond Compare
前端·macos
雪芽蓝域zzs32 分钟前
第三十四节:用户新增编辑弹窗增加 el‑tree‑select 部门选择器
前端·javascript·vue.js
程序员老赵41 分钟前
Docker 部署 go2rtc:轻松搭建摄像头多协议流媒体平台
前端·docker·直播