Skip to content

feat(plugins): 官方 Node 插件后端 SDK 与示例构建插件 (#1351 PR 2) - #1365

Merged
xiaoyumuxi merged 6 commits into
previewfrom
feat/1351-plugin-sdk
Oct 20, 2026
Merged

xiaoyumuxi merged 6 commits into
previewfrom
feat/1351-plugin-sdk

Conversation

@xiaoyumuxi

@xiaoyumuxi xiaoyumuxi commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

概述

Issue #1351 的 PR 2:提供第一个官方插件后端 SDK(Node,sdk/node)和一个经真实 Core 会话端到端验证的示例构建插件。SDK 只实现 shared/contracts/plugins/v1 中已有的 litheBuild 1 与 litheFunctions 1,不新增任何线上字段;不使用 SDK 的后端同样合法。插件作者只写处理器,Lithe 的统一管理规则由 SDK 变成默认行为。

改动点

新增 sdk/node(@lithe/plugin-sdk 0.1.0,零依赖,private)

  • src/framing.mjs:LSP Content-Length 分帧,头部 8 KiB、帧 64 MiB 上限,非 JSON 帧回 -32700。
  • src/protocol.mjs:协议常量、函数 ID / 插件错误码校验、文本截断、保留方法判断(生命周期方法与所有 lithe/*)。
  • src/errors.mjs:PluginError(code, message) 是唯一能设置 pluginErrorCode 的方式;其他异常回通用文案,不泄漏路径和堆栈(细节写 stderr);取消映射为 -32800。
  • src/backend.mjs:createBackend()
    • build.model/dependencies 处理类型化构建请求并补 format/version;只有存在处理器且宿主提供 v1 时才协商 litheBuild。
    • function(id, h) 声明初始函数;registerFunction/unregisterFunction 在回复 initialize 前直接改初始列表(可在 onInitialize 中按状态决定),之后通过 lithe/function/register|unregister 串行发送,宿主拒绝时回滚处理器。
    • 处理器上下文:signal、throwIfCancelled、绑定取消信号的 run、log;函数额外有 functionID、operationID、progress(截断/取整/夹紧,调用结束后忽略)。
    • 生命周期:初始化前 -32002、shutdown 后 -32600、未知方法 -32601、$/cancelRequest 中止对应调用;shutdown 中止全部调用并结束全部子进程;exit 在 shutdown 后退出码 0,否则 1。
    • 拒绝发送任何 lithe/* 方法(避免递归派发)、拒绝在 capabilities 中自行声明 litheBuild/litheFunctions;以 stdio 服务时把 console.log/info/debug 改到 stderr。
  • src/process.mjs:runProcess 不经 shell;POSIX 用进程组,取消时 SIGTERM → 宽限期后 SIGKILL 整个进程组;Windows 用 taskkill /T /F;输出有上限。
  • src/state.mjs:openStateStore 原子写(临时文件 + fsync + rename),解析真实路径后拒绝插件包内目录(在 mkdir 之前),保护只读包、代码签名和 Sparkle 增量基线。
  • src/index.d.ts:完整类型声明(已用 tsc --strict 检查)。
  • README.md:英文开发者指南(API、打包方式、保持 Lithe 统一管理的 6 条规则)。
  • examples/sample-build:示例插件,runtime 启动 Node,提供 sample 构建系统(模型/依赖来自 Provider)、函数 workspace.build(分步进度、逐项目运行命令、失败抛 sample.buildFailed)、snapshot.read,以及有快照时运行时注册的 snapshot.clear;状态写入 ${workspaceStateDir}。

测试

  • sdk/node/test/*.test.mjs(24 项,全部有界等待):协商、ID 规则(对照 function-protocol.json)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期与退出码、分帧、取消时结束忽略 SIGTERM 的孙进程、会话结束清理子进程、状态存储与只读包拒绝、.d.ts 导出与运行时一致。
  • rust/lithe-core/tests/plugin_sdk_node.rs(设置 LITHE_PLUGIN_SDK_NODE 时运行):示例插件经 Core 清单校验、启动计划、运行时版本检查后启动;验证构建模型/依赖解码、函数结果与进度、插件错误码、取消后进程被结束、重启后状态恢复与 snapshot.clear 重新注册,最后确认插件包文件清单不变、workspace 状态目录无残留。

CI 与文档

  • scripts/verify-rust-core-crates.sh:Rust Core lane 运行 SDK 单测(含 junit 报告),并以 LITHE_PLUGIN_SDK_NODE 打开端到端测试。
  • scripts/classify-ci-changes.sh / test-classify-ci-changes.sh:sdk/* 归入 rust_core,不再走“未分类 → 全量验证”。
  • shared/contracts/plugins/v1/README.md:新增 “Official backend SDK” 小节。
  • .agents/skills/develop-lithe/SKILL.md:归属表新增 sdk/。
  • Agent Note:新增 2026-10-20-plugin-backend-sdk.md(为什么做 SDK、零依赖复制分发、被否方案),并从函数契约 Note 链接。
  • 功能矩阵 plugin-functions.json:补 SDK 证据与验证步骤;状态不变(宿主仍无调用入口,Windows 的 taskkill 路径尚未在 Windows 上验证)。

后续修复(macOS 原生验收中发现)

  • sample-build 的 backend/main.mjs:先用 path.resolve 规范化工作区根目录。macOS 传来的目录 rootUri 末尾带 /,原来的检查会拒绝所有路径,每次构建都报 sample.config。
  • plugin_sdk_node.rs 端到端测试改用末尾带斜杠的 rootUri,覆盖这种情况。修复前该测试失败,修复后通过。
  • 验收过程见 feat(plugins): 宿主调用插件函数入口(#1351 PR 3) #1366。

不在本 PR

  • 宿主调用函数的界面入口、lithe/host/* 宿主能力(PR 3+)。
  • SDK 发布到 npm、Java SDK。

本地验证

  • node --test sdk/node/test/*.test.mjs:24 通过
  • LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node:通过
  • bash scripts/test-classify-ci-changes.sh、verify-test-stability.sh、verify-rust-core-comments.sh、cargo fmt --check、verify-shared-contracts.sh、verify-agent-notes.sh、verify-platform-feature-matrix.sh、verify-platform-feature-matrix-change.sh origin/preview HEAD、verify-runtime-bundle-immutability.sh:全部通过
  • 测试结束后无残留进程

Refs #1351

@ghfind-review ghfind-review Bot added the review: medium ghfind author score; see https://ghfind.com label 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
xiaoyumuxi added a commit that referenced this pull request Oct 20, 2026
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
@xiaoyumuxi
xiaoyumuxi marked this pull request as ready for review October 20, 2026 07:45
@lithe-code-review

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

Copy link
Copy Markdown

Lithe Review

结论: ⚠️ 有建议
范围: preview ← feat/1351-plugin-sdk · head: 8a2e041

Findings

  1. [P2] 构建结果的版本字段可覆盖 SDK 标记
    sdk/node/src/backend.mjs:579

    • 实际影响:Core 解码模型时要求 litheBuildModel 的版本为 1;处理器返回 version: 2 会覆盖 SDK 写入的版本,导致构建模型请求被拒绝,工作区模型无法加载。
    • 触发条件:构建处理器返回的对象带有非 1 的顶层 version,例如透传了含版本字段的对象。
    • 复现步骤:声明 build.model 处理器并返回 { version: 2, projects: [] },经 build.decodeModel 解码时 Core 会以 plugin.build.invalidOutput 拒绝结果。Core 的版本校验位于 rust/lithe-core/src/build/model.rs:747。
    • 来源:本次引入。
  2. [P2] 非 JSON 函数结果会使后端会话异常退出
    sdk/node/src/backend.mjs:449

    • 实际影响:处理器返回 BigInt 或循环对象时,响应编码中的 JSON.stringify 会抛错;该异常发生在未被捕获的 Promise 回调中,Node 默认会因未处理拒绝而退出。当前会话中的其他请求也会随进程退出而失败。
    • 触发条件:lithe/function/invoke 处理器返回不能 JSON 序列化的值。
    • 复现步骤:注册一个返回 1n 的函数并调用它;响应会进入 #respond、#write,最终在 sdk/node/src/framing.mjs:67 编码时抛错,而不是返回受控错误。
    • 来源:本次引入。

Scope Check

  • 关联 Issue 或需求是否完成:本 PR 完成了 Node 后端 SDK、示例插件及经真实 Core 会话的集成覆盖;Issue [Epic] 保留声明式接入,为插件增加可执行后端与原生/Web UI 扩展路径 #1351 的整体路线仍包含后续宿主调用入口、宿主能力及 Windows 后端宿主。
  • 已完成范围:构建 Provider 与函数协议协商、运行时函数注册、取消和子进程清理、状态恢复及插件包只读验证。
  • 未完成范围:Windows taskkill 路径尚未在 Windows 验证;函数宿主 UI 和 Windows 插件后端宿主不属于本 PR。
  • 是否复用了已有上游能力,或对自研边界给出充分证据:复用现有 LSP/JSON-RPC stdio 会话和 Core 的构建解码、调用编排;SDK 负责协议适配和插件侧生命周期,没有另建领域模型。Agent Note 记录了零依赖、复制分发及未采用 LSP npm 包的原因。
  • 每个 Finding 是否由本次变更引入或加剧:是,均位于新增 SDK 实现中。
  • 是否属于本次变更职责范围:是,二者涉及 SDK 承诺的构建结果规范化和函数响应处理。

Suggestions

  1. 在构建结果合并时让 SDK 的 format、version 覆盖处理器字段,或明确拒绝非 1 版本,并为覆盖/拒绝行为补测试。
  2. 在写响应前验证结果可 JSON 序列化且不超过 1 MiB;将无效结果转成受控错误,确保编码失败不会留下未处理拒绝。

Summary

整体边界与现有插件契约一致,示例覆盖了主要构建、函数、取消和状态恢复路径。未发现 P0/P1 阻塞项;上述两个结果边界值得在合并前补齐。没有可见 UI 改动,因此无需截图审查。

Verification

  • 实际检查的文件、提交和调用链:按指定 base...head 检查了新增 SDK、示例、Core 集成测试、共享契约、Agent Note 和 CI 分类;追踪了构建结果到 Core 解码及函数结果到 JSON-RPC 编码的路径。未检查工作区状态或未提交文件。
  • 已提供的测试或 CI 结果:PR 描述报告 Node SDK 测试、Core 端到端测试及其他本地验证通过;提供的 Check Run 列表中 Rust Core tests 为 skipped,不能据此视为 CI 已验证该端到端测试。
  • 本次未运行的测试:按只读要求,未运行代码或测试。
  • 仍存在的非阻塞风险或平台验证缺口:Windows 进程树清理路径尚未在 Windows 上验证;测试未覆盖构建处理器覆盖版本字段或返回不可 JSON 序列化值的情况。

@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 8a2e041)。已有自动评论指出构建结果 version 覆盖与非 JSON 结果编码异常,这里不重复发相同的行内意见。本次另外发现示例构建插件对并发快照写入、未知项目选择的处理不够可靠,详见两条行内评论。建议补回归测试。本次为静态审查,未独立运行测试。

// Step 1: prepare. Read the workspace file and the previous snapshot.
context.progress({ message: 'Preparing', percentage: 0 });
const config = await readConfig('.');
const previous = store.read('snapshot', null);

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] 并发 workspace.build 会覆盖快照代数。 previous 在运行子进程之前读取,但 record(previous, ...) 在异步 context.run 返回后才执行。如果页面同时调用两次 workspace.build,它们可能都读到 generation = N,随后各自写入 generation = N + 1;最后一次写入覆盖前一次,代数不再反映实际完成的构建次数。建议在提交快照时重新读取最新状态并同步生成新代数,或者序列化同一工作区的构建;补一个两次调用交错完成的测试。

context.progress({ message: 'Preparing', percentage: 0 });
const config = await readConfig('.');
const previous = store.read('snapshot', null);
const projects = selected ? config.projects.filter(({ name }) => selected.includes(name)) : config.projects;

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] 未知项目选择会被报告为构建成功。 当调用传入 { "projects": ["typo"] } 而配置里不存在此名称时,filter 返回空数组;下面的循环直接跳过,仍会 record(..., 'succeeded') 并发送 Done,造成未构建任何项目却成功的假阳性。建议验证 selected 的类型与所选项目是否全部存在,未知项目应返回明确的插件错误;增加相应测试。

- buildResult refuses a handler version other than 1 and always stamps
  format/version last, so a passthrough version can no longer break Core decoding.
- Function results are checked to be JSON-encodable and within 1 MiB before
  the response is written; #respond also turns an encoding failure into a
  controlled internal error instead of an unhandled rejection.
- sample-build reads the previous snapshot when a build ends, so overlapping
  workspace.build calls get consecutive generations.
- sample-build rejects an empty, malformed, or unknown project selection with
  a plugin error instead of reporting a successful empty build.
- TestHost can drive a backend child process; new tests cover all four cases.

@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.

第二轮深入审查:生命周期、资源回收与边界输入

审查锚定 33a69b24cf16454b8d5309a90c124548409dd63c。已对照新提交:上一轮的快照代数、未知项目选择,以及自动审查的版本覆盖、不可编码结果处理均已有修复,本轮不重复提交。

结论

新增 5 条行内发现(2 个 P1、3 个 P2)。优先解决两条 P1:函数失败后仍运行且无法按原调用取消的并行工具,以及后端强制退出后遗留的工具进程。它们直接影响“工作随调用/会话结束”的承诺,不只是示例代码问题。

实际验证

在 Linux / Node 22.16.0 上,使用从该提交读取且与 GitHub blob SHA 核对一致的 SDK 源码运行了独立边界测试。9 个用例中,普通调用/普通取消的对照用例通过,8 个期望正确行为的断言失败:其中 5 个对应行内发现,另外 3 个属于下面的低优先级健壮性观察。这里不是声称仓库原有测试有 8 个失败,也未运行完整 SDK 原测试套件、Rust Core 端到端测试或 macOS 应用。

可观测结果包括:正常 leader 退出后注册表为 0,但孙进程在 killAllProcesses() 后仍存活;强杀后端后 context.run 工具仍存活;并行工具启动失败后函数回 -32603,另一个工具仍被注册且仍运行;写入 undefined 后原快照变成缺失;分块输出“中”时流式回调得到替换字符,而最终捕获字符串正常。复现进程在测试 finally 中被显式清理。

另外 3 个低优先级健壮性观察

这些也已用 SDK 复现,但不把畸形宿主输入或非当前正常 Core 时序包装成合并阻塞项:

  • backend.mjs:267:#dispatch 先访问 message.malformed,后判断 message === null。发送合法分帧的 JSON null 会直接抛 TypeError,而非受控地拒绝无效 JSON-RPC 消息。应先验证 null/类型。
  • framing.mjs:25–36:8 KiB 头部上限只在尚未找到头部分隔符时检查。一个 chunk 同时包含超过 8 KiB 的完整头部和正文时会被接受;建议对找到的 headerEnd 同样检查上限,测试完整帧与分块帧两种情况。
  • backend.mjs:345–370:异步 onInitialize 挂起时收到 shutdown,随后初始化 hook 完成,会无条件把 phase 设回 ready,再次接受函数调用。应防止结束后的初始化恢复会话。注意:现有 Core 在尚未 initialized 时选择强杀而非发送 shutdown,因此这是 SDK 协议状态机的健壮性问题,不声称正常 macOS 关闭路径一定触发它。

范围与缺口

追踪了 SDK → JSON-RPC → Rust Core 的进程终止边界及相关 Swift 接线;没有要求修复无关历史模块。Windows 的 taskkill 路径与 macOS 原生进程回收仍需平台回归验证。建议把本轮失败场景加入生命周期测试,而不只检查单个直接子进程和顺序成功路径。

Comment thread sdk/node/src/backend.mjs
})
.then(({ result, error }) => {
onSettled?.();
this.#incoming.delete(key);

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] 函数失败时先收回仍在运行的并行工具,再删除调用记录。 这里结束请求只删除 #incoming,没有中止该调用的 controller,也没有清理它的 context.run 子进程。正常的并行构建就能触发:先启动一个持续运行的工具,再 await Promise.all([slowTool, context.run('/不存在的工具')]);第二个启动报 ENOENT 后,函数立即返回 -32603,第一个工具仍继续运行/修改工作区。此时再发送原 ID 的 $/cancelRequest 也无效,因为记录已被删除。

已在本提交 SDK 上复现:失败响应到达后 runningProcessCount() === 1,随后取消原调用,工具仍存活。建议为每次调用维护资源所有权,在成功/失败终结路径清理尚未完成的工具,而不只依靠宿主显式取消;同时避免误停其他调用。补一个“一项并行工具失败,兄弟工具仍在运行”的回归测试。

Comment thread sdk/node/src/process.mjs
shell: false,
windowsHide: true,
// Its own process group on POSIX, so a stop reaches grandchildren too.
detached: !isWindows,

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] 独立进程组缺少后端强制退出后的清理责任方。 新 SDK 把每个工具放入独立进程组,但组号仅保存在后端 JS 的 running 集合中。当前 Core 的 rust/lithe-core/src/lsp/interface/process.rs 中 SystemProcess::terminate() 只对后端调用 Child::kill();初始化未完成时停止、关闭超时等路径都会使用它。后端一旦被 SIGKILL 结束,SDK 的 #finish / killAllProcesses 不会执行,工具也不会随着后端 PID 一起退出。

已用实际 SDK 后端复现:函数通过 context.run 启动持续运行的 Node 工具,收到其 PID 后对后端发送 SIGKILL,后端退出后该工具仍存活。这是本次 detached 工具与现有 Core 停止边界的集成缺口。建议把进程树/进程组所有权交给能在后端死亡后继续清理的宿主或监督进程,并覆盖后端崩溃/强杀;仅增加 JS exit 回调无法处理 SIGKILL。

Comment thread sdk/node/src/process.mjs Outdated
settled = true;
// A stopped tree stays registered until its forced stop: the leader
// can exit on SIGTERM while a grandchild in its group ignores it.
if (!entry.stopping) running.delete(entry);

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] leader 正常退出不代表整个进程组已经结束。 finish 在直接子进程 close 时删除注册项并移除取消监听,但一个启动器可以启动同组的长期工作进程(stdio 设为 ignore),然后自己正常退出。此时 entry.stopping 为 false,仍有成员存活的组会从注册表消失,后续 killAllProcesses() 以及原 signal 的取消都到不了它。

已复现:通过 runProcess 启动一个 spawn 孙进程后 unref() 并退出 0 的 leader;runProcess 成功返回、注册表为 0,而调用 killAllProcesses() 后孙进程仍活着。这与后端被强杀是不同路径:后端本身一直正常运行。建议在 leader 结束时清理剩余组成员,或保留可验证的组所有权直到组确实结束,并补 leader 先退出的回归场景。

Comment thread sdk/node/src/state.mjs Outdated
/** Stores `value`, which must be JSON-serializable, replacing the previous one. */
write(key, value) {
const path = this.#path(key);
const body = JSON.stringify({ litheStateStore: STORE_FORMAT, value });

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] undefined 会静默覆盖已有状态,而不是触发 invalidValue。 这里序列化的是 envelope 对象,故 value === undefined 时仍得到字符串 {"litheStateStore":1},下面的 body === undefined 分支不会执行;随后原子替换会覆盖旧快照,read 又因缺少 value 返回 fallback。

已复现:store.write('snapshot', {generation:8}); store.write('snapshot', undefined) 不抛错,重新读取得到 MISSING,原有数据已丢失。函数值/Symbol 等同样会在对象属性序列化时被省略。建议在替换文件前验证 value 的编码结果不会被省略,无效输入应抛 sdk.state.invalidValue 并保留旧文件;补“错误写入不破坏原状态”的测试。

Comment thread sdk/node/src/process.mjs Outdated
let truncated = false;
return {
push(chunk) {
listener?.(chunk.toString('utf8'));

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] 流式输出按 Buffer 单独解码会损坏跨 chunk 的 UTF-8 字符。 chunk.toString('utf8') 不保留上一块未完成的字符。管道可以在任意字节处分块,因此中文日志、路径或其他多字节文本会在 onStdout / onStderr 中变成替换字符;最终 stdout.text() 因先拼 Buffer 再解码反而正常,已有捕获输出测试测不到此问题。

已用握手控制分块复现:子进程先写“中”的第 1 个 UTF-8 字节,收到父进程确认后再写余下字节;流式回调拼接得到“���”,最终 result.stdout 为“中”。建议每条流用独立的 StringDecoder(并在结束时 flush),或让 stream 自己保留 UTF-8 解码状态,补跨块字符测试。

- Abort the call signal once the handler settles so tools it left running stop
- Swallow rejections of unawaited run() calls so they cannot crash the backend
- Track an initializing phase; a shutdown during initialize keeps the session closed
- Refuse non-object JSON values and oversized headers even when the frame arrives whole
- End a tool's process group when its leader exits normally
- Decode streamed output with StringDecoder so split UTF-8 characters stay whole
- Refuse state values JSON would drop instead of storing undefined
- Document that ending what a killed backend left is the host's job
…vironment

runProcess with env used to drop LITHE_PROCESS_SCOPE and LITHE_PLUGIN_BACKEND_SCOPE,
so the host could not find what a killed backend left. They are now carried over
and cannot be forged by the plugin.
@xiaoyumuxi
xiaoyumuxi merged commit 7654719 into preview Oct 20, 2026
11 of 15 checks passed
xiaoyumuxi added a commit that referenced this pull request Oct 20, 2026
* feat(plugins): invoke plugin functions from the plugin's own web pages 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

* docs(plugins): record macOS native acceptance of plugin functions

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

* fix(plugins): reserve page function calls before 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.

* fix(plugins): keep plugin calls off the main actor and end what a backend leaves

- Send plugin function and build requests to Core off the main actor; hold events
  until the operation is registered, and end a backend whose blocked write outlives
  the call's timeout or a short grace after cancel or stop
- Cancel operations and stop a session with a write in flight off the main actor
- Let each caller stop waiting for a shared provider start without cancelling it
- Promote only Core's plugin.function refusal codes from details; keep transport
  codes and leave OS error text in the message
- Start each plugin backend with its own LITHE_PLUGIN_BACKEND_SCOPE and end the
  processes carrying it once the session is released
- Scripted provider start honours initialState so the initializing-stop test keeps
  its late ready reply

@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.

合并后深度复查:SDK 边界与故障路径

审查基线为最终 head 7213936a068a498a4cb78e692c242f44b8db7c1c,不是最早的 8a2e041。已核对现有评论和后续修复;不重复报告已修复的版本覆盖、BigInt/循环结果崩溃、并行工具失败清理、undefined 状态覆盖、UTF-8 分块等问题。

本轮新增 7 条行内意见:6 条 P2、1 条 P3。重点是现有修复尚未覆盖的 exit/close 区别、单次调用取消与后台进程所有权、处理器和流回调的错误边界、发送背压,以及实际消费端类型声明解析。

验证范围

  • 在 Linux / Node v22.16.0 上对本 head 的实际 SDK 源码执行定向探针,相关文件的 Git blob SHA 已核对一致。
  • TypeScript 5.8.3:按 README 的相对 .mjs 导入方式编译消费端,并用相同声明字节改为 .d.mts 做对照。
  • 核对 #1366 的 onReleased 后端清理接线,区分“整个 Provider 结束”与“只取消一个函数”。
  • 未运行完整 macOS 应用、Windows 分支、Rust Core 端到端测试或全量上游单测;不会把上述定向验证描述成全平台验收。

PR 已合并,以下问题建议作为后续修复处理。

Comment thread sdk/node/src/process.mjs
outcome();
};
child.once('error', (error) => finish(() => reject(error)));
child.once('close', (exitCode, exitSignal) =>

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] 把组清理挂在 close 上,会被继承输出管道的孙进程反向阻塞。 现有 finish 中的 SIGKILL 修复了孙进程 stdio: 'ignore' 的情况,但这里仍只在直接子进程的 close 后调用它。启动器若 spawn(..., {stdio: ['ignore', 1, 2]}).unref() 后正常退出,孙进程仍持有同一 stdout/stderr;leader 已经 exit,却不会产生 close,于是清理孙进程的代码永远等不到执行,await context.run(...) 也不结束。

在本 head 实测:leader 已退出、孙进程仍存活、Promise 未完成,runningProcessCount() === 1;显式清理后 Promise 才返回。建议在 exit 时启动剩余进程组的回收,流的最终收尾另做有界等待;补一个“leader 正常退出,孙进程继承输出管道”的测试,而不只测 stdio ignore。

Comment thread sdk/node/src/process.mjs
signalTree(entry.child, 'SIGTERM');
entry.forceTimer = setTimeout(() => {
running.delete(entry);
signalTree(entry.child, 'SIGKILL', { evenAfterExit: true });

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] 单次调用取消清不掉另起进程组的后代,宿主的会话级兜底也不会在此时运行。 signalTree 仅向 -child.pid 发信号。工具启动 detached: true 的后台进程后,该后代就不在这个组内;计时器仍删除登记项,随后 killAllProcesses() 也找不到它。

本 head 实测:取消后 runProcess 以 AbortError 结束、注册表归零,但 detached 孙进程仍存活,调用 killAllProcesses() 后仍存活。#1366 新增的 scope 标记只在 session.onReleased 等路径触发宿主清理;这里只取消一个函数,Provider 继续服务,所以不能靠那条路径补救。这不同于之前“后端被 SIGKILL”的问题。

建议把逃离进程组的后代纳入可按调用回收的所有权/监督机制,避免为清一个调用误杀整个 Provider 的其他调用,并覆盖“取消函数但保留会话”的测试。

Comment thread sdk/node/src/backend.mjs
log: (...parts) => this.log(...parts),
};
Promise.resolve()
.then(() => invoke(context))

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] 执行处理器前没有检查已收到的取消,导致返回 cancelled 时副作用已经发生。 #run 先登记 controller,再把 invoke(context) 排入微任务;同一输入 chunk 后面的 $/cancelRequest 可以先把 signal 置为 aborted,但这里仍执行处理器,直到处理器返回才 signal.throwIfAborted()。

把一次 snapshot.clear 请求和对应取消帧一起送入已初始化的后端,处理器只执行 writes++; return null:本 head 实测 writes === 1,同时响应为 -32800。真实的同步删除快照或写文件函数会呈现“已经取消,实际仍修改”的行为。

建议在 invoke(context) 之前也检查 signal;补“请求与取消同一 chunk,取消已处理但 handler 尚未开始”的回归测试。

Comment thread sdk/node/src/process.mjs
let size = 0;
let truncated = false;
const deliver = (text) => {
if (text) listener?.(text);

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] 输出回调抛异常会越过请求错误边界,直接终止整个后端。 listener 在 child.stdout/stderr 的 data 事件栈上执行,没有把异常转成 runProcess 的拒绝。因此处理器即便写了 try { await context.run(..., {onStdout: chunk => JSON.parse(chunk)}) } catch (...) { ... },也接不到格式错误,SDK 的 #run 也捕获不到。

本 head 的独立 Node 后端探针中,工具输出非 JSON 后进程退出码为 1、stderr 有 SyntaxError,函数没有收到任何响应;这会连带中断会话中的其他调用,而不只是当前工具失败。建议捕获每条输出流及 decoder flush 的回调异常,终止该工具树并 reject 所属调用;补一个“流回调抛错但后端仍可处理下一请求”的测试。

Comment thread sdk/node/src/backend.mjs

#write(message) {
if (!this.#output || this.#phase === 'exited') return;
this.#output.write(encodeFrame(message));

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] 忽略输出背压使进度帧在宿主暂停读取时无界堆积。 write() 返回 false 并不会抛错,当前实现仍持续编码和排队;单条进度消息的 1024-byte 上限不能限制累计队列。宿主暂停、调试或读取变慢时,频繁报告进度的普通长任务就可以把后端内存持续推高。

探针先完成 initialize,再让 Writable 停止完成写入(highWaterMark=64),从一个函数发送 8192 条各自合法的进度:writableLength 增至 9,510,233 bytes,writableNeedDrain === true,SDK 没有施加任何边界。

建议实现有界发送队列并处理 drain,或对进度做合并/限流和丢弃策略;响应及生命周期消息也需要明确的排队预算。补“初始化后宿主停止读取,持续发送进度”的测试。

Comment thread sdk/node/src/index.d.ts
@@ -0,0 +1,142 @@
// Type declarations for the Lithe plugin backend SDK. Keep in sync with the

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] 声明文件名与 README 推荐的相对 .mjs 导入不匹配。 文档要求复制 src/,然后按 ./vendor/lithe-plugin-sdk/src/index.mjs 导入。该相对路径绕过 package.json 的 exports.types;在 NodeNext 下,.mjs 对应的是 .d.mts,这里的 index.d.ts 不会被消费端找到。

TypeScript 5.8.3 实测:一个 .mts 消费端相对导入 src/index.mjs,执行 tsc --strict --noEmit --module nodenext --moduleResolution nodenext --skipLibCheck,得到 TS7016,处理器参数同时变成隐式 any。只把完全相同的声明字节另存为 index.d.mts,同一命令立即通过。

建议发布/复制时提供匹配的 .d.mts 并同步 exports,增加按 README 实际路径导入的消费端测试;仅检查导出名字或单独编译 .d.ts 测不到这一问题。

Comment thread sdk/node/src/framing.mjs
for (;;) {
const headerEnd = this.#buffer.indexOf(HEADER_END);
// The same bound whether the header arrived whole or in pieces.
if (headerEnd > MAX_HEADER_BYTES || (headerEnd < 0 && this.#buffer.length > MAX_HEADER_BYTES)) {

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.

[P3] 合法的最大长度头部会因分块位置不同而被拒绝。 完整到达时,8192-byte header 加 \r\n\r\n 可以通过;但若第一块恰好包含该 header 和终止符的第一个 \r,此时 buffer 长 8193、headerEnd 为 -1,这个条件立即抛错。同一帧完整输入可解析为 {},拆在 8193 处却得到 The message header is too long.,已在本 head 复现。

建议未找到完整分隔符时允许最多 3 个可能属于终止符的尾字节,再按实际 header 长度判定;增加接近上限时各个分隔符拆分位置的测试。

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