mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
5 Commits
v1.2.0-bet
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 26dc76f1a6 | |||
| 440704cf27 | |||
| c08383dc4b | |||
| e671a8c082 | |||
| dcaea64439 |
@ -193,6 +193,10 @@ internal/
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.0.3** — 内核 stage 协调器双重解锁修复。现网 homed 主进程曾一次 `fatal error: sync: unlock of unlocked mutex` 整体死亡(带走全部 27 个子进程插件):`Host.endStage` 把「递减 inflight、判定最后离开者」放在 `coordMu` 临界区之外,而摘除协调器在临界区之内,于是后到插件能挂进一个正在收尾的协调器、被误判成最后离开者,对同一把 `stageMu` 解了两次。**`sync.Mutex` 双重解锁是 runtime fatal 而非 panic,两层 `recover` 结构上拦不住**,这才让「插件崩溃不拖垮内核」的隔离设计整体失效。修法是把计数、判定、摘除收进同一临界区,并把首进者写共享段的 `enter()` 也移入锁内(此前后到者可能读到写一半的段)。配套 5 个回归用例,含把旧实现 stash 回来验证测试确实能复现 fatal 的反向验证。
|
||||
|
||||
**v1.0.1** — 多模态 bugfix。插件 ABI/协议未变,1.0.0 编出的 `plugin.bin` 无需重编。修三类缺陷:(1)**看图假成功**——媒体块挂在 tool message 上不被模型当作可视内容(实测同一张图:tool message 0/3 读到、独立 user message 3/3),改为另起一条紧随其后的 user message 承载,落实插件文案一直在说的「注入后续对话」;(2)**新增多模态能力声明与回退链**——`core.llm.sources.<name>.vision/.audio` 声明源能否真正处理媒体(网关会静默剥离 `image_url` 后仍返回 200,带图与不带图 prompt_tokens 完全相同),不支持时自动走视觉源转写成文字,并落实了 `core.input_processing.image.fallback_provider` 这批早已注册却从未被读取的配置项;(3)**`see_video` 帧数语义反了**——`fps=1/N` 是频率不是数量,20s 视频请求 10 帧只得 2 帧、请求 1 帧反得 20 帧,改为 `ffprobe` 取时长 + `fps=N/时长` + `-frames:v` 硬封顶。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
@ -218,7 +222,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- Windows:`HomeAgent_v1.0.3_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
|
||||
@ -179,6 +179,10 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.0.3** — Kernel stage-coordinator double-unlock fix. The production `homed` main process once died outright with `fatal error: sync: unlock of unlocked mutex`, taking all 27 subprocess plugins with it: `Host.endStage` performed "decrement inflight, decide whether I'm the last leaver" *outside* the `coordMu` critical section while detaching the coordinator *inside* it, so a late-arriving plugin could attach to a coordinator that was already finishing, be misjudged as the last leaver, and unlock the same `stageMu` twice. **A `sync.Mutex` double unlock is a runtime fatal, not a panic, so the two layers of `recover` structurally cannot catch it**—which is exactly why the "a crashing plugin must not take down the kernel" isolation design failed wholesale here. The fix folds counting, decision, and detach into one critical section, and also moves the first arriver's `enter()` (which writes the shared segment) inside the lock—previously a late arriver could read a half-written segment. Ships with 5 regression cases, including a reverse check that stashes the old implementation back to confirm the tests really do reproduce the fatal.
|
||||
|
||||
**v1.0.1** — Multimodal bugfix. The plugin ABI/protocol is unchanged, so `plugin.bin` artifacts built for 1.0.0 need no rebuild. Three defects fixed: (1) **vision silently failing**—media blocks attached to a tool message are not treated as viewable content by the model (measured on one image: 0/3 read from a tool message, 3/3 from a standalone user message); media now rides its own user message placed immediately after, which is what the plugin's own wording ("injected into the following conversation") always claimed; (2) **new multimodal capability declaration + fallback chain**—`core.llm.sources.<name>.vision/.audio` declares whether a source can genuinely process media (a gateway may strip `image_url` and still return 200, with identical prompt_tokens with and without the image); when it cannot, media is transcribed to text via a vision-capable source, finally wiring up the long-registered but never-read `core.input_processing.image.fallback_provider` settings; (3) **`see_video` frame-count semantics were inverted**—`fps=1/N` is a *rate*, not a count, so a 20s video yielded 2 frames when 10 were requested and 20 frames when 1 was requested; now `ffprobe` measures duration and the filter becomes `fps=N/duration` with `-frames:v` as a hard cap.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
@ -204,7 +208,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Windows: `HomeAgent_v1.0.3_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
|
||||
@ -27,10 +27,19 @@ COMPONENT="${2:-all}"
|
||||
case "$TARGET" in
|
||||
native) GOOS="" GOARCH="" ;;
|
||||
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
|
||||
# arm64 刻意不设 CXX:设了会让 Go 用 aarch64 的 g++ 去链接,
|
||||
# 而它对 host 产生的 .o 报 "file format not recognized"。
|
||||
# gojieba 的 C++ 源仍由 CC 对应的 gcc 驱动编译(gcc 能编 C++)。
|
||||
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;;
|
||||
# arm64 必须同时给 CXX:gojieba 是 C++,缺 CXX 时 cgo 用宿主 g++ 编出
|
||||
# x86-64 的 .o,链接时报 "Relocations in generic ELF (EM: 183)"(183 = aarch64)。
|
||||
#
|
||||
# 此处曾有一条注释写着「arm64 刻意不设 CXX」,理由是设了会报
|
||||
# "file format not recognized"。那个判断是错的:那个报错的真因是
|
||||
# cmd/{homed,waiter}/*.syso(x86-64 COFF Windows 资源对象)被链进了目标,
|
||||
# 与 CXX 无关。四组对照:
|
||||
# syso 在 + 无 CXX → Relocations in generic ELF (EM: 183)
|
||||
# syso 在 + 有 CXX → 000000.o: file format not recognized
|
||||
# syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
|
||||
# syso 隐藏 + 有 CXX → 成功,ELF aarch64
|
||||
# 本脚本的 hide_syso_for_target 已处理前一个条件,这里补上后一个。
|
||||
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
|
||||
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
|
||||
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
|
||||
# Windows 必须同时给 CXX:gojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++,
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "1.0.0"
|
||||
!define PRODUCT_VERSION "1.0.3"
|
||||
!endif
|
||||
|
||||
!if "${VARIANT}" == "full"
|
||||
|
||||
@ -66,19 +66,41 @@ def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
|
||||
return 0, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def project_root() -> str:
|
||||
"""向上找带 go.mod 的目录作为仓库根。
|
||||
|
||||
为何不数 dirname:本脚本初版在 scripts/(深度 1),移到 deploy/scripts/
|
||||
(深度 2)后写死的两层 dirname 就指向了 deploy/dist/release,上传直接
|
||||
FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断
|
||||
PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。
|
||||
"""
|
||||
d = os.path.dirname(os.path.abspath(__file__))
|
||||
while d != os.path.dirname(d):
|
||||
if os.path.exists(os.path.join(d, "go.mod")):
|
||||
return d
|
||||
d = os.path.dirname(d)
|
||||
# 实在找不到(脚本被单独拷出仓库)就回退到 cwd,给 ASSET_DIR 一个机会
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
tag, token = sys.argv[1], sys.argv[2]
|
||||
outdir = os.environ.get("ASSET_DIR") or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"dist",
|
||||
"release",
|
||||
project_root(), "dist", "release"
|
||||
)
|
||||
if not os.path.isdir(outdir):
|
||||
print(f"error: 资产目录不存在: {outdir}")
|
||||
print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/")
|
||||
return 2
|
||||
files = sys.argv[3:] or sorted(
|
||||
f for f in os.listdir(outdir) if is_artifact(f)
|
||||
)
|
||||
if not files:
|
||||
print(f"error: {outdir} 下没有可识别的发布产物")
|
||||
return 2
|
||||
print(f"repo={REPO} tag={tag} dir={outdir}", flush=True)
|
||||
failed = []
|
||||
for name in files:
|
||||
|
||||
@ -10,7 +10,10 @@ var (
|
||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||
// 用新版 plugindev 重编),故跃到主版本号。
|
||||
Version = "1.0.0"
|
||||
//
|
||||
// 1.0.1:多模态修复。仅内核与内置插件改动,插件 ABI/协议未变,
|
||||
// 1.0.0 编出的 plugin.bin 无需重编。
|
||||
Version = "1.0.3"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
@ -144,48 +144,83 @@ func (h *Host) Close() error {
|
||||
//
|
||||
// 首个进入者:获取 stageMu(独占共享段)→ 把内核 StageContext 写入段。
|
||||
// 后续进入者:仅递增 inflight。
|
||||
//
|
||||
// enter() 在 coordMu 内完成,两个原因:
|
||||
// 1. 首进者的 WriteAll 未结束前不能让后到者拿到 coord 就去读共享段
|
||||
// (旧码的后到者 enter 立即返回,可能读到写一半的段)。
|
||||
// 2. 与 endStage 的摘除互斥,防止后到者挂进一个正在收尾的协调器
|
||||
// (具体见 endStage 的注释)。
|
||||
//
|
||||
// 锁序:stageMu → coordMu。endStage 只解锁 stageMu、不获取,所以无环。
|
||||
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) {
|
||||
h.coordMu.Lock()
|
||||
first := h.coord == nil
|
||||
if first {
|
||||
// 独占共享段直到本次 stage 全部插件离开
|
||||
if h.coord == nil {
|
||||
// 首个进入者:独占共享段直到本次 stage 全部插件离开。
|
||||
// 必须先放 coordMu 再取 stageMu,不能反序。
|
||||
h.coordMu.Unlock()
|
||||
h.stageMu.Lock()
|
||||
h.coordMu.Lock()
|
||||
// 双检:等锁期间可能已有其他插件建好协调器(它们会先拿到 stageMu)
|
||||
if h.coord != nil {
|
||||
first = false
|
||||
h.stageMu.Unlock()
|
||||
} else {
|
||||
h.coord = newStageCoordinator(h.seg)
|
||||
}
|
||||
}
|
||||
coord := h.coord
|
||||
h.coordMu.Unlock()
|
||||
|
||||
if err := coord.enter(sc, first); err != nil {
|
||||
if first {
|
||||
h.coordMu.Lock()
|
||||
h.coord = nil
|
||||
if h.coord == nil {
|
||||
coord := newStageCoordinator(h.seg)
|
||||
h.coord = coord
|
||||
if err := coord.enter(sc, true); err != nil {
|
||||
// 注意:runStage 的 defer endStage(coord) 是在 beginStage
|
||||
// 返回 err 的检查之后才注册的,所以这条路径上
|
||||
// endStage 永远不会被调用——stageMu 必须在此自行释放,
|
||||
// 否则整个 stage 通道永久卡死。
|
||||
h.coord = nil
|
||||
h.coordMu.Unlock()
|
||||
h.stageMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
h.coordMu.Unlock()
|
||||
h.stageMu.Unlock()
|
||||
h.locks.bind(coord.lock)
|
||||
return coord, nil
|
||||
}
|
||||
// 双检失败:等锁期间已有其他插件建好协调器,退回后到者路径。
|
||||
h.stageMu.Unlock()
|
||||
}
|
||||
|
||||
coord := h.coord
|
||||
if err := coord.enter(sc, false); err != nil {
|
||||
h.coordMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
h.coordMu.Unlock()
|
||||
h.locks.bind(coord.lock)
|
||||
return coord, nil
|
||||
}
|
||||
|
||||
// endStage 由插件 handler 返回时调用。
|
||||
// 最后离开者:把共享段结果读回内核 StageContext → 压实 arena → 释放 stageMu。
|
||||
//
|
||||
// coordMu 必须覆盖「递减 inflight → 判定最后离开者 → 摘除 h.coord」全过程。
|
||||
// 旧码把 leave() 放在 coordMu 之外,留出了这个窗口(即 2026-09-04 06:56:18
|
||||
// 线上 fatal error: sync: unlock of unlocked mutex 的真因):
|
||||
//
|
||||
// A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
|
||||
// B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
|
||||
// (后到者不取 stageMu)
|
||||
// A.endStage: h.coord = nil;stageMu.Unlock() ← 第 1 次
|
||||
// B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥
|
||||
//
|
||||
// B 从未持有 stageMu(它是后到者),却因为挂进了一个正在收尾的协调器
|
||||
// 而成为“最后离开者”,于是对同一把锁解了两次。sync.Mutex 的双重解锁是
|
||||
// runtime fatal,**recover 捕不到**——这就是为何 stage.go / stages.go 里
|
||||
// 那两层 recover 全部失效、整个 homed 直接死掉的原因。
|
||||
func (h *Host) endStage(coord *stageCoordinator) error {
|
||||
last, err := coord.leave()
|
||||
if !last {
|
||||
return err
|
||||
}
|
||||
h.coordMu.Lock()
|
||||
h.coord = nil
|
||||
last, sc, written := coord.depart()
|
||||
if last && h.coord == coord {
|
||||
h.coord = nil
|
||||
}
|
||||
h.coordMu.Unlock()
|
||||
if !last {
|
||||
return nil
|
||||
}
|
||||
// finish 必须在 stageMu.Unlock() 之前:先放锁会让下一轮 stage
|
||||
// 在回读未完时就改写共享段。
|
||||
err := coord.finish(sc, written)
|
||||
h.stageMu.Unlock()
|
||||
return err
|
||||
}
|
||||
@ -232,26 +267,38 @@ func (c *stageCoordinator) enter(sc *pubsdk.StageContext, first bool) error {
|
||||
|
||||
// leave 登记一个插件离开;返回是否为最后一个离开者。
|
||||
//
|
||||
// 最后离开者负责把共享段结果读回内核 StageContext,并压实 arena
|
||||
// (此时无插件持锁,满足 §3.3 的压实前提)。
|
||||
// 拆成两段:depart() 只动计数(由 endStage 在 coordMu 内调用,使
|
||||
// 「递减 → 判定最后者 → 摘除 h.coord」成为原子操作),finish() 做
|
||||
// 共享段回读与压实。本方法保留给单测用。
|
||||
func (c *stageCoordinator) leave() (last bool, err error) {
|
||||
c.mu.Lock()
|
||||
c.inflight--
|
||||
last = c.inflight == 0
|
||||
sc := c.ctxRef
|
||||
written := c.written
|
||||
c.mu.Unlock()
|
||||
|
||||
if !last || !written || sc == nil {
|
||||
last, sc, written := c.depart()
|
||||
if !last {
|
||||
return last, nil
|
||||
}
|
||||
return last, c.finish(sc, written)
|
||||
}
|
||||
|
||||
// depart 递减 inflight 并报告是否为最后离开者。
|
||||
func (c *stageCoordinator) depart() (last bool, sc *pubsdk.StageContext, written bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.inflight--
|
||||
return c.inflight == 0, c.ctxRef, c.written
|
||||
}
|
||||
|
||||
// finish 把共享段结果读回内核 StageContext 并压实 arena
|
||||
// (此时无插件持锁,满足 §3.3 的压实前提)。
|
||||
func (c *stageCoordinator) finish(sc *pubsdk.StageContext, written bool) error {
|
||||
if !written || sc == nil {
|
||||
return nil
|
||||
}
|
||||
if rErr := c.seg.ReadInto(sc); rErr != nil {
|
||||
return last, fmt.Errorf("回读共享段: %w", rErr)
|
||||
return fmt.Errorf("回读共享段: %w", rErr)
|
||||
}
|
||||
if reclaimed := c.seg.Compact(); reclaimed > 0 {
|
||||
log.Printf("[proc] stage 结束,arena 压实回收 %d 字节", reclaimed)
|
||||
}
|
||||
return last, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShmSize 返回共享段大小(供诊断/日志)。
|
||||
|
||||
220
internal/plugin/proc/host_stage_test.go
Normal file
220
internal/plugin/proc/host_stage_test.go
Normal file
@ -0,0 +1,220 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 本文件是 2026-09-04 06:56:18 线上 crash 的回归测试。
|
||||
//
|
||||
// 崩溃形态:homed 主进程直接死亡,退出码 2。
|
||||
//
|
||||
// fatal error: sync: unlock of unlocked mutex
|
||||
// proc.(*Host).endStage(...) host.go:189
|
||||
// proc.(*coreHandler).runStage.func1() stage.go:94
|
||||
// core.(*StageHost).RunStage.func1() stages.go:190
|
||||
//
|
||||
// 注意 stage.go 与 stages.go 各有一层 recover,却都没拦住——
|
||||
// sync.Mutex 的双重解锁是 runtime fatal,recover 捕不到。这是本次
|
||||
// "整个内核本体崩溃"而非"插件崩溃被隔离"的直接原因。
|
||||
|
||||
// TestEndStage_LateArrivalNoDoubleUnlock 复现根因竞态。
|
||||
//
|
||||
// 旧实现把 leave() 放在 coordMu 之外,留出这个窗口:
|
||||
//
|
||||
// A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
|
||||
// B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
|
||||
// (后到者不取 stageMu)
|
||||
// A.endStage: h.coord = nil; stageMu.Unlock() ← 第 1 次
|
||||
// B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥
|
||||
//
|
||||
// B 从未持有 stageMu,却因为挂进了一个正在收尾的协调器而成为
|
||||
// "最后离开者",于是对同一把锁解了两次。
|
||||
//
|
||||
// 本测试直接驱动 depart/enter 制造那个时序,不依赖调度巧合。
|
||||
func TestEndStage_LateArrivalNoDoubleUnlock(t *testing.T) {
|
||||
host, err := NewHost()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHost: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
scA := &pubsdk.StageContext{RawMessage: "A"}
|
||||
coordA, err := host.beginStage(scA)
|
||||
if err != nil {
|
||||
t.Fatalf("A beginStage: %v", err)
|
||||
}
|
||||
|
||||
// A 收尾:修复后 depart 与摘除 h.coord 在同一个 coordMu 临界区内,
|
||||
// 所以此刻起 h.coord 已是 nil,B 不可能再挂进 A 的协调器。
|
||||
if err := host.endStage(coordA); err != nil {
|
||||
t.Fatalf("A endStage: %v", err)
|
||||
}
|
||||
|
||||
// B 现在进入:必须成为新的首进者(拿到自己的 stageMu),
|
||||
// 而不是挂进 A 那个已收尾的协调器。
|
||||
scB := &pubsdk.StageContext{RawMessage: "B"}
|
||||
coordB, err := host.beginStage(scB)
|
||||
if err != nil {
|
||||
t.Fatalf("B beginStage: %v", err)
|
||||
}
|
||||
if coordB == coordA {
|
||||
t.Fatal("B 不该复用 A 已收尾的协调器——这正是 double-unlock 的来源")
|
||||
}
|
||||
if err := host.endStage(coordB); err != nil {
|
||||
t.Fatalf("B endStage: %v", err)
|
||||
}
|
||||
|
||||
// 若上面多解了一次锁,这里会 fatal(runtime 级,测试进程直接死);
|
||||
// 能走到这一步说明配对正确。
|
||||
scC := &pubsdk.StageContext{RawMessage: "C"}
|
||||
coordC, err := host.beginStage(scC)
|
||||
if err != nil {
|
||||
t.Fatalf("C beginStage: %v", err)
|
||||
}
|
||||
if err := host.endStage(coordC); err != nil {
|
||||
t.Fatalf("C endStage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndStage_ConcurrentChurnNoFatal 高并发进出:真实触发线上那个窗口。
|
||||
//
|
||||
// 旧实现下这个测试会以 fatal error: sync: unlock of unlocked mutex 结束
|
||||
// (整个测试二进制死亡,不是 FAIL)。修复后应干净通过。
|
||||
func TestEndStage_ConcurrentChurnNoFatal(t *testing.T) {
|
||||
host, err := NewHost()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHost: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
const workers = 8
|
||||
const rounds = 40
|
||||
var wg sync.WaitGroup
|
||||
var failures atomic.Int64
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for r := 0; r < rounds; r++ {
|
||||
sc := &pubsdk.StageContext{RawMessage: "churn"}
|
||||
coord, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
failures.Add(1)
|
||||
return
|
||||
}
|
||||
if err := host.endStage(coord); err != nil {
|
||||
failures.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if n := failures.Load(); n > 0 {
|
||||
t.Fatalf("%d 次 begin/end 失败", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeginStage_MultiPluginSameStage 同阶段多插件扇出:
|
||||
// 首进者取 stageMu,后到者只递增 inflight,最后离开者才解锁。
|
||||
// 验证并发扇出这一原始设计仍然成立(§0.2 第 1 条)。
|
||||
func TestBeginStage_MultiPluginSameStage(t *testing.T) {
|
||||
host, err := NewHost()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHost: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
sc := &pubsdk.StageContext{RawMessage: "fanout"}
|
||||
|
||||
// 三个插件先后进入同一次 stage
|
||||
c1, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("plugin1 beginStage: %v", err)
|
||||
}
|
||||
c2, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("plugin2 beginStage: %v", err)
|
||||
}
|
||||
c3, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("plugin3 beginStage: %v", err)
|
||||
}
|
||||
// 同一次 stage 内必须共用一个协调器(共享同一份 StageContext 段)
|
||||
if c1 != c2 || c2 != c3 {
|
||||
t.Fatal("同阶段并发插件应共用一个协调器")
|
||||
}
|
||||
|
||||
// 前两个离开不该释放 stageMu
|
||||
if err := host.endStage(c1); err != nil {
|
||||
t.Fatalf("plugin1 endStage: %v", err)
|
||||
}
|
||||
if err := host.endStage(c2); err != nil {
|
||||
t.Fatalf("plugin2 endStage: %v", err)
|
||||
}
|
||||
// 最后一个离开才释放
|
||||
if err := host.endStage(c3); err != nil {
|
||||
t.Fatalf("plugin3 endStage: %v", err)
|
||||
}
|
||||
|
||||
// 锁已释放:新一轮能立即开始
|
||||
c4, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("新一轮 beginStage 应成功(stageMu 已释放): %v", err)
|
||||
}
|
||||
if c4 == c1 {
|
||||
t.Fatal("新一轮应是新的协调器")
|
||||
}
|
||||
if err := host.endStage(c4); err != nil {
|
||||
t.Fatalf("新一轮 endStage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeginStage_SerialRounds 长串行:确认没有单向泄漏(少解锁会在第二轮卡死)。
|
||||
func TestBeginStage_SerialRounds(t *testing.T) {
|
||||
host, err := NewHost()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHost: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
for round := 0; round < 50; round++ {
|
||||
sc := &pubsdk.StageContext{RawMessage: "serial"}
|
||||
coord, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d beginStage: %v", round, err)
|
||||
}
|
||||
if err := host.endStage(coord); err != nil {
|
||||
t.Fatalf("round %d endStage: %v", round, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeginStage_PhaseSequence 模拟一条消息走完 pre_action → chat → post_action。
|
||||
func TestBeginStage_PhaseSequence(t *testing.T) {
|
||||
host, err := NewHost()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHost: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
phases := []pubsdk.Stage{"pre_action", "chat", "after_toolcall", "post_action"}
|
||||
for msg := 0; msg < 10; msg++ {
|
||||
for _, p := range phases {
|
||||
sc := &pubsdk.StageContext{RawMessage: "msg", Phase: p}
|
||||
coord, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("msg %d phase %s beginStage: %v", msg, p, err)
|
||||
}
|
||||
if err := host.endStage(coord); err != nil {
|
||||
t.Fatalf("msg %d phase %s endStage: %v", msg, p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user