mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
6 Commits
v1.0.3
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b792b91f5 | |||
| 208d39c296 | |||
| eb02f00998 | |||
| f478659b89 | |||
| 743b963dec | |||
| 6b87a1de14 |
@ -193,6 +193,8 @@ internal/
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.0.4** — 两处数据竞争修复(现网 `/api/v1/device/ws` 通道与终端推流)。此前 `-race` 全仓复验即暴露:`remotedevice` 网关对同一连接的 `bufio.Writer` 由两条路径并发写(`handleWS` 主循环回写 hello_ack/绑定回执/pong,与 `PushJSON`/`PushData` 的 agent→设备下发),`bufio.Writer` 非线程安全,`TestWSPushDataAudio` 异步下发即稳定撞车;`agentcli` 终端把共享读缓冲传给 reader goroutine(OS 层持续覆写)又在 `readLoop` 里 `copy(data, buf[:r.n])`,读写并发。修法:连接级写锁(`wconn.wmu`,Push* 与 handleWS 共用同一把锁,`PushData` 整条下发持锁保证协议顺序)与「读结果随 `readResult` 自带切片传递、不再共享缓冲」。全仓 `go test ./... -race` 由 7 处 race / 5 个测试 FAIL 变为 32 包全绿。
|
||||
|
||||
**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` 硬封顶。
|
||||
@ -222,7 +224,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.0.3_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- Windows:`HomeAgent_v1.0.4_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
|
||||
@ -179,6 +179,8 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.0.4** — Two data-race fixes (the live `/api/v1/device/ws` gateway and terminal streaming). A full `-race` pass exposed both: `remotedevice` wrote one connection's `bufio.Writer` from two concurrent paths (`handleWS` loop replies hello_ack/bind_ack/pong, plus `PushJSON`/`PushData` agent→device pushes) — `bufio.Writer` is not thread-safe, and `TestWSPushDataAudio` async push hit it reliably; `agentcli` handed the shared read buffer to the reader goroutine (which the OS keeps overwriting) while `readLoop` did `copy(data, buf[:r.n])` — concurrent read/write of the same buffer. Fix: connection-level write lock (`wconn.wmu`, shared by Push* and handleWS; `PushData` holds it across the whole start/chunks/end sequence to preserve protocol order) plus carrying read results in per-result slices instead of a shared buffer. Repo-wide `go test ./... -race` went from 7 races / 5 failing tests to all-clean.
|
||||
|
||||
**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.
|
||||
@ -208,7 +210,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.3_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Windows: `HomeAgent_v1.0.4_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
|
||||
@ -153,6 +153,14 @@ build_initconfig() {
|
||||
}
|
||||
|
||||
# ---- gui (Electron) ----
|
||||
#
|
||||
# 输出目录必须用 --config.directories.output,**不能用 -o**:
|
||||
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
|
||||
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
|
||||
# ⨯ Unknown target: /home/program/trueagent/build
|
||||
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
|
||||
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
|
||||
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
|
||||
build_gui() {
|
||||
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
||||
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
|
||||
@ -170,12 +178,21 @@ build_gui() {
|
||||
# 不传 --config:electron-builder 默认从 package.json 的 "build" 键读配置。
|
||||
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
|
||||
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
|
||||
(cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
-o "$BUILD_DIR")
|
||||
echo " OK"
|
||||
#
|
||||
# GUI 失败不中断整体构建:homed/waiter/initconfig 是发布的主体,
|
||||
# 而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
|
||||
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
|
||||
if (cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
--config.directories.output="$BUILD_DIR"); then
|
||||
echo " OK"
|
||||
else
|
||||
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig)"
|
||||
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- dispatch ----
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "1.0.3"
|
||||
!define PRODUCT_VERSION "1.0.4"
|
||||
!endif
|
||||
|
||||
!if "${VARIANT}" == "full"
|
||||
|
||||
@ -9,14 +9,20 @@ PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
|
||||
ARCH="${1:-amd64}" # amd64 or arm64
|
||||
|
||||
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
|
||||
# 两者在 arm64 上恰好同名,amd64 上不同——此前缓存查找统一用 TAR_ARCH
|
||||
# (amd64),于是 electron-v*-linux-x64.zip 永远命中不到,amd64 GUI 只能
|
||||
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
|
||||
# node_modules,GUI 就被静默跳过。故单独映射。
|
||||
ACTION="${2:-all}" # all, build, deb, tar, rpm
|
||||
|
||||
DEB_ARCH="$ARCH"
|
||||
RPM_ARCH="$ARCH"
|
||||
TAR_ARCH="$ARCH"
|
||||
case "$ARCH" in
|
||||
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;;
|
||||
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;;
|
||||
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
|
||||
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
|
||||
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
@ -122,6 +128,15 @@ build_go() {
|
||||
}
|
||||
|
||||
# ---- build GUI (manual directory assembly, avoids electron-packager network issues) ----
|
||||
#
|
||||
# electron 运行时必须按**目标架构**取,不能用 host 的
|
||||
# node_modules/electron/dist——那里永远是 host 架构(本机 x64)。
|
||||
# v1.0.0 / v1.0.1 的 arm64 full/client 包都踩了这个坑:目录名带
|
||||
# -arm64、homed/waiter 确实是 aarch64,但里面的 electron 是 x86-64,
|
||||
# 在 arm64 机器上一启动就是 Exec format error(从未被交叉验证过)。
|
||||
#
|
||||
# 现在改为优先从 electron 缓存里取对应架构的 zip,并在最后做
|
||||
# 一道强制校验:架构不符就删掉目录并跳过 GUI,宁可不发也不发坏包。
|
||||
build_gui() {
|
||||
local gui_dir="$PROJECT_ROOT/cmd/gui"
|
||||
local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
|
||||
@ -133,22 +148,81 @@ build_gui() {
|
||||
|
||||
echo ">>> Building GUI directory for linux/$ARCH..."
|
||||
|
||||
if [ ! -d "$gui_dir/node_modules" ]; then
|
||||
# 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
|
||||
#
|
||||
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
|
||||
# node_modules,目录存在但 electron 缺失。只看目录会以为"已安装",
|
||||
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
|
||||
# 静默跳过 GUI——包名和目录名全都正确,只是没有 GUI,没有任何一步报错。
|
||||
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
|
||||
if [ -d "$gui_dir/node_modules" ]; then
|
||||
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
|
||||
fi
|
||||
echo " npm install..."
|
||||
(cd "$gui_dir" && npm install --production)
|
||||
if ! (cd "$gui_dir" && npm install --production); then
|
||||
echo " WARNING: npm install 失败——离线环境下这是预期的。"
|
||||
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
|
||||
fi
|
||||
fi
|
||||
|
||||
local electron_dir="$gui_dir/node_modules/electron/dist"
|
||||
if [ ! -f "$electron_dir/electron" ]; then
|
||||
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
|
||||
return
|
||||
# electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
|
||||
# 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
|
||||
# "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
|
||||
local ever
|
||||
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
|
||||
if [ -z "$ever" ]; then
|
||||
ever=$(python3 -c "
|
||||
import json, re
|
||||
d = json.load(open('$gui_dir/package.json'))
|
||||
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
|
||||
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
|
||||
print(m.group(1) if m else '')
|
||||
" 2>/dev/null || true)
|
||||
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
|
||||
fi
|
||||
|
||||
mkdir -p "$gui_out"
|
||||
|
||||
# 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)
|
||||
local zip=""
|
||||
if [ -n "$ever" ]; then
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$zip" ]; then
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$zip" ]; then
|
||||
echo " electron runtime: $(basename "$zip")"
|
||||
unzip -q -o "$zip" -d "$gui_out"
|
||||
else
|
||||
# 回退:仅当目标架构 == host 架构时才能用 host 的 dist
|
||||
local host_arch
|
||||
case "$(uname -m)" in
|
||||
x86_64) host_arch=amd64 ;;
|
||||
aarch64|arm64) host_arch=arm64 ;;
|
||||
*) host_arch=unknown ;;
|
||||
esac
|
||||
if [ "$TAR_ARCH" != "$host_arch" ]; then
|
||||
echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
|
||||
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
|
||||
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
|
||||
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
|
||||
rm -rf "$gui_out"
|
||||
return
|
||||
fi
|
||||
local electron_dir="$gui_dir/node_modules/electron/dist"
|
||||
if [ ! -f "$electron_dir/electron" ]; then
|
||||
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
|
||||
rm -rf "$gui_out"
|
||||
return
|
||||
fi
|
||||
echo " electron runtime: host node_modules (同架构 $host_arch)"
|
||||
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
|
||||
fi
|
||||
|
||||
mkdir -p "$gui_out/resources/app/node_modules"
|
||||
mkdir -p "$gui_out/resources/app/renderer"
|
||||
|
||||
# copy electron runtime (binary + shared libs)
|
||||
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
|
||||
rm -f "$gui_out/resources/default_app.asar" 2>/dev/null
|
||||
|
||||
# copy app source
|
||||
@ -190,7 +264,28 @@ LAUNCHER
|
||||
chmod +x "$gui_out/homeagent-gui"
|
||||
chmod +x "$gui_out/electron"
|
||||
|
||||
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1))"
|
||||
# 最后一道强制校验:electron 二进制的实际架构必须匹配目标架构。
|
||||
# 不做这步就会重现 v1.0.0/v1.0.1 的隐形坏包:包名、目录名、
|
||||
# homed/waiter 全对,只有 electron 是错架构,直到用户在 arm64 机器上
|
||||
# 双击才发现 Exec format error。
|
||||
local want_pat
|
||||
case "$TAR_ARCH" in
|
||||
amd64) want_pat="x86-64" ;;
|
||||
arm64) want_pat="aarch64" ;;
|
||||
*) want_pat="" ;;
|
||||
esac
|
||||
if [ -n "$want_pat" ]; then
|
||||
local got
|
||||
got=$(file -b "$gui_out/electron" 2>/dev/null || echo "")
|
||||
if ! printf '%s' "$got" | grep -q "$want_pat"; then
|
||||
echo " ERROR: electron 架构不符——期望 $want_pat,实际: ${got%%,*}"
|
||||
echo " 删除 GUI 目录并跳过(宁可不发,也不发装了跑不起来的包)。"
|
||||
rm -rf "$gui_out"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1), $(file -b "$gui_out/electron" | cut -d, -f2 | tr -d ' '))"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ var (
|
||||
//
|
||||
// 1.0.1:多模态修复。仅内核与内置插件改动,插件 ABI/协议未变,
|
||||
// 1.0.0 编出的 plugin.bin 无需重编。
|
||||
Version = "1.0.3"
|
||||
Version = "1.0.4"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
@ -18,10 +18,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
ReadBufSize = 4096
|
||||
MaxOutputBuffer = 128 * 1024
|
||||
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
ReadBufSize = 4096
|
||||
MaxOutputBuffer = 128 * 1024
|
||||
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
|
||||
DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底)
|
||||
)
|
||||
|
||||
@ -68,12 +68,12 @@ type TerminalSession struct {
|
||||
done chan struct{}
|
||||
|
||||
// 通知节流字段
|
||||
unreadBytes int // 最近一次通知后积累的未读字节数
|
||||
lastNotify time.Time // 最近一次通知时间
|
||||
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
|
||||
lastFeedback time.Time // 最近一次定时反馈时间
|
||||
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
|
||||
watch terminalWatch // 该终端的提醒规则
|
||||
unreadBytes int // 最近一次通知后积累的未读字节数
|
||||
lastNotify time.Time // 最近一次通知时间
|
||||
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
|
||||
lastFeedback time.Time // 最近一次定时反馈时间
|
||||
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
|
||||
watch terminalWatch // 该终端的提醒规则
|
||||
|
||||
// 实时画面推流(terminal_output 事件)
|
||||
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
|
||||
@ -226,7 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
|
||||
"通知模式通过 notify 参数选择(默认 exit):exit=仅命令执行结束后提醒一次;interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
|
||||
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192);多个模式用逗号组合(如 interval=30s,buffer=8192)。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||
NoMemory: true,
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -283,7 +283,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_read", sdk.ToolDef{
|
||||
Name: "terminal_read",
|
||||
Name: "terminal_read",
|
||||
Description: "读取指定终端的输出。mode=new(默认)返回自上次读取以来的新输出并清空缓冲;mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
@ -759,11 +759,11 @@ func (p *Plugin) handleList() (interface{}, error) {
|
||||
defer p.mu.Unlock()
|
||||
|
||||
type termInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Uptime string `json:"uptime"`
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Uptime string `json:"uptime"`
|
||||
ExpiresIn string `json:"expires_in"`
|
||||
Running bool `json:"running"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
var terms []termInfo
|
||||
@ -787,8 +787,8 @@ func (p *Plugin) handleList() (interface{}, error) {
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"count": len(terms),
|
||||
"status": "ok",
|
||||
"count": len(terms),
|
||||
"terminals": terms,
|
||||
}, nil
|
||||
}
|
||||
@ -797,6 +797,9 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
defer p.wg.Done()
|
||||
defer close(t.done)
|
||||
|
||||
// reader 协程独享这个读缓冲:结果随 readResult 携带,
|
||||
// readLoop 不再从其中做 copy(见 reader 注释,那是对共享缓冲
|
||||
// 的并发读写,-race 实测触发)。
|
||||
buf := make([]byte, ReadBufSize)
|
||||
pollInterval := 200 * time.Millisecond
|
||||
|
||||
@ -816,7 +819,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
t.lastFeedback = now
|
||||
t.mu.Unlock()
|
||||
|
||||
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
|
||||
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
|
||||
hardNotifyBytes := 64 * 1024
|
||||
hardNotifyInterval := 10 * time.Second
|
||||
// 输出停止判定:超过该时长无新数据则视为输出停止
|
||||
@ -886,9 +889,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
return
|
||||
}
|
||||
if r.n > 0 {
|
||||
data := make([]byte, r.n)
|
||||
copy(data, buf[:r.n])
|
||||
t.appendOutput(data)
|
||||
t.appendOutput(r.data)
|
||||
|
||||
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
|
||||
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
|
||||
@ -951,15 +952,28 @@ func previewTail(s string, n int) string {
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
n int
|
||||
err error
|
||||
n int
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// reader 从终端读取输出并通过 channel 交给 readLoop。
|
||||
//
|
||||
// 读到的数据**随结果一起传**而不是复用外层共享的 buf:
|
||||
// reader 是唯一写 buf 的 goroutine,readLoop 又常在 reader 尚未
|
||||
// 写完下一段时就从 buf[:r.n] 做 copy——同一个 shared buf 被并发
|
||||
// 读写就是 data race(-race 实测触发)。改为每个结果自带切片后,
|
||||
// 读与拷贝天然隔离,不再共享可变状态。
|
||||
func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) {
|
||||
for {
|
||||
n, err := t.session.Read(buf)
|
||||
var data []byte
|
||||
if n > 0 {
|
||||
data = make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
}
|
||||
select {
|
||||
case ch <- readResult{n, err}:
|
||||
case ch <- readResult{n, data, err}:
|
||||
case <-t.stopCh:
|
||||
return
|
||||
}
|
||||
|
||||
@ -213,27 +213,68 @@ func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) {
|
||||
t.Fatal("editdoc 未加载")
|
||||
}
|
||||
|
||||
// 找到插件子进程并 SIGKILL
|
||||
pid := findPluginPID(t, "editdoc")
|
||||
// 找插件子进程并 SIGKILL。
|
||||
//
|
||||
// 必须拿 plgDir 限定范围:旧实现用全系统 pgrep -f plugin.bin 后
|
||||
// 只比“路径含 editdoc”,于是在跑着生产实例的机器上,它会把
|
||||
// /home/newqqagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次,
|
||||
// 全部落在有人跑 go test 的时段)。更糟的是此时本测试仍会通过:
|
||||
// 它断言的是测试内核存活,而那个内核的插件压根没死——**它在测一件
|
||||
// 没发生的事**,同时还把生产环境打坏了。
|
||||
pid := findPluginPID(t, plgDir, "editdoc")
|
||||
if pid == 0 {
|
||||
t.Skip("未找到插件子进程(进程名匹配失败)")
|
||||
t.Skip("未找到本测试自己拉起的插件子进程")
|
||||
}
|
||||
t.Logf("kill 插件进程 pid=%d", pid)
|
||||
t.Logf("kill 插件进程 pid=%d (exe 在 %s 下)", pid, plgDir)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill: %v", err)
|
||||
}
|
||||
|
||||
// 内核必须存活并能继续工作
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
// 先确认目标进程真的死了。
|
||||
//
|
||||
// 这步不能省:旧版直接断言“内核存活”,而内核本来就活着——
|
||||
// 即使 SIGKILL 发错了对象(杀了生产实例的插件)测试也会结束。
|
||||
// 先验“目标真死”再验“内核未被连带”,两步都成立才能证明隔离生效。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
dead := false
|
||||
for time.Now().Before(deadline) {
|
||||
if syscall.Kill(pid, 0) != nil {
|
||||
dead = true
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !dead {
|
||||
t.Fatalf("pid=%d 在 SIGKILL 后 3s 内未退出,崩溃隔离无从验证", pid)
|
||||
}
|
||||
|
||||
// 内核(本测试进程)必须存活并能继续工作
|
||||
if env.pluginReg.List() == nil {
|
||||
t.Fatal("内核在插件崩溃后不可用")
|
||||
}
|
||||
t.Logf("插件崩溃后内核存活,已加载插件数=%d", len(env.pluginReg.List()))
|
||||
t.Logf("插件进程已确认退出,内核存活,已加载插件数=%d", len(env.pluginReg.List()))
|
||||
}
|
||||
|
||||
// findPluginPID 按二进制路径找插件子进程 pid。
|
||||
func findPluginPID(t *testing.T, name string) int {
|
||||
// findPluginPID 在**指定插件目录下**找插件子进程 pid。
|
||||
//
|
||||
// root 参数是硬约束,不是可选过滤器:本函数的唯一用途是给崩溃隔离
|
||||
// 测试提供一个“可以安全 SIGKILL 的 pid”,而安全的定义就是它必须属于
|
||||
// 本测试自己的临时目录。不带这个约束就会误杀同机生产实例的插件。
|
||||
//
|
||||
// 匹配依据是 /proc/<pid>/exe 的真实路径必须以 root 为前缀。
|
||||
// 用 exe 而不用 cmdline:cmdline 可被进程自行改写,而 exe 符链由内核维护。
|
||||
// root 先过一道 EvalSymlinks:/tmp 在部分发行版上是符链(如 macOS 的
|
||||
// /tmp -> /private/tmp),不归一化会让前缀比较永远不命中,退化成静默 Skip。
|
||||
func findPluginPID(t *testing.T, root, name string) int {
|
||||
t.Helper()
|
||||
if root == "" {
|
||||
t.Fatal("findPluginPID: root 不得为空(防止误杀全系统同名插件)")
|
||||
}
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
realRoot = root
|
||||
}
|
||||
|
||||
out, err := exec.Command("pgrep", "-f", "plugin.bin").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
@ -244,15 +285,18 @@ func findPluginPID(t *testing.T, name string) int {
|
||||
if pid == 0 {
|
||||
continue
|
||||
}
|
||||
// 校验 cwd 或 cmdline 含插件名
|
||||
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
|
||||
if err == nil && strings.Contains(exe, name) {
|
||||
return pid
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
|
||||
if err == nil && strings.Contains(cwd, name) {
|
||||
return pid
|
||||
// 两道条件同时成立才算命中:在本测试的目录树内,且是目标插件
|
||||
if !strings.HasPrefix(exe, realRoot+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(exe, name) {
|
||||
continue
|
||||
}
|
||||
return pid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -37,6 +37,24 @@ type DeviceMeta struct {
|
||||
type wconn struct {
|
||||
deviceID string
|
||||
w *bufio.Writer
|
||||
// wmu 序列化对该连接 bufio.Writer 的所有写。
|
||||
//
|
||||
// 两个角色会并发写同一连接:handleWS 主循环(读设备帧后的 hello_ack/
|
||||
// bind_ack/pong 回写)与 PushJSON/PushData(agent→设备的下发路径,可能
|
||||
// 来自任意 goroutine)。bufio.Writer 不是线程安全的,不加锁会在
|
||||
// WriteByte/Flush 上产生 data race(生产实测触发)。
|
||||
wmu sync.Mutex
|
||||
}
|
||||
|
||||
// lockWrite 对 wconn 加写锁并返回 writer;调用方必须 defer unlockWrite。
|
||||
// 单独写成方法而不是直接暴露字段,避免调用方绕过锁。
|
||||
func (c *wconn) lockWrite() *bufio.Writer {
|
||||
c.wmu.Lock()
|
||||
return c.w
|
||||
}
|
||||
|
||||
func (c *wconn) unlockWrite() {
|
||||
c.wmu.Unlock()
|
||||
}
|
||||
|
||||
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
|
||||
@ -319,7 +337,9 @@ func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) err
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
return writeText(c.w, mustJSON(payload))
|
||||
w := c.lockWrite()
|
||||
defer c.unlockWrite()
|
||||
return writeText(w, mustJSON(payload))
|
||||
}
|
||||
|
||||
// PushCmd 向设备发送命令执行请求。
|
||||
@ -348,7 +368,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
if err := writeText(c.w, mustJSON(map[string]interface{}{
|
||||
// 整条下发(start + N 个 chunk + end)持锁:设备侧按协议串行聚合,
|
||||
// 若中途被 handleWS 的 hello/pong 插帧会破坏协议顺序。
|
||||
w := c.lockWrite()
|
||||
defer c.unlockWrite()
|
||||
if err := writeText(w, mustJSON(map[string]interface{}{
|
||||
"op": "cmd_speech_start",
|
||||
"req_id": reqID,
|
||||
"kind": kind,
|
||||
@ -363,11 +387,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
if err := writeBinary(c.w, data[off:end]); err != nil {
|
||||
if err := writeBinary(w, data[off:end]); err != nil {
|
||||
return fmt.Errorf("push data chunk: %w", err)
|
||||
}
|
||||
}
|
||||
if err := writeText(c.w, mustJSON(map[string]interface{}{
|
||||
if err := writeText(w, mustJSON(map[string]interface{}{
|
||||
"op": "cmd_speech_end",
|
||||
"req_id": reqID,
|
||||
})); err != nil {
|
||||
@ -618,6 +642,26 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
||||
go r.handleWS(conn, rw)
|
||||
}
|
||||
|
||||
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
|
||||
//
|
||||
// handleWS 主循环与 Push* 是两条并发写同一 bufio.Writer 的路径,
|
||||
// 必须共用同一把锁。handleWS 里拿到的是 rw.Writer(与 conns 存储的是
|
||||
// 同一个对象),回写前必须经此函数取锁,否则跟 Push* 依然会撞。
|
||||
//
|
||||
// 注意设备已离线(conns 中已删除)时直接报错——设备断开后仍尝试
|
||||
// 回写没有意义,还可能在已关闭的 bufio 上写入。
|
||||
func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error) error {
|
||||
r.mu.RLock()
|
||||
c, ok := r.conns[deviceID]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
w := c.lockWrite()
|
||||
defer c.unlockWrite()
|
||||
return fn(w)
|
||||
}
|
||||
|
||||
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
defer conn.Close()
|
||||
var curID string
|
||||
@ -635,7 +679,9 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
payload, isClose, opcode, err := readFrame(rw.Reader)
|
||||
if err != nil {
|
||||
if err == errPing {
|
||||
if werr := writePong(rw.Writer); werr != nil {
|
||||
// pong 也走写锁:它可能在 Push* 持锁推送大块数据时到达。
|
||||
err := r.wsWriteLocked(curID, writePong)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
@ -679,11 +725,13 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
r.mu.Lock()
|
||||
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
|
||||
r.mu.Unlock()
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": true,
|
||||
})); err != nil {
|
||||
if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": true,
|
||||
}))
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
case "bind":
|
||||
@ -694,11 +742,17 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
// 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动
|
||||
// (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。
|
||||
}
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil {
|
||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil {
|
||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"}))
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user