生命周期
插件从安装到卸载的完整状态转换,以及激活、会话、线程模型。
插件在 IDE 中经历以下生命周期阶段:
install → start → pause ↔ resume → dispose → uninstallinstall / uninstall 由 IDE 的文件系统与插件管理器处理;start / pause / resume / dispose 通过生命周期钩子回调到插件代码。每个阶段对应一个可选的钩子函数。
当前 PyriteIDE 实际发送的钩子只有 start 和 dispose。pause / resume 已定义在 SDK 协议中(LifecycleHook 枚举),但宿主尚未分发,因此不要依赖 on_pause() / on_resume() 在当前版本中触发。
激活与运行状态
IDE 侧维护每个插件的激活状态机,与钩子一一对应:
installed → enabled → activating → active
↘ deactivating → enabledinstalled— 插件已安装但尚未加载元数据。enabled— 插件可用、处于"可被激活"状态,但进程尚未启动。activating— 正在创建会话、执行握手并发送start钩子。active—start钩子返回成功,插件可以收发业务消息。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.initialize、sdk.initialize、ide.initialized、sdk.ready依次完成。双方只在sdk.ready后接受业务消息。 - 序列号严格递增。每个方向独立维护
sequence;重复或逆序信封属于协议错误。请求使用requestId标识,响应通过replyTo指向原请求。 - 身份字段覆盖整个会话。每个信封都携带
pluginId、sessionId和generation,宿主与 SDK 分别按当前上下文校验,过期信封不能进入业务处理器。 start钩子等待回复(waitForReply: true)。宿主在收到sdk.response.ok后才把插件标记为active,并把激活事件 / 视图请求投递给插件。- 若
on_start()抛异常,SDK 返回sdk.response.error(INTERNAL_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 自动执行以下流程:
- 调用
on_contribute()— 你在此注册数据贡献 - 调用
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 在每个信封上写入 pluginId、sessionId 和 generation,宿主收到后逐项比对,不属于当前会话的消息会被拒绝。
反过来,插件发起的请求若收到宿主的 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— 插件 IDself.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_ID、PYRITE_IDE_PLUGIN_SESSION_ID、PYRITE_IDE_PLUGIN_GENERATION、PYRITE_IDE_PLUGIN_DIR、PYRITE_IDE_PLUGIN_DATA_DIR、PYRITE_IDE_PLUGIN_CACHE_DIR、PYRITE_IDE_PLUGIN_TEMP_DIR、PYRITE_IDE_PLUGIN_CAPABILITIES),并在握手时与宿主再次交叉校验。若环境变量缺失或不完整,Bridge 构造时会抛出 PluginContextError,插件无法启动。
传输层还注入 PYRITE_IDE_PLUGIN_BRIDGE_PORT、PYRITE_IDE_PLUGIN_BRIDGE_LABEL 和 PYRITE_IDE_DART_SESSION_TOKEN。这些值属于宿主与 SDK 的内部连接参数,插件代码不应读取、记录或覆盖它们。
临时目录位于 <插件 cache>/sessions/<sessionId>/tmp。会话正常退出后宿主删除该目录;需要跨会话保留的数据必须写入 data,可重建内容写入 cache。
停止与恢复
停用插件时,宿主发送 dispose 钩子并等待回复,然后关闭传输。SDK 在 on_dispose() 完成后释放事件订阅、命令处理器、视图模型、挂起请求和请求任务。IDE 端随后清理事件总线订阅、视图存储和组件方法注册表,避免会话状态进入下一次激活。
内嵌运行时使用 daemon 目标执行插件入口,宿主不能像管理独立系统进程一样安全地强制终止单个目标。若插件阻塞且在停止超时内没有退出,会话保持 failed,临时目录也会保留到目标实际结束。整体“重启插件运行时”会先停止全部会话,再重置解释器;重置前创建的对象、回调和引用都必须视为失效。
插件启动失败会进入指数退避,等待时间从 1 秒开始,最大 60 秒。成功启动会清除失败次数。调用方不能用紧密循环反复启动;应显示剩余等待时间,并在允许重试后重新走完整的会话创建与握手流程。