Skip to content

Commit 5e823f5

Browse files
committed
修复:自更新每次都被静默回滚,前端卡在「正在重启并加载新版本…」
RollbackIfStale 跑在启动早期,而清除待验证标记的 MarkHealthy 要等端口监听 成功之后才执行。于是「标记还在」这个判据永远成立 —— 新版本刚起来的那一刻 就被判定成「上次更新后没能启动」,二进制被换回旧版再重启一次。 结果是每一次自更新都悄悄回退。前端轮询 /api/version 拿到的一直是旧版本号, 永远等不到目标版本,转满 60 秒才超时。用户看到的是「更新卡住了」, 实际上是更新成功之后又被自己撤销了。 修法:待验证标记里记「带着这个标记启动过几次」。 0 次 = 新版本头一回启动 → 放行,让它自己跑到 MarkHealthy 把标记删掉 ≥1 次 = 上一回带着标记启动过却没撑到 MarkHealthy → 这才是真的起不来,回滚 老版本写的标记内容是 "1",正好落在「已经启动过一次」这一档,升级上来语义正确。 顺带去掉健康信号那个 3 秒 sleep:改成自己 net.Listen,拿到 listener 就是 确凿的启动成功信号。原来靠「睡几秒应该起来了」猜,猜早了端口还没占上就 宣布健康,猜晚了这几秒内被重启一次会被误判成启动失败。 回归测试补了 4 个,覆盖首次启动放行、第二次才回滚、成功路径不留残留、 以及认得老版本的标记。把修复临时改回旧逻辑验证过:前三个会红。 受影响版本 v0.1.2 / v0.1.3 的用户没法用在线更新升上来(点了会转到超时), 需要手动替换一次二进制;换到这一版之后在线更新就正常了。
1 parent df84ed6 commit 5e823f5

4 files changed

Lines changed: 138 additions & 10 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.3
1+
0.1.4

server/internal/updater/updater.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"os"
3030
"path/filepath"
3131
"runtime"
32+
"strconv"
3233
"strings"
3334
"time"
3435

@@ -369,13 +370,35 @@ func RollbackIfStale(state *app.State) bool {
369370
if _, err := os.Stat(backup); err != nil {
370371
return false
371372
}
372-
if _, err := os.Stat(pending); err != nil {
373+
raw, perr := os.ReadFile(pending)
374+
if perr != nil {
373375
// 有备份但没有"启动中"标记:说明上次是正常起来过的(只是 MarkHealthy 没删掉),
374376
// 清掉备份即可,不要回滚 —— 那会把用户刚更新好的版本又换回旧的。
375377
_ = os.Remove(backup)
376378
return false
377379
}
378380

381+
// 标记里记的是"带着这个标记启动过几次"。
382+
//
383+
// 这里必须能区分「新版本正在启动」和「新版本上次没启动起来」——
384+
// 而这个函数跑在启动早期,MarkHealthy 要等端口监听成功才执行,
385+
// 所以光看"标记还在"永远是真的。之前就是这么写的,后果是每次自更新
386+
// 都在新版本的第一次启动时被判定为失败、静默回滚成旧版本,
387+
// 用户看到的是前端一直转"正在重启并加载新版本…"直到超时。
388+
//
389+
// 计数 0 = 新版本头一回启动,放行,让它自己跑到 MarkHealthy 把标记删掉;
390+
// 计数 ≥1 = 上一回带着标记启动过却没能撑到 MarkHealthy,这才是真的起不来。
391+
if boots := parseBoots(raw); boots < 1 {
392+
if err := os.WriteFile(pending, []byte(strconv.Itoa(boots+1)), 0o644); err != nil {
393+
// 写不进去就不能再放行:下次启动还会读到 0,永远滚不动,
394+
// 等于回滚保护彻底失效。宁可这一次多回滚一遍。
395+
state.Logger.Warn("[更新] 无法更新待验证标记,按保守策略回滚: "+err.Error(), "version")
396+
} else {
397+
state.Logger.Info("[更新] 新版本首次启动,回滚保护已就绪(启动成功后自动解除)", "version")
398+
return false
399+
}
400+
}
401+
379402
state.Logger.Error("[更新] 检测到上一次更新后未能正常启动,正在回滚到更新前的版本", "version")
380403
_ = os.Remove(pending)
381404
if err := os.Rename(backup, exe); err != nil {
@@ -389,9 +412,23 @@ func RollbackIfStale(state *app.State) bool {
389412
// pendingSuffix "更新后待验证"标记
390413
const pendingSuffix = ".pending"
391414

392-
// MarkPending 替换完二进制、准备重启前写下标记
415+
// MarkPending 替换完二进制、准备重启前写下标记。
416+
// 内容是"带着这个标记启动过几次",初始 0 —— 此刻新版本还一次都没启动过。
393417
func MarkPending() {
394418
if exe, err := selfPath(); err == nil {
395-
_ = os.WriteFile(exe+pendingSuffix, []byte("1"), 0o644)
419+
_ = os.WriteFile(exe+pendingSuffix, []byte("0"), 0o644)
420+
}
421+
}
422+
423+
// parseBoots 读启动次数。内容不认识就当 1 处理 ——
424+
// 老版本写的是 "1",而且"读不懂"时保守地倾向回滚比倾向放行安全。
425+
func parseBoots(raw []byte) int {
426+
n, err := strconv.Atoi(strings.TrimSpace(string(raw)))
427+
if err != nil {
428+
return 1
429+
}
430+
if n < 0 {
431+
return 0
396432
}
433+
return n
397434
}

server/internal/updater/updater_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,88 @@ func testUpdaterState(t *testing.T) *app.State {
349349
lg := logger.New(filepath.Join(dir, "t.log"), slog.New(slog.NewTextHandler(io.Discard, nil)))
350350
return app.NewState(storage.Paths{DataDir: dir}, config.New(database), lg, database)
351351
}
352+
353+
// 这是回归测试:自更新替换完二进制、重启进新版本,新版本的**第一次启动**
354+
// 绝不能被判定为"起不来"。
355+
//
356+
// 之前就是这么错的:RollbackIfStale 跑在启动早期,而清除标记的 MarkHealthy
357+
// 要等端口监听成功才执行 —— 于是"标记还在"永远成立,每次自更新都在新版本
358+
// 刚起来的那一刻被静默回滚成旧版本。用户看到的是前端一直转
359+
// "正在重启并加载新版本…"直到超时,以为是更新卡住了。
360+
func TestRollbackIfStale_新版本首次启动不回滚(t *testing.T) {
361+
exe := withFakeExe(t, "新版本")
362+
os.WriteFile(exe+backupSuffix, []byte("旧版本"), 0o755)
363+
MarkPending() // 自更新在重启前做的事
364+
365+
if RollbackIfStale(testUpdaterState(t)) {
366+
t.Fatal("新版本头一回启动就被回滚了 —— 这样自更新永远升不上去")
367+
}
368+
if got, _ := os.ReadFile(exe); string(got) != "新版本" {
369+
t.Errorf("二进制不该被换掉,实际 %q", got)
370+
}
371+
if _, err := os.Stat(exe + pendingSuffix); err != nil {
372+
t.Error("标记要留着:这一版还没跑到 MarkHealthy,万一崩了下次启动要靠它回滚")
373+
}
374+
}
375+
376+
// 首次启动放行之后,如果这一版真的没能跑到 MarkHealthy,
377+
// 下一次启动就必须回滚 —— 否则回滚保护等于没有
378+
func TestRollbackIfStale_第二次启动才回滚(t *testing.T) {
379+
exe := withFakeExe(t, "新版本(会崩)")
380+
os.WriteFile(exe+backupSuffix, []byte("旧版本(能跑)"), 0o755)
381+
MarkPending()
382+
383+
st := testUpdaterState(t)
384+
if RollbackIfStale(st) {
385+
t.Fatal("第一次不该回滚")
386+
}
387+
// 模拟:这一版启动后崩了,没到 MarkHealthy,进程被拉起来第二次
388+
if !RollbackIfStale(st) {
389+
t.Fatal("第二次带着标记启动,说明上一次没撑到健康点,必须回滚")
390+
}
391+
if got, _ := os.ReadFile(exe); string(got) != "旧版本(能跑)" {
392+
t.Errorf("回滚后应该是旧版本,实际 %q", got)
393+
}
394+
if _, err := os.Stat(exe + pendingSuffix); !os.IsNotExist(err) {
395+
t.Error("回滚后要清掉标记,否则会反复回滚")
396+
}
397+
}
398+
399+
// 完整走一遍成功路径:更新 → 首次启动放行 → 跑到健康点 → 标记和备份都清掉,
400+
// 之后再启动多少次都不会回滚
401+
func TestRollback成功路径不留残留(t *testing.T) {
402+
exe := withFakeExe(t, "新版本")
403+
os.WriteFile(exe+backupSuffix, []byte("旧版本"), 0o755)
404+
MarkPending()
405+
406+
st := testUpdaterState(t)
407+
if RollbackIfStale(st) {
408+
t.Fatal("首次启动不该回滚")
409+
}
410+
MarkHealthy(st) // 端口监听成功
411+
412+
if _, err := os.Stat(exe + pendingSuffix); !os.IsNotExist(err) {
413+
t.Error("健康之后标记该没了")
414+
}
415+
if _, err := os.Stat(exe + backupSuffix); !os.IsNotExist(err) {
416+
t.Error("健康之后备份该没了")
417+
}
418+
if RollbackIfStale(st) {
419+
t.Fatal("已经健康过了,再启动不该回滚")
420+
}
421+
if got, _ := os.ReadFile(exe); string(got) != "新版本" {
422+
t.Errorf("二进制该保持新版本,实际 %q", got)
423+
}
424+
}
425+
426+
// 老版本写的标记内容是 "1"。升级上来时如果把它当成"没启动过"而放行,
427+
// 一个真的起不来的版本就会永远滚不回去
428+
func TestRollbackIfStale_认得老版本的标记(t *testing.T) {
429+
exe := withFakeExe(t, "新版本(起不来)")
430+
os.WriteFile(exe+backupSuffix, []byte("旧版本"), 0o755)
431+
os.WriteFile(exe+pendingSuffix, []byte("1"), 0o644) // v0.1.3 及以前的写法
432+
433+
if !RollbackIfStale(testUpdaterState(t)) {
434+
t.Fatal("老标记应当被当作『已经启动过一次』,直接回滚")
435+
}
436+
}

server/main.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io/fs"
77
"log/slog"
8+
"net"
89
"net/http"
910
"os"
1011
"strings"
@@ -497,11 +498,16 @@ func main() {
497498
// 端口真正 Listen 成功之后才标记"这一版能跑" —— 此时数据库已打开、路由已注册、
498499
// 端口也占上了。太早标记等于没验证:启动就 panic、端口被占、数据库损坏,
499500
// 恰恰是最需要回滚的几种情况。
500-
go func() {
501-
// 给 ListenAndServe 一点时间真正把端口占上;失败的话进程已经退出,这里不会执行
502-
time.Sleep(3 * time.Second)
503-
updater.MarkHealthy(state)
504-
}()
501+
//
502+
// 自己 Listen 而不是用 ListenAndServe + sleep:后者只能靠"睡几秒应该起来了"猜,
503+
// 猜早了端口还没占上就宣布健康,猜晚了这几秒里被重启一次就会被误判成启动失败。
504+
// 拿到 listener 就是确凿的成功信号,没有窗口。
505+
ln, err := net.Listen("tcp", addr)
506+
if err != nil {
507+
console.Error("listen", "err", err)
508+
os.Exit(1)
509+
}
510+
updater.MarkHealthy(state)
505511

506512
// 自更新完成后走这里:先停止接受新请求并等在途请求收尾,再关数据库,最后换进程映像。
507513
// 顺序不能反 —— 先 exec 的话,新进程会发现端口还被自己占着。
@@ -522,7 +528,7 @@ func main() {
522528
}
523529
}
524530

525-
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
531+
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
526532
console.Error("server run", "err", err)
527533
os.Exit(1)
528534
}

0 commit comments

Comments
 (0)