Read OSS

扩展宿主:VS Code 如何隔离并与扩展通信

高级

前置知识

  • 第 1 篇:架构与分层
  • 第 2 篇:启动与进程架构
  • 第 3 篇:DI 引擎与服务模式
  • 理解 IPC 和 RPC 的基本概念

扩展宿主:VS Code 如何隔离并与扩展通信

VS Code 的扩展生态系统是将一个文本编辑器蜕变为开发平台的核心能力。但如果把成千上万个第三方扩展都运行在编辑器主进程中,崩溃、卡死和安全漏洞将会接踵而至。为此,VS Code 将扩展运行在独立的扩展宿主进程中,通过一套庞大且类型完备的 RPC 协议与工作台进行通信。本文将深入剖析这一架构。

三种扩展宿主类型

ExtensionHostKind 枚举定义了三种扩展宿主环境:

export const enum ExtensionHostKind {
    LocalProcess = 1,   // Node.js child process
    LocalWebWorker = 2, // Web Worker
    Remote = 3          // SSH/container/WSL
}
graph TD
    subgraph "Desktop VS Code"
        RENDERER["Renderer Process<br/>(Workbench UI)"]
        LP["<b>LocalProcess</b><br/>Node.js child process<br/>Full Node API access"]
        LW["<b>LocalWebWorker</b><br/>Web Worker<br/>Browser APIs only"]
    end
    
    subgraph "Remote Machine"
        SERVER["Remote Server"]
        REMOTE["<b>Remote</b><br/>Node.js process<br/>on SSH/container/WSL"]
    end
    
    RENDERER <-->|"Electron IPC /<br/>MessagePort"| LP
    RENDERER <-->|"Worker<br/>postMessage"| LW
    RENDERER <-->|"WebSocket"| SERVER
    SERVER <-->|"Node IPC"| REMOTE
    
    style LP fill:#e3f2fd
    style LW fill:#e8f5e9
    style REMOTE fill:#fff3e0

LocalProcess 是桌面版 VS Code 的默认宿主类型,扩展运行在 Node.js 子进程中,拥有完整的文件系统和网络访问权限,是最宽松的运行环境。在 manifest 中声明 "extensionKind": ["workspace"] 的扩展会在此环境中运行。

LocalWebWorker 面向轻量级、以 UI 为中心的扩展,这类扩展只需要浏览器 API。在 vscode.dev(Web 版 VS Code)上,这也是唯一可用的宿主类型。声明 "extensionKind": ["ui"] 的扩展会尽可能在此环境中运行。

Remote 将扩展宿主运行在完全不同的机器上。通过 SSH 连接时,Remote 扩展会在远端机器上启动一个无界面的 VS Code 服务器,由它负责启动扩展宿主进程。工作区扩展在远端运行并访问远程文件系统,而 UI 扩展则留在本地运行。

determineExtensionHostKinds() 中的分配逻辑会综合考量每个扩展声明的类型、本地或远程安装情况以及开发偏好,最终生成一份完整的映射关系。

协议契约:extHost.protocol.ts

工作台(主线程侧)与扩展宿主之间的通信,全部定义在一个单一的庞大文件中:src/vs/workbench/api/common/extHost.protocol.ts。这个文件将近 3,922 行,是整个代码库中最大的接口定义文件。

该文件采用成对接口的设计方式——每个 MainThread*Shape 接口(扩展宿主可调用的工作台方法)都对应一个 ExtHost*Shape 接口(工作台可调用的扩展宿主方法)。以下是部分示例:

MainThread(工作台侧) ExtHost(扩展宿主侧)
MainThreadCommandsShape ExtHostCommandsShape
MainThreadLanguageFeaturesShape ExtHostLanguageFeaturesShape
MainThreadSCMShape ExtHostSCMShape
MainThreadDebugServiceShape ExtHostDebugServiceShape
MainThreadWebviewsShape ExtHostWebviewsShape

文件末尾,两个对象负责注册所有代理标识符:

src/vs/workbench/api/common/extHost.protocol.ts#L3757-L3840

MainContext 对象注册了约 83 个工作台侧服务的代理标识符,ExtHostContext 对象注册了 60 余个扩展宿主侧处理器的代理标识符。两者合计定义了超过 140 个 RPC 接口,共同构成了扩展与工作台之间完整的通信契约。

提示: 实现新的扩展 API 时,从 extHost.protocol.ts 开始是最好的切入点。先添加 MainThread*ShapeExtHost*Shape 接口,在两个 context 对象中注册代理标识符,再分别实现两侧的逻辑。类型系统会帮你确保实现的完整性。

RPC 代理模式与 IPC 传输层

createProxyIdentifier<T>() 函数创建一个带类型的标记,RPC 系统据此自动生成代理对象。其工作流程如下:

sequenceDiagram
    participant Ext as Extension Code
    participant EH as ExtHostCommands
    participant RPC as RPC Protocol
    participant MT as MainThreadCommands

    Ext->>EH: vscode.commands.executeCommand('myCmd')
    EH->>RPC: proxy.MainThreadCommands.$executeCommand('myCmd')
    Note over RPC: Serialize to MessagePort/IPC
    RPC->>MT: $executeCommand('myCmd')
    MT->>MT: Execute command in workbench
    MT-->>RPC: Return result
    RPC-->>EH: Deserialized result
    EH-->>Ext: Promise resolves

扩展宿主启动时会收到一个 IExtHostRpcService,即 RPC 协议实例。两侧分别通过 rpcProtocol.set(identifier, handler) 注册用于处理入站调用的对象,并通过 rpcProtocol.getProxy(identifier) 获取代理,将方法调用转发到对端。

底层传输通过 IChannel/IServerChannel 接口进行抽象:

export interface IChannel {
    call<T>(command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
    listen<T>(event: string, arg?: any): Event<T>;
}

export interface IServerChannel<TContext = string> {
    call<T>(ctx: TContext, command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
    listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}

IChannel 抽象与传输层无关,同一套接口可以运行在 Electron IPC(ipc.electron.ts)、Node 命名管道(ipc.net.ts)、MessagePortipc.mp.ts)以及 WebSocket 之上。其上层的 RPC 协议层则在此基础上提供了自动序列化、取消传播,以及基于 RequestType/ResponseType 枚举协议的事件订阅管理。

构建 vscode.* 命名空间

扩展通过 import * as vscode from 'vscode' 引入的公共 API,是在运行时由 createApiFactoryAndRegisterActors() 动态构建的。

这个函数是整个 API 层的连接枢纽,它负责:

  1. 从扩展宿主的 DI 容器中解析约 30 个 ExtHost 服务实例,包括 extHostWorkspaceextHostConfigurationextHostCommandsextHostTerminalService 等。

  2. 将它们注册为 RPC 处理器,对每个实例调用 rpcProtocol.set(ExtHostContext.ExtHostCommands, extHostCommands),使工作台能够跨进程调用这些对象上的方法。

  3. 创建不受 DI 管理的额外 ExtHost 对象,如 ExtHostDocumentsExtHostNotebookController 等。

  4. 返回一个工厂函数,该函数接收扩展描述信息,为该扩展构建出一个量身定制的完整 typeof vscode 对象。

flowchart TD
    A["createApiFactoryAndRegisterActors()"] --> B["Resolve ~30 ExtHost<br/>services from DI"]
    B --> C["Register as RPC handlers<br/>rpcProtocol.set()"]
    C --> D["Create additional<br/>ExtHost objects"]
    D --> E["Return factory function"]
    E --> F["factory(extension)"]
    F --> G["Check proposed API access"]
    G --> H["Assemble vscode.* namespace"]
    H --> I["Extension receives<br/>typed API object"]

返回的工厂函数在每次扩展激活时调用一次。正是这种针对每个扩展的独立调用,实现了 proposed API 的访问控制——工厂函数会在将不稳定 API 纳入命名空间之前,先检查该扩展是否具有相应访问权限。

Proposed API 与扩展信任机制

公开的 vscode.d.ts 类型定义是稳定的 API 契约。但 VS Code 还在 src/vscode-dts/vscode.proposed.*.d.ts 下维护着数十个 proposed API 文件。这些不稳定的 API 仅对以下情况开放:

  1. product.jsonextensionEnabledApiProposals 中列出的扩展(微软及合作伙伴扩展)。
  2. --enable-proposed-api 标志在扩展开发模式下运行的扩展。

访问控制通过 checkProposedApiEnabled() 来实现,在 extHost.api.impl.ts 中每当访问 proposed API 时都会调用该函数:

// Example from the authentication namespace
checkProposedApiEnabled(extension, 'authLearnMore');
checkProposedApiEnabled(extension, 'authIssuers');

内置扩展(位于 extensions/ 目录下)是一类特殊的存在。它们随 VS Code 一起发布,可以访问内部 API,并随产品版本更新,而非通过扩展市场分发。这些扩展承载着核心功能:TypeScript 语言支持、Git 集成、Markdown 渲染、主题默认值等。

IExtensionHostInitData 接口定义了扩展宿主启动时收到的所有初始化数据:

export interface IExtensionHostInitData {
    version: string;
    quality: string | undefined;
    commit?: string;
    parentPid: number | 0;
    environment: IEnvironment;
    workspace?: IStaticWorkspaceData | null;
    extensions: IExtensionDescriptionSnapshot;
    telemetryInfo: { ... };
    logLevel: LogLevel;
    remote: { isRemote: boolean; authority: string | undefined; ... };
    uiKind: UIKind;
    // ...
}

这份数据包含了完整的扩展列表(含激活事件)、工作区标识、日志级别以及远程连接信息。扩展宿主据此决定激活哪些扩展以及何时激活。

扩展与工作台之间的数据流

让我们通过一个具体示例来梳理整个流程:当扩展注册一个 DocumentSymbolProvider 时,调用链将跨越四道边界:

sequenceDiagram
    participant Ext as Extension
    participant API as vscode.languages
    participant EH as ExtHostLanguageFeatures
    participant RPC as RPC Protocol  
    participant MT as MainThreadLanguageFeatures
    participant EditorModel as Editor Model

    Ext->>API: registerDocumentSymbolProvider(selector, provider)
    API->>EH: $registerDocumentSymbolProvider(handle, selector)
    EH->>RPC: proxy.$registerDocumentSymbolProvider(handle, selector)
    RPC->>MT: $registerDocumentSymbolProvider(handle, selector)
    MT->>EditorModel: Register provider adapter
    
    Note over EditorModel: User opens a file...
    
    EditorModel->>MT: Request symbols
    MT->>RPC: $provideDocumentSymbols(handle, uri)
    RPC->>EH: $provideDocumentSymbols(handle, uri)
    EH->>Ext: provider.provideDocumentSymbols(document)
    Ext-->>EH: SymbolInformation[]
    EH-->>RPC: Serialized symbols
    RPC-->>MT: Deserialized symbols
    MT-->>EditorModel: Display in outline

注册流程朝工作台方向流动,而实际的 provider 调用则朝扩展宿主方向流动。正是这种双向特性,决定了 MainContextExtHostContext 两套代理标识符同时存在的必要性。

下一篇

至此,我们已经完整梳理了服务基础设施:服务的声明与装配方式(第 3 篇)、进程间通信机制(本篇),以及扩展的接入方式。最后一篇文章将聚焦两大主要 UI 层——Monaco 编辑器引擎与工作台 shell——并探讨功能特性如何借助贡献系统组合成完整的 IDE 体验,将前几篇的内容融会贯通。