From ca956f69bf570e6a90fc3dbe38b2dc239a361a91 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 26 Oct 2026 14:03:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(update):=20=E5=8D=87=E7=BA=A7=E5=90=8E?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AC=A1=E5=90=AF=E5=8A=A8=E8=A2=AB=E8=AF=AF?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=EF=BC=88=E7=9C=9F=E6=9C=BA=20OTA=20=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=EF=BC=89+=20=E5=85=B3=E9=97=AD=E6=9C=9F=20nil=20?= =?UTF-8?q?=E8=A7=A3=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、★★★ 升级永远无法生效(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 不设总时长。 --- internal/plugin/proc/host.go | 13 ++++- internal/update/release.go | 30 +++++++++++- internal/update/rollback.go | 29 +++++++++++ internal/update/rollback_test.go | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/internal/plugin/proc/host.go b/internal/plugin/proc/host.go index e156504b..16331d4c 100644 --- a/internal/plugin/proc/host.go +++ b/internal/plugin/proc/host.go @@ -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。 // diff --git a/internal/update/release.go b/internal/update/release.go index 3479d383..7e9f7ad3 100644 --- a/internal/update/release.go +++ b/internal/update/release.go @@ -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 } diff --git a/internal/update/rollback.go b/internal/update/rollback.go index f4efebc4..9fe100cd 100644 --- a/internal/update/rollback.go +++ b/internal/update/rollback.go @@ -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)) diff --git a/internal/update/rollback_test.go b/internal/update/rollback_test.go index 42764a29..1ba8e07a 100644 --- a/internal/update/rollback_test.go +++ b/internal/update/rollback_test.go @@ -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 靠**执行** ` --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 +}