按下 F5 后,mini-vscode 到底做了什么?

该项目基于 mini-vscode , 按 VS Code 调试链路的核心分层做了 mini 版复刻

按下 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。

相关推荐
TT_Close7 天前
别劝退了!5秒搞定 Flutter 鸿蒙 FVM 起跑线
flutter·harmonyos·visual studio code
Flynt14 天前
装上TypeScript 7.0 RC之后,最让我意外不是10倍提速
typescript·visual studio code
golang学习记18 天前
VSCode 1.124 新特性:使用快捷键重构AI协作的“心流”
visual studio code
WonderThink19 天前
AI编程:Claude Code + VSCode + CC-Switch
visual studio code
armwind21 天前
claude code接入deepseek报 “API Error: 400“
visual studio code
TT_Close23 天前
别再复制旧 Flutter 工程了,真正拖慢你的不是业务代码
flutter·npm·visual studio code
初出茅庐的1 个月前
vscode 的 样式提示失效了!!!
visual studio code
暗冰ཏོ1 个月前
ECharts 前端图表开发全攻略:参数配置、项目实战与高级可视化资源整理
前端·vue.js·echarts·visual studio code
bug远离Jemma1 个月前
Claude-code-windows安装步骤记录(无翻墙)
visual studio code