生命周期

插件从安装到卸载的完整状态转换,以及激活、会话、线程模型。

插件在 IDE 中经历以下生命周期阶段:

install → start → pause ↔ resume → dispose → uninstall

install / uninstall 由 IDE 的文件系统与插件管理器处理;start / pause / resume / dispose 通过生命周期钩子回调到插件代码。每个阶段对应一个可选的钩子函数。

当前 PyriteIDE 实际发送的钩子只有 startdisposepause / resume 已定义在 SDK 协议中(LifecycleHook 枚举),但宿主尚未分发,因此不要依赖 on_pause() / on_resume() 在当前版本中触发。

激活与运行状态

IDE 侧维护每个插件的激活状态机,与钩子一一对应:

installed → enabled → activating → active
                    ↘ deactivating → enabled
  • installed — 插件已安装但尚未加载元数据。
  • enabled — 插件可用、处于"可被激活"状态,但进程尚未启动。
  • activating — 正在创建会话、执行握手并发送 start 钩子。
  • activestart 钩子返回成功,插件可以收发业务消息。
  • deactivating — 正在执行 dispose 钩子并回收会话。
  • failed — 启动或执行过程中出错。

插件不是默认启动的。是否被激活由清单中声明的激活事件activationEvents)决定,例如:

  • onStartup — IDE 启动时激活(见下方"自动启动")。
  • onView:<viewId> — 打开某个视图时激活。
  • onCommand:<commandId> — 执行某个命令时激活。
  • onLanguage:<languageId> — 打开某种语言的文件时激活。
activation_events = ["onView:my-plugin.main", "onCommand:my-plugin.format"]

当事件发生但插件尚未激活时,IDE 会按需激活它:先创建会话、完成握手,再发送 start 钩子,最后才把事件交给插件。多个视图或命令同时触发激活会共享同一个激活 Future 和会话,不会重复启动。

钩子函数

每个阶段对应一个可选的钩子函数,你可以在插件类中覆盖这些方法:

钩子调用时机典型用途
on_start()插件会话完成握手后初始化资源、启动后台任务、打开视图
on_pause()IDE 将插件切换到后台时释放前台资源、暂停动画
on_resume()IDE 将插件切回前台时恢复前台资源、刷新状态
on_dispose()插件被停用、IDE 关闭或插件被卸载时释放资源、保存状态

on_pause()on_resume()on_dispose()BasePlugin 上声明,可被任意插件类型覆盖。UiPlugin / ServicePlugin 必须实现 on_start()DataPlugin.on_start() 由 SDK 实现,你只需要实现 on_contribute();SDK 会在贡献完成后等待挂起响应并自动退出。

钩子可以是普通函数,也可以是 async 函数。SDK 会先调用处理器,再用 inspect.isawaitable 判断返回值;若返回协程则等待其完成,然后才回复宿主“钩子已执行完成”。宿主在收到回复前不会把插件标记为 active,但 Bridge 事件循环仍可处理响应和并发请求,因此 on_start() 发出的 API 请求能够正常回调。不要在钩子中执行阻塞操作;需要持续运行的逻辑应交给异步任务或后台线程。

启动时序

一次完整的激活流程如下:

IDE 宿主                   插件会话 (SDK Bridge)
   │                            │
   │  ide.initialize            │
   ├───────────────────────────►│  校验协议版本 / 插件 ID / 会话
   │                            │
   │  sdk.initialize            │
   │◄───────────────────────────┤  protocolVersion、sdkVersion、capabilities
   │  ide.initialized           │
   ├───────────────────────────►│  再次校验上下文,进入 ready
   │  sdk.ready                 │
   │◄───────────────────────────┤  握手完成,可以收发业务消息
   │                            │
   │  ide.lifecycle.hook (start)│
   ├───────────────────────────►│  调用 on_start()
   │                            │   (若返回协程则等待其完成)
   │  sdk.response.ok           │
   │◄───────────────────────────┤
   │  状态 → active             │

关键点:

  • 握手先于任何业务消息。宿主发出 ide.initialize 后,SDK 校验协议版本(PROTOCOL_VERSION = 1)、插件 ID、会话 ID 与代数是否与启动环境一致;任何一项不匹配都会收到协议错误并终止会话。
  • 四个阶段不可跳过或重排ide.initializesdk.initializeide.initializedsdk.ready 依次完成。双方只在 sdk.ready 后接受业务消息。
  • 序列号严格递增。每个方向独立维护 sequence;重复或逆序信封属于协议错误。请求使用 requestId 标识,响应通过 replyTo 指向原请求。
  • 身份字段覆盖整个会话。每个信封都携带 pluginIdsessionIdgeneration,宿主与 SDK 分别按当前上下文校验,过期信封不能进入业务处理器。
  • start 钩子等待回复waitForReply: true)。宿主在收到 sdk.response.ok 后才把插件标记为 active,并把激活事件 / 视图请求投递给插件。
  • on_start() 抛异常,SDK 返回 sdk.response.errorINTERNAL_ERROR),宿主把插件标记为 failed 并停止会话。

自动启动

声明了 onStartup 激活事件的插件,以及所有 DataPlugin,会在 IDE 启动时自动走一遍上述激活流程。DataPlugin 的启动原因是 data-startup,贡献完成后即退出,不保持运行。

UiPlugin 生命周期

from pyrite_sdk.api.native_views import FormField
from pyrite_sdk.core.plugin import UiPlugin


class MyPlugin(UiPlugin):
    def __init__(self):
        super().__init__()
        self.main_view = self.views.form(
            "my-plugin.main",
            title="My Plugin",
        )
        self.main_view.set_items(
            [FormField(id="message", label="Message", value="Hello World")]
        )

    def on_start(self):
        # 初始化:打开视图
        print("插件已启动")
        self.main_view.open()

    def on_pause(self):
        # 暂停:停止动画、释放前台资源
        print("插件已暂停")

    def on_resume(self):
        # 恢复:重新激活动画、刷新数据
        print("插件已恢复")

    def on_dispose(self):
        # 清理:释放所有资源
        print("插件已停止")


plugin = MyPlugin()
plugin.start()

ServicePlugin 生命周期

from pyrite_sdk.core.plugin import ServicePlugin


class MyService(ServicePlugin):
    def on_start(self):
        self.file.get_root_dir(
            lambda **result: print("Workspace root dir:", result.get("path"))
        )

    def on_dispose(self):
        print("服务已停止")


plugin = MyService()
plugin.start()

DataPlugin 生命周期

DataPlugin 的生命周期较特殊。调用 start() 后,Bridge 自动执行以下流程:

  1. 调用 on_contribute() — 你在此注册数据贡献
  2. 调用 stop_when_idle() — 所有挂起的请求完成后自动退出

stop_when_idle() 不是立即退出:它先设置"空闲即停止"标志,只有当所有待处理响应都返回后pending_responses == 0)才真正停止事件循环。因此如果你在 on_contribute() 里发起异步请求,插件会等这些请求的回调执行完毕再退出,不会中途被杀掉。

from pyrite_sdk.core.plugin import DataPlugin


class MyTheme(DataPlugin):
    def on_contribute(self):
        self.theme.contribute("my-theme", {
            "color.primary": "#1976D2",
            "color.onPrimary": "#FFFFFF",
        })
        # on_contribute 返回后,插件自动退出


plugin = MyTheme()
plugin.run_once()

__main__.py 中,DataPlugin 使用 plugin.run_once() 启动。它是 start() 的便捷包装,没有额外的 Python 版本要求。插件使用的 Python 版本由 manifest 与 IDE 内置运行时共同决定。

DataPlugin 不支持 on_pause()on_resume()。如果需要在贡献后保持运行,使用 ServicePlugin 替代。

会话与代数

每次插件激活对应一个会话,由 PluginContext 描述。其中有两个概念直接影响协议校验:

  • 会话 ID(session_id — 每次启动插件时由宿主重新生成的随机标识。
  • 代数(generation — 宿主维护的全局递增整数。每次创建插件会话时递增,重置 Python 运行时后也会再次递增。

每次激活都会产生新的会话 ID 和代数。SDK 的 Bridge 在每个信封上写入 pluginIdsessionIdgeneration,宿主收到后逐项比对,不属于当前会话的消息会被拒绝。

反过来,插件发起的请求若收到宿主的 invalid_context 错误响应(信封不属于当前会话,通常是会话已过期),SDK 会在回调的 error= 参数里给出 InvalidPluginContextError。典型场景:

def on_start(self):
    # 请求可能因会话过期而被宿主拒绝
    def on_root_dir(**result):
        error = result.get("error")
        if error is not None:
            print("请求失败:", error)
            return
        print("Root dir:", result.get("path"))

    self.file.get_root_dir(on_root_dir)

线程模型

插件运行在 IDE 内嵌的 Python 解释器中,入口是插件的 __main__.py。每个插件会话维护一个独立的 Bridge,其核心是一个 asyncio 事件循环

  • 宿主到插件的握手、生命周期、命令、事件和视图帧由该事件循环接收。需要等待的宿主请求会建立独立 asyncio.Task,因此任务可在 await 处交错执行。
  • API 成功和失败都进入同一个 callback=,并在 Bridge 事件循环中执行。它不是 Flutter UI isolate,也不应直接访问 Flutter 对象。
  • 事件循环中不要调用 time.sleep 或同步网络 IO,否则该插件的消息处理会停住。阻塞工作应放入 asyncio.to_thread、后台线程或异步 IO;多个异步任务共享可变状态时仍需考虑它们在 await 处交错。
  • self.bridge.push() 使用 loop.call_soon_threadsafe 投递,可从任意后台线程调用,是线程安全的。
  • 插件的 stdout / stderr 会被 BridgeOutputRouter 接管并路由回 IDE 的输出面板,即使是从派生的子线程打印也会被正确归属到该插件的输出流。输出按行分批(单批上限 64 KB)转发。

Bridge 的出站队列默认容量为 50,单轮最多发送 32 条消息。队列已满时,push() 失败并向等待响应的调用完成一个错误;持续高频数据应在插件侧合并,不能依赖无界缓冲。

宿主请求可带截止时间,SDK 在执行前检查 deadline。宿主发送 ide.request.cancel 时,SDK 取消对应任务;会话结束时所有未完成任务和挂起回调都会取消或以 TransportClosedError 完成。处理器应允许 asyncio.CancelledError 正常传播,以便停止流程及时收敛。

简化的数据流:

 IDE 宿主 ──transport──► 事件循环(Bridge)──► 分发到钩子 / 命令 / 事件处理器
                              ▲                        │
                              └──────── 回调 ──────────┘
  任意后台线程 ── bridge.push() ──► call_soon_threadsafe ──► 事件循环 ──► 宿主

目录与上下文

每个插件会话由 PluginContext 描述,可通过 self.context 访问:

  • self.context.id — 插件 ID
  • self.context.session_id / self.context.session — 会话 ID(每次启动重新生成)
  • self.context.generation — 会话代数(后端重启后递增)
  • self.context.plugin_dir / pluginDir — 插件目录
  • self.context.data_dir / dataDir — 数据目录(跨会话持久化)
  • self.context.cache_dir / cacheDir — 缓存目录
  • self.context.temp_dir / tempDir — 临时目录(会话结束后由宿主清理)
  • self.context.capabilities — 能力集(frozenset[str]

上下文通过环境变量注入插件入口(PYRITE_IDE_PLUGIN_IDPYRITE_IDE_PLUGIN_SESSION_IDPYRITE_IDE_PLUGIN_GENERATIONPYRITE_IDE_PLUGIN_DIRPYRITE_IDE_PLUGIN_DATA_DIRPYRITE_IDE_PLUGIN_CACHE_DIRPYRITE_IDE_PLUGIN_TEMP_DIRPYRITE_IDE_PLUGIN_CAPABILITIES),并在握手时与宿主再次交叉校验。若环境变量缺失或不完整,Bridge 构造时会抛出 PluginContextError,插件无法启动。

传输层还注入 PYRITE_IDE_PLUGIN_BRIDGE_PORTPYRITE_IDE_PLUGIN_BRIDGE_LABELPYRITE_IDE_DART_SESSION_TOKEN。这些值属于宿主与 SDK 的内部连接参数,插件代码不应读取、记录或覆盖它们。

临时目录位于 <插件 cache>/sessions/<sessionId>/tmp。会话正常退出后宿主删除该目录;需要跨会话保留的数据必须写入 data,可重建内容写入 cache

停止与恢复

停用插件时,宿主发送 dispose 钩子并等待回复,然后关闭传输。SDK 在 on_dispose() 完成后释放事件订阅、命令处理器、视图模型、挂起请求和请求任务。IDE 端随后清理事件总线订阅、视图存储和组件方法注册表,避免会话状态进入下一次激活。

内嵌运行时使用 daemon 目标执行插件入口,宿主不能像管理独立系统进程一样安全地强制终止单个目标。若插件阻塞且在停止超时内没有退出,会话保持 failed,临时目录也会保留到目标实际结束。整体“重启插件运行时”会先停止全部会话,再重置解释器;重置前创建的对象、回调和引用都必须视为失效。

插件启动失败会进入指数退避,等待时间从 1 秒开始,最大 60 秒。成功启动会清除失败次数。调用方不能用紧密循环反复启动;应显示剩余等待时间,并在允许重试后重新走完整的会话创建与握手流程。

On this page