mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(update): 升级后第一次启动被误回滚(真机 OTA 实测)+ 关闭期 nil 解引用
## 一、★★★ 升级永远无法生效(rollback.go)
真机跑 OTA 闭环时暴露:
-apply 输出:✅ 升级完成:1.4.0dev (commit ca5b1b1)
重启后日志:ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)
原因:pending 标记是在**替换之前**写的(这是对的,见下),所以升级后的
**第一次启动必然看到它**。而原实现"只要标记在就回滚",从不比对当前运行的
是哪个版本 ⇒ 每次升级都在第一次启动时被自己撤销。
正确语义(两段式确认的本意)是比对版本:
当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认、清标记、继续
当前版本 != To(或读不到) ⇒ 新版本没能成为运行版本 ⇒ 回滚到备份
标记仍然必须写在替换**之前**:反过来(替换后才写)会在"替换成功但没来得及
写标记就崩了"时丢标记,那时就不知道该回滚了。
两条判据(用真能执行 `--version` 的脚本做假二进制 —— 原来那些用例写死字节、
根本读不出版本,只覆盖了"读不到 ⇒ 回滚"这一条分支,所以抓不到本缺陷):
TestRollback_ConfirmsWhenRunningTargetVersion 跑的是 To ⇒ 不回滚 + 清标记
TestRollback_RollsBackWhenRunningOldVersion 跑的是 From ⇒ 必须回滚
## 二、每次重启必现两条 panic(proc/host.go)
[stage] tool xxx handler panic: invalid memory address or nil pointer dereference
github.com/.../proc.(*unifiedRegion).generation(...)
关机/重启时 SharedRef 统一段先被拆掉,而在途的工具调用(stage handler /
cleaner proxy)仍会走到 Host.Generation() ⇒ 解引用 nil。panic 虽被 recover,
但把"正常关闭"伪装成故障,并刷屏掩盖真问题。加 nil 守卫(连 h 一起判)。
## 三、下载客户端 20s 总超时(release.go)
`DownloadLatestRelease` 复用了查询用的 kernelHTTPClient(Timeout=20s,
那是**整条请求**上限,含读完 body)。而发行包近 900MB ⇒ 必然
`context deadline exceeded ... while reading body`,"从 Release 自动更新"
永久失败,且错误看起来像网络问题。
改为独立下载客户端:建连/TLS/响应头仍分层设超时(卡住能失败),
读 body 不设总时长。
This commit is contained in:
@ -393,7 +393,18 @@ func (c *stageCoordinator) finish(sc *pubsdk.StageContext, written bool) error {
|
||||
func (h *Host) Arena() *arenaRegion { return h.arena }
|
||||
|
||||
// Generation 返回统一区域当前 generation(SharedRef 校验用)。
|
||||
func (h *Host) Generation() uint64 { return h.unified.generation() }
|
||||
//
|
||||
// ★ 必须判空(2026-10-26 真机实测):关机/重启时 unified 段会先被拆掉,
|
||||
// 而此时仍有在途的工具调用(stage handler、cleaner)走到这里 ——
|
||||
// 直接解引用会 panic(每次 OTA 重启必现两条:
|
||||
// `[stage] tool xxx handler panic: invalid memory address`)。
|
||||
// panic 虽被 recover 住,但会污染日志、并把"正常关闭"伪装成故障。
|
||||
func (h *Host) Generation() uint64 {
|
||||
if h == nil || h.unified == nil {
|
||||
return 0
|
||||
}
|
||||
return h.unified.generation()
|
||||
}
|
||||
|
||||
// NextOwnerID 分配一个插件专用的槽 owner ID。
|
||||
//
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -29,8 +30,35 @@ func ghRepo() string {
|
||||
return "JianFeeeee/HologramAgent"
|
||||
}
|
||||
|
||||
// kernelHTTPClient 用于**查询**(JSON 元数据,小、快)。
|
||||
//
|
||||
// 20s 总超时在这里是合适的:查不到就该快速失败,让用户看到"查不了",
|
||||
// 而不是挂着一个"正在检查更新"的界面。
|
||||
var kernelHTTPClient = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
// downloadHTTPClient 用于**下载安装包**。
|
||||
//
|
||||
// ★ 为什么不能复用上面那个客户端(实测踩到):发行包约 880MB,
|
||||
// 而 Client.Timeout 是**整条请求**的上限(含读完 body)—— 20s 下必然
|
||||
// `context deadline exceeded (Client.Timeout ... while reading body)`。
|
||||
// 那会让"从 Release 自动更新"永远失败,而错误看起来像网络问题。
|
||||
//
|
||||
// ⇒ 下载客户端**不设总超时**,改用分层超时:
|
||||
//
|
||||
// · 建连/TLS 握手/等响应头 —— 有界,卡住就失败
|
||||
// · 读 body —— 不限总时长(大文件),靠连接空闲超时 + 传输层兜底
|
||||
var downloadHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ReleaseInfo 是一次「内核检查更新」的结果。
|
||||
type ReleaseInfo struct {
|
||||
Current string `json:"current"` // 本机版本
|
||||
@ -144,7 +172,7 @@ func DownloadLatestRelease(dst string) (*ReleaseInfo, error) {
|
||||
}
|
||||
|
||||
func downloadFile(url, dst string) error {
|
||||
resp, err := kernelHTTPClient.Get(url)
|
||||
resp, err := downloadHTTPClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -127,6 +127,35 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
// ★★★ 先判"这次启动跑的是不是升级后的那个版本"。
|
||||
//
|
||||
// 这一步是必须的,而且顺序不能反(2026-10-26 真机实测踩到):
|
||||
// 标记是在**替换之前**写的,所以升级后的**第一次启动**必然看到它。
|
||||
// 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销 ——
|
||||
// 实测表现:`-apply` 报"升级完成",重启后日志却是
|
||||
// "ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)",
|
||||
// 也就是升级永远不可能生效。
|
||||
//
|
||||
// 语义区分(这才是两段式确认的本意):
|
||||
//
|
||||
// 当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认,清标记,继续启动
|
||||
// 当前版本 != To(或读不到)⇒ 新版本没能成为运行版本(exec 失败、被 guard 回退等)
|
||||
// ⇒ 回滚到备份
|
||||
confirmed := false
|
||||
if cur, cerr := ReadBinVersion(binPath); cerr == nil {
|
||||
sameVer := cur.Version == rec.ToVer
|
||||
sameCommit := rec.ToCommit == "" || cur.Commit == rec.ToCommit
|
||||
confirmed = sameVer && sameCommit
|
||||
}
|
||||
if confirmed {
|
||||
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
|
||||
log.Printf("[update] %s 已生效,但清除标记失败: %v", rec.ToVer, cerr)
|
||||
} else {
|
||||
log.Printf("[update] upgrade to %s confirmed (running binary matches), marker cleared", rec.ToVer)
|
||||
}
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
log.Printf("[update] found unconfirmed upgrade: %s → %s(上次升级后内核未能启动成功)",
|
||||
ShortCommit(rec.FromCommit), ShortCommit(rec.ToCommit))
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package update
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@ -243,3 +244,86 @@ func TestPendingMarker_TimestampSet(t *testing.T) {
|
||||
t.Errorf("At 应接近当前时间,实际 %v", got.At)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:升级后的**第一次启动**必须确认,不能回滚。
|
||||
//
|
||||
// 真机实测(2026-10-26)踩到的缺陷:标记在替换**之前**写,所以升级后第一次
|
||||
// 启动必然看到它。原实现"见标记就回滚"⇒ 每次升级都被立刻撤销:
|
||||
//
|
||||
// -apply 报"✅ 升级完成"
|
||||
// 重启后日志:ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)
|
||||
//
|
||||
// 升级因此永远不可能生效。正确语义是比对版本:
|
||||
// 当前跑的 == 标记里的 To ⇒ 升级已生效 ⇒ 确认并清标记。
|
||||
func TestRollback_ConfirmsWhenRunningTargetVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := writeVersionScript(t, dir, "homed", "1.4.1", "newcommit")
|
||||
|
||||
backupDir := filepath.Join(dir, "backup")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
os.WriteFile(filepath.Join(backupDir, "homed"), []byte("OLD-GOOD"), 0755)
|
||||
|
||||
MarkPendingUpgrade(dir, PendingRecord{
|
||||
FromVer: "1.4.0", FromCommit: "old",
|
||||
ToVer: "1.4.1", ToCommit: "newcommit",
|
||||
BackupDir: backupDir,
|
||||
})
|
||||
|
||||
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
|
||||
if err != nil {
|
||||
t.Fatalf("确认不应失败: %v", err)
|
||||
}
|
||||
if rolled {
|
||||
t.Fatal("★★★ 已在跑升级后的版本,却被回滚了 —— 升级永远无法生效")
|
||||
}
|
||||
// 标记必须被清掉,否则下次启动又判一次
|
||||
if reced, _ := ReadPendingUpgrade(dir); reced != nil {
|
||||
t.Error("确认成功后应清除 pending 标记")
|
||||
}
|
||||
// 二进制不能被改动
|
||||
if b, _ := os.ReadFile(bin); !strings.Contains(string(b), "1.4.1") {
|
||||
t.Error("确认路径不应改写二进制")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:当前版本**不是**升级目标 ⇒ 必须回滚(升级没生效/被回退)。
|
||||
func TestRollback_RollsBackWhenRunningOldVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := writeVersionScript(t, dir, "homed", "1.4.0", "oldcommit")
|
||||
|
||||
backupDir := filepath.Join(dir, "backup")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
os.WriteFile(filepath.Join(backupDir, "homed"), []byte("OLD-GOOD"), 0755)
|
||||
|
||||
MarkPendingUpgrade(dir, PendingRecord{
|
||||
FromVer: "1.4.0", FromCommit: "oldcommit",
|
||||
ToVer: "1.4.1", ToCommit: "newcommit",
|
||||
BackupDir: backupDir,
|
||||
})
|
||||
|
||||
rolled, from, to, err := RollbackIfUnconfirmed(dir, bin)
|
||||
if err != nil {
|
||||
t.Fatalf("回滚不应失败: %v", err)
|
||||
}
|
||||
if !rolled {
|
||||
t.Fatal("跑的还是旧版本,说明升级没生效,必须回滚")
|
||||
}
|
||||
if from != "1.4.0" || to != "1.4.1" {
|
||||
t.Errorf("版本轨迹不对: %s → %s", from, to)
|
||||
}
|
||||
}
|
||||
|
||||
// writeVersionScript 造一个能被 ReadBinVersion 读出版本的假二进制。
|
||||
//
|
||||
// 为什么用脚本而不是随便写字节:ReadBinVersion 靠**执行** `<bin> --version`
|
||||
// 取版本,写死字节的假文件根本跑不起来(现有用例走的就是"读不到 ⇒ 回滚"
|
||||
// 分支,所以那些用例测不到"确认"这条路径)。
|
||||
func writeVersionScript(t *testing.T, dir, name, ver, commit string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
body := "#!/bin/sh\nprintf 'version=" + ver + "\\ncommit=" + commit + "\\n'\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0755); err != nil {
|
||||
t.Fatalf("写假二进制: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user