Skip to content

feat(plugins): 宿主调用插件函数入口(#1351 PR 3) - #1366

Open
xiaoyumuxi wants to merge 3 commits into
previewfrom
feat/1351-host-function-invoke
Open

xiaoyumuxi wants to merge 3 commits into
previewfrom
feat/1351-host-function-invoke

Conversation

@xiaoyumuxi

@xiaoyumuxi xiaoyumuxi commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

关联 #1351(PR 3:宿主调用插件函数入口)。基于已合并的 PR 1 (#1358) 的 Core 协议;Node SDK 在 #1365,验收插件 sdk/node/examples/sample-build 位于该 PR 分支。

功能点

插件自己的网页窗口现在可以列出、调用、取消自己构建 Provider 进程提供的插件函数,并收到结果、进度和插件错误码。宿主没有任何按插件名的分支。

改动清单

接线:Provider 以插件会话启动

  • LanguageServerRuntimeCore 新增 startPluginLanguageServer(pluginID:…) 和 requestPluginFunction(…),两者都有默认实现,旧的 Core 实现不用改。
  • RustCoreBridge:
    • lsp.startServer 可以带 pluginId。
    • 新增 lsp.functionRequest。
    • 运行时错误新增 pluginErrorCode 字段。
  • RustLanguageServerRuntimeAdapter 实现上面两个方法,并把 pluginErrorCode 映射进事件。
  • LanguageServerRuntimeSession:
    • 新增 pluginID,有它时用插件会话启动。
    • 订阅 functionsChanged 来维护 functionIDs,订阅 requestProgress 来分发进度。
    • 新增 invokeFunction:Core 拒绝时保留 plugin.function.* 码;任务取消时调用 lsp.cancelOperation。
    • 释放会话时清掉函数状态。
  • MacBuildProviderSessions:
    • 用所属插件的 pluginID 创建会话。
    • 新增 pluginFunctions(of:) 和 invokePluginFunction,复用正在运行的进程,否则按 send 的方式启动。
    • 共享语言服务器作为 Provider 时,以 plugin.function.unavailable 拒绝。

服务与功能模型

  • BuildProviderRequesting 新增两个方法,默认实现返回 unavailable。
  • BuildSystemService:
    • 新增 pluginFunctions 和 invokePluginFunction。
    • 实例不存在时返回 unavailable,没有 Provider 时返回原有的不支持错误。
    • captureOutput 改用 providerTarget(_:workspaceURL:)。
  • BuildSystemsFeatureModel:转发这两个方法。

网页桥接

  • PluginWebBridge 新增三个方法:
    • functions.list {instance} 返回 {functions}。它会启动 Provider,所以要先同意。
    • functions.invoke {instance, function, params?, call?} 返回 {value}。需要同意(与 build.* 相同:同一插件和工作区同意一次后,到关闭项目前不再询问);同一 call ID 不能重复使用;每页最多同时 16 个调用。
    • functions.cancel {call} 返回 {}。
  • 新增事件 functions.progress,只在调用时给了 call ID 才发送。
  • 关闭页面会取消该页面所有正在跑的调用。
  • 归属检查沿用 entry(),只能访问本插件的实例。Core 还会再校验一次 pluginID。
  • 错误码按 Core 原样返回(plugin.function.*、requestCancelled、requestTimeout),桥接错误新增 pluginErrorCode。MacPluginWebPage 的 JS 拒绝对象也带上 pluginErrorCode。
  • 进度回调类型统一为 @MainActor @Sendable,以兼容不同语言模式的 target。

文档、Note 与矩阵

  • shared/contracts/plugins/v1/README.md:
    • Plugin functions 一节写明 macOS 的入口。
    • 桥接方法表新增 functions.* 三行,事件表新增 functions.progress。
    • 补充错误码、调用上限和同意规则的说明(同意按插件和工作区记住,到关闭项目为止)。
  • 新增 Agent Note 2026-10-20-host-plugin-function-invocation.md,记录以下决策:
    • 只有插件自己的网页能调用,只对插件自己的进程调用,归属检查两层。
    • 结果映射规则,以及不留进程的依据。
    • lithe/host/* 暂缓及理由:没有真实需求,权限要和信任审查一起设计,以后按新协议版本加不会破坏兼容。
  • 原有的函数契约 Note 改为链接到新 Note。
  • 功能矩阵 plugin-functions:更新 macOS 证据、验证说明和备注,macOS 验证状态改为 verified(见下方原生验收)。实现状态仍为 partial,因为 Windows 没有插件后端宿主。Windows 不变。

测试

  • PluginWebBridgeTests 新增 3 个测试:
    • 只能访问本插件实例,并且必须同意。
    • 结果、进度和 pluginErrorCode 能到达页面。
    • 取消调用和关闭页面都会取消函数。
  • MacBuildProviderSessionsTests 新增 2 个测试,用脚本化的假 Core:
    • 以插件会话启动并带上 pluginID;函数列表、结果、进度、插件错误码和 Core 拒绝码都正确。
    • 取消会调用 cancelLanguageServerOperation;停用插件会停止 Provider。

本地验证

  • scripts/test-macos.sh --filter "PluginWebBridgeTests|MacBuildProviderSessionsTests|BuildSystemServiceTests":82 个测试全部通过。
  • swift build 通过。
  • 以下校验脚本全部通过:
    • verify-test-stability.sh
    • verify-service-boundaries.sh
    • verify-runtime-bundle-immutability.sh
    • verify-shared-contracts.sh
    • verify-agent-notes.sh
    • verify-platform-feature-matrix.sh
    • verify-platform-feature-matrix-change.sh origin/preview HEAD

macOS 原生验收(2026-10-20)

在 debug 应用里,用 #1365 的 sdk/node/examples/sample-build(加一个只用于验收的网页窗口)和另一个带构建系统的插件做了验收:

  • functions.list 返回 snapshot.read、workspace.build;成功构建一次后,列表里多出运行时注册的 snapshot.clear。
  • functions.invoke 正常返回 {value},页面依次收到三条 functions.progress。失败的项目返回 plugin.function.failed,并带上插件自己的 pluginErrorCode: sample.buildFailed。
  • functions.cancel 后调用以 requestCancelled 结束,构建子进程也随之退出。
  • 拒绝场景:
    • 另一个插件的页面访问该实例,得到 plugin.web.forbidden,并且在弹同意框之前就被拒绝。
    • 调用未注册的函数,得到 plugin.function.unknown。
  • 调用进行中关闭项目、停用插件或替换插件包,Provider 和构建子进程都会退出;退出应用后没有进程残留。
  • 验收发现 sample-build 在 rootUri 末尾带斜杠时会拒绝所有路径,修复已推送到 feat(plugins): 官方 Node 插件后端 SDK 与示例构建插件 (#1351 PR 2) #1365(8a2e04109)。
  • 验收还发现同意框的说明写错了:实际是同一插件和工作区只确认一次,不是每次调用都确认。本 PR 的 Note、README 和本描述已改正。

合并顺序

#1365 和本 PR 都修改了 shared/platform-feature-matrix/features/plugin-functions.json 和契约 README,后合并的那个需要解决冲突,两边内容都保留。

未完成

  • Windows 没有插件后端宿主,本 PR 不涉及。

…s on macOS

Build providers start as sessions of the plugin that owns them, so Core
negotiates litheFunctions. Plugin web pages list, invoke and cancel the
functions of their own instances through functions.* bridge methods,
with consent, progress events, Core error codes and pluginErrorCode.
Shared language servers serve no plugin's functions; lithe/host/* stays
closed (see the new Agent Note).

Refs #1351
@ghfind-review ghfind-review Bot added the review: medium ghfind author score; see https://ghfind.com label Oct 20, 2026
xiaoyumuxi added a commit that referenced this pull request Oct 20, 2026
macOS sends the workspace as a directory URI ending in '/', so the
sample's inside-workspace check rejected every path and each build
reported sample.config. Resolve the root before comparing, and make the
plugin_sdk_node end-to-end test send that form of rootUri.

Found during the macOS native acceptance of plugin functions (#1366).

Refs #1351
The sample-build plugin from #1365 was exercised in a debug app:
list, invoke with progress, plugin failure codes, cancel, cross-plugin
refusal, and process cleanup on project close and plugin disable.
plugin-functions is now verified on macOS.

Consent for functions.* is remembered per plugin and workspace until the
project closes, like build.*; the Note and the contract README said it
was asked on every call.

Refs #1351
@lithe-code-review

lithe-code-review Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Lithe Review

结论: ⚠️ 有建议
范围: preview ← feat/1351-host-function-invoke · head: 5c5df59

Findings

  1. [P2] 并发请求可绕过每页 16 个调用的上限
    macos/Sources/Lithe/Application/Features/PluginWebBridge.swift:472

    • 实际影响:同意提示等待期间并发到达的请求都能通过容量检查,获准后可同时启动超过 16 个 Provider 调用,削弱资源上限保护。
    • 触发条件:页面在尚未获准时并发发送至少 17 个不同调用;同一插件和工作区的同意请求会合并,获准后各请求继续执行。
    • 复现步骤:挂起同意提示,并发提交 17 个使用不同 call ID 的 functions.invoke,再批准提示;检查启动的调用数,当前流程可能超过 16。容量检查发生在 await consent 前,之后创建调用时没有再次检查。
    • 来源:本次引入。
  2. [P2] 调用结束后仍可重复使用同一 call ID
    macos/Sources/Lithe/Application/Features/PluginWebBridge.swift:469

    • 实际影响:实现只拒绝仍在运行的 ID;完成或取消后,defer 会移除记录,页面即可复用该 ID。旧调用排队中的进度事件可能因此与新调用使用相同 ID,页面无法可靠区分。新 Agent Note 也规定同一 ID 不能重复使用。
    • 触发条件:调用以 call: "c1" 完成或取消后,再用 call: "c1" 发起另一调用。
    • 复现步骤:发送带 call: "c1" 的调用,等待其结束,再发送第二个带相同 ID 的调用;当前代码会接受第二次调用。
    • 来源:本次引入。

Scope Check

  • 关联 Issue 或需求是否完成:关联 Issue [Epic] 保留声明式接入,为插件增加可执行后端与原生/Web UI 扩展路径 #1351 仍为 open;本 PR 完成了 macOS 插件网页调用自身 Provider 函数的入口,不代表 Issue 的端到端目标已完成。
  • 已完成范围:自有构建实例归属检查、调用同意、函数列表与调用、进度和插件错误码透传、取消及页面关闭清理。
  • 未完成范围:Issue 描述中的 Maven 最小闭环仍需构建调度与前后处理接入;Windows 插件后端宿主也未实现。PR 将这些列为后续工作。
  • 是否复用了已有上游能力,或对自研边界给出充分证据:复用了现有 Provider 会话与 Rust Core 的 LSP 函数调用、取消和事件机制;新增部分负责宿主编排与桥接,相关 Agent Note 记录了所有权边界。
  • 每个 Finding 是否由本次变更引入或加剧:是,两项均由新增调用管理逻辑引入。
  • 是否属于本次变更职责范围:是,均涉及本 PR 新增的调用上限与 call ID 契约。

Suggestions

  1. 在同意等待后、启动调用前再次检查并保留容量名额;确保并发请求不会都通过同一份旧计数,失败或结束时释放名额。
  2. 为页面维护已使用过的 call ID 集合,完成或取消后仍拒绝复用,并在页面关闭时清理。

Summary

整体接线沿用现有 Provider 和 LSP 生命周期,没有按插件名分支;归属、错误、进度、取消与资源清理路径均有对应实现。本次有两项非阻塞建议,涉及并发容量和调用标识的契约一致性。未发现需要阻塞合并的问题。

Verification

  • 实际检查:给定 base...head 的 16 个变更文件,重点追踪 PluginWebBridge、PluginWebPages 同意流程、BuildSystemService、MacBuildProviderSessions、LanguageServerRuntimeSession、Rust Core 适配器、相关测试及插件契约和 Agent Note;并读取了相关开发、审查、测试稳定性和 Agent Note 规则。未检查工作区状态或范围外文件。
  • 已提供的测试或 CI 结果:PR 描述报告 82 个相关 macOS 测试通过、swift build 与所列校验脚本通过,并报告完成 macOS 原生验收;提供的 Check Run 中 macOS 与 Windows CI gate 为 success,但 Swift 与 Windows 测试项显示 skipped。这些结果未在本次独立验证。
  • 本次未运行测试、构建、校验脚本或应用;未访问网络。
  • 非阻塞范围缺口:Windows 插件后端宿主尚未实现;Issue [Epic] 保留声明式接入,为插件增加可执行后端与原生/Web UI 扩展路径 #1351 的 Maven 端到端闭环尚未由本 PR 完成。此次没有可见 UI 布局或样式改动,因此不需要截图验证。

@xiaoyumuxi xiaoyumuxi left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

补充代码审查(head 5c5df59)。已有自动评论指出同意提示并发绕过 16 调用上限,以及完成后可复用 call ID,这里不重复发相同行内意见。另发现同意提示期间取消请求的时序缺口,详见行内评论。建议把调用 ID/生命周期的保留提前到异步同意之前,并补时序测试。本次为静态审查,未独立运行测试。

guard functionCalls.count < Self.functionCallLimit else {
throw PluginWebBridgeFailure.unavailable("This page already runs \(Self.functionCallLimit) function calls.")
}
try await consent(String(localized: "Run \(functionID) of \(entry.instance.displayName)"), workspaceURL: workspaceURL)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 等待同意期间,functions.cancel 无法取消已发起的调用。 functionCalls[call] 直到 await consent(...) 返回后才登记。页面先发送 functions.invoke({ call: 'c1', ... }) 并停在同意框,再发送 functions.cancel({ call: 'c1' }),第 250 行只会对一个不存在的任务执行 no-op 并返回 {};随后若同意框被批准,原请求仍会启动可能修改工作区的函数。建议在等待同意之前就保留调用记录/取消标记,同意后核对该标记再真正启动;补一个 invoke → cancel → approve consent 的回归测试。

- functions.invoke takes its slot under the 16-call limit and registers its
  call before awaiting consent, so concurrent requests cannot all pass a stale
  count and exceed the limit once consent is granted.
- functions.cancel during the consent prompt marks the call; it ends with
  requestCancelled after consent and the provider function never starts.
- A page's call ID is never accepted twice, also after the call ended, so late
  progress of an old call cannot be attributed to a new one; the set is
  released when the page closes.
- Contract and Agent Note describe the limit, cancel, and ID rules; tests cover
  invoke -> cancel -> approve, 16 calls pending consent plus one, and ID reuse.

@xiaoyumuxi xiaoyumuxi left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

第二轮深入审查:宿主调用链与故障时序

审查锚定 5c5df59f854485318528f65bca1aa5881f704f44,向下追踪到已有 Rust Core 的请求发送、管道写入、取消和进程终止实现。

结论

新增 4 条行内发现(1 个 P1、3 个 P2)。其中 P1 是新增函数入口在 MainActor 上同步写 Provider 管道,遇到不再读取 stdin 的后端时会阻塞 UI,连同取消和事件轮询一起被阻塞。建议合并前优先处理。

其他发现分别是:Provider 初始化期间的调用取消不能及时结束等待、把任意 Core details 当成稳定错误码、以及新的 scripted plugin start 忽略 initialState、破坏了原有初始化中停止测试的受控前置条件。

已有的“同意等待期间绕过 16 上限”“call ID 复用”和上一轮“同意框期间丢失取消”不重复发评论。本轮初始化等待问题发生在同意已结束、调用已登记之后,是另一个取消阶段。

实际验证与限制

  • 静态追踪:PluginWebBridge → BuildSystemService → MacBuildProviderSessions → LanguageServerRuntimeSession → RustCoreBridge → Rust RuntimeSession / SystemProcess。
  • 在 Linux / Swift 6.2.1 上运行了两个隔离探针。同步管道探针使用实际 Node SDK 后端,让一个函数占用 JS 线程不再读 stdin,然后在 MainActor 同步写入约 700 KiB 的另一个函数请求;100 ms 的 MainActor 计时器直到约 402 ms、外部 watchdog 强杀后端后才执行。这验证了阻塞机制,不是声称已经在完整 macOS 应用中实测同一时间值。
  • 共享启动 Task 的取消探针显示:取消等待者后 150 ms 仍未结束,只有释放 startup gate 后才得到 requestCancelled,与当前 task.value 后才检查取消的结构一致。
  • 错误码和测试 fixture 两项来自代码追踪;未声称观察到真实 CI 随机失败。
  • 未运行完整 macOS 测试、Swift 应用构建、Rust Core 端到端测试或 Windows 验证。探针不替代这些平台回归。

SDK 侧另外的进程回收缺口已评论在 #1365,避免在两个 PR 重复列同一问题。

}
let operation: LanguageServerRuntimeOperation
switch core.requestPluginFunction(
sessionID: sessionID, pluginID: pluginID, functionID: functionID, params: params, timeout: timeout

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 不要在 MainActor 上同步写入可能不再读取的 Provider 管道。 这里的 core.requestPluginFunction 是同步调用。实际链路通过 RustCoreBridge 的同步 FFI,进入 Rust RuntimeSession::function_request → send_request_or_fail → SystemProcess::write_input 的 write_all,并非只把操作放入非阻塞队列。

触发条件很具体:一个插件函数长时间占用 Node 线程、暂停调试或后端卡住而不再读取 stdin,页面又发起一个合法的大参数调用(如约 700 KiB)。管道满后这里会阻塞 MainActor;下面的 cancellation handler 尚未安装,页面取消和 Swift 事件轮询也无法执行。Core 的普通请求超时可以排队 completion/cancellation,但 outbound maintenance 因发送锁被持有而跳过,不能靠该超时保证这次写入返回。

隔离 Swift + 实际 SDK 后端探针已验证同一机制:100 ms 主 actor 计时器直到约 402 ms、外部强杀后端后才运行;未运行完整 macOS 应用。建议将可能阻塞的发送移出 MainActor,并提供有界发送/终止后端的逃生路径;补“不读 stdin + 大请求 + 用户取消”的原生回归测试。

timeout: Duration,
progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void
) async throws -> ToolingJSONValue {
let session = try await functionSession(for: target)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Provider 还在启动时,取消调用无法及时结束其等待。 此处 functionSession 最终会在 startSession / runningSession 中等待共享的非结构化启动 Task.value;它们只在 value 返回后才 Task.checkCancellation()。因此用户已经同意、调用已经登记后,在 Provider 初始化挂起时执行 functions.cancel 或关闭页面,仅标记调用 Task 为 cancelled,并不能结束它当前的 await。原 invoke Promise 和调用名额要一直保留到启动成功/失败;当前初始化预算可达 60 秒。

已用隔离 Swift 探针验证相同 await 结构:取消等待者后仍不结束,释放启动 gate 后才得到取消。这里不是上一轮同意框期间“取消根本没记住”的问题。建议给每个调用建立可独立取消的启动等待者,取消时立即结束该调用,但不要直接取消其他调用也在复用的共享启动 Task;补 cancel-during-initialize 测试。

case .failure(let error):
throw PluginFunctionFailure(code: error.details ?? error.code, message: error.message)
}
functionProgress[operation.operationID] = progress

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 只把已识别的 plugin.function 拒绝码从 details 提升为 code。 error.details ?? error.code 假定每个 Core details 都是稳定函数码,但真实传输错误不符合这个假定。例如 Rust SystemProcess::write_input 的写入失败返回 ProcessFailed,并用 .with_details(error.to_string()) 保存 Broken pipe (os error 32) 一类诊断文本;这条错误沿当前调用链返回后,会被作为页面的 error.code,丢失原有稳定代码并随平台/语言变化。

建议仅当 details 是允许的 plugin.function.* 契约码时使用它,否则保留 error.code,诊断仍作为 detail/message。可给 scripted Core 注入一个 code 为传输失败、details 为任意 OS 错误文案的拒绝,断言网页拿到的 code 不会变成诊断句子。本项来自真实错误构造与映射链路的静态核对,未在完整应用中注入 broken pipe。

])
),
LanguageServerRuntimeEvent(type: "stateChanged", state: "ready")
]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 新的 plugin start 假实现需要保留真正的 initializing 分支。 这里不论 ScriptedProviderCore.initialState 是什么,都立即排入 stateChanged: ready。而现有 releasingProvidersStopsReadyAndInitializingSessions(initializing: true) 专门构造 initialState: "initializing", readyAfterStop: true,目的是在收到 ready 前停止,并验证延迟 ready 不会触发构建请求。

本 PR 把 Provider 改走 startPluginLanguageServer 后,该配置被忽略:ready 现在可能在测试调用 retainProviders 前就被轮询处理,使“初始化中停止”的前置条件依赖调度顺序,或实际测成已经 ready 的停止。建议按 initialState 控制初始 ready 事件,并用显式 gate 保留停止后的迟到 ready 场景。这里指出的是 fixture 丢失受控时序,不声称本轮已经跑出 macOS CI 随机失败。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: medium ghfind author score; see https://ghfind.com

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant