Skip to content

Commit 14aef29

Browse files
author
YuLaiZ
committed
Add update step logs, result consumption and stale temp file cleanup
1 parent dc5277a commit 14aef29

16 files changed

Lines changed: 878 additions & 52 deletions

‎internal/cli/update.go‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"os"
99
goruntime "runtime"
10+
"time"
1011

1112
"github.com/spf13/cobra"
1213

@@ -102,6 +103,24 @@ func defaultUpdateServiceFactory(info buildinfo.Info, checkOnly bool) (UpdateSer
102103
svc.Installer = buildUpdateInstaller()
103104
svc.AssetDownloader = downloader
104105
svc.VersionProbe = update.NewExecVersionProbe()
106+
107+
// 打开升级日志文件并注入 LogSink/LogPath + installer 的 writer/logDir。
108+
// 日志打开失败是 best-effort:Apply 不依赖日志也能工作。
109+
if home, herr := os.UserHomeDir(); herr == nil {
110+
logDir := update.ResolveUpdateLogDir(home)
111+
if f, logPath, oerr := update.OpenUpdateLogFile(logDir, time.Now()); oerr == nil {
112+
svc.LogSink = f
113+
svc.LogPath = logPath
114+
// POSIX 安装器接收 logWriter 输出 [install] 行;Windows 安装器接收 logDir
115+
// 重定向 helper stderr。经类型断言注入,使工厂无需知道安装器具体类型。
116+
if s, ok := svc.Installer.(update.StepLogWriter); ok {
117+
s.SetLogWriter(f)
118+
}
119+
if s, ok := svc.Installer.(update.HelperLogDirSetter); ok {
120+
s.SetLogDir(logDir)
121+
}
122+
}
123+
}
105124
}
106125
return svc, nil
107126
}
@@ -182,6 +201,11 @@ func runUpdate(cmd *cobra.Command, info buildinfo.Info) error {
182201
return err
183202
}
184203

204+
// 升级日志文件在工厂内打开,命令返回前关闭句柄(仅 *update.Service 有此方法)。
205+
if rs, ok := svc.(*update.Service); ok {
206+
defer rs.CloseLogSink()
207+
}
208+
185209
ctx := cmdContext(cmd)
186210

187211
if checkOnly {
@@ -237,9 +261,16 @@ func runUpdateApply(ctx context.Context, cmd *cobra.Command, svc UpdateService,
237261
res, err := svc.Apply(ctx, update.ApplyOptions{TargetTag: versionFlag})
238262
if err != nil {
239263
fmt.Fprintf(errOut, "更新失败: %v\n", err)
264+
if res.LogPath != "" {
265+
fmt.Fprintf(out, "升级日志: %s\n", res.LogPath)
266+
}
240267
return err
241268
}
242-
return renderApplyResult(out, errOut, res)
269+
renderErr := renderApplyResult(out, errOut, res)
270+
if res.LogPath != "" {
271+
fmt.Fprintf(out, "升级日志: %s\n", res.LogPath)
272+
}
273+
return renderErr
243274
}
244275

245276
// renderApplyResult 把 ApplyResult 翻译为面向用户的中文提示。

‎internal/cli/update_helper_windows.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func runUpdateHelperCmd(ctx context.Context, planPath string) error {
4949
}
5050
cm := update.NewControlManager(mgr)
5151

52-
runner, err := update.NewHelperRunner(parent, mover, result, cm, loadConfig)
52+
runner, err := update.NewHelperRunner(parent, mover, result, cm, loadConfig, os.Stderr)
5353
if err != nil {
5454
return fmt.Errorf("装配 helper runner 失败: %w", err)
5555
}

‎internal/update/apply_download_test.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,21 @@ func TestApply_TrustedDownloadsStageViaAssetDownloader(t *testing.T) {
9999
if stagePath == "" {
100100
t.Fatal("stagePath 不应为空(已注入 AssetDownloader,应下载到真实 stage)")
101101
}
102-
content, rerr := os.ReadFile(stagePath)
103-
if rerr != nil {
104-
t.Fatalf("读取 stage 失败 %s: %v", stagePath, rerr)
102+
// stage 内容须在 Install 内捕获:Apply 成功后外部 stage 已删(removeRegularFile)。
103+
if len(installer.stageContents) != 1 || installer.stageContents[0] == nil {
104+
t.Fatalf("应在 Install 内捕获 stage 内容,stageContents=%v", installer.stageContents)
105105
}
106+
content := installer.stageContents[0]
106107
if string(content) != string(targetBin) {
107108
t.Fatalf("stage 内容不匹配,got %q want %q", string(content), string(targetBin))
108109
}
109110
if filepath.Dir(stagePath) != filepath.Dir(got.BinaryPath) {
110111
t.Errorf("stage 目录 %q 应与 target 同目录 %q", filepath.Dir(stagePath), filepath.Dir(got.BinaryPath))
111112
}
113+
// 外部 stage 文件应在 Apply 后被删除。
114+
if _, err := os.Stat(stagePath); !os.IsNotExist(err) {
115+
t.Errorf("外部 stage 应在 Apply 后删除: %s", stagePath)
116+
}
112117
}
113118

114119
// TestApply_DownloadFailureRejectsInstall 目标资产下载失败(500):

‎internal/update/helper_plan.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ func validateHelperPlanFields(plan helperPlan) error {
297297
//
298298
// 严格安全:只删除 (selfDir, targetBasename, nonce) 派生的精确路径,且只删普通文件
299299
// (拒绝 symlink / 目录);不存在的文件视为已清理(幂等)。绝不按前缀模糊匹配。
300-
// 不删除 result 文件——它供下一次 update --check 展示,由后续更新覆盖或人工查看。
300+
// 不删除 result 文件——它供下次完整 update(Apply)在来源校验通过后消费,由后续更新覆盖或人工查看。
301301
func CleanupHelperTempFiles(selfDir, targetBasename, nonce string) error {
302302
if selfDir == "" || targetBasename == "" || nonce == "" {
303303
return errors.New("清理 helper 临时文件的 selfDir/targetBasename/nonce 不能为空")

‎internal/update/helper_plan_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ func TestIsHexNonce(t *testing.T) {
445445
}
446446

447447
// TestCleanupHelperTempFiles_DeletesNonceBoundFiles 清理 helper 临时文件:
448-
// 删除 helper.exe/plan/stage/backup,保留 result(供下次 update --check 展示)。
448+
// 删除 helper.exe/plan/stage/backup,保留 result(供下次完整 update 在来源校验通过后消费)。
449449
func TestCleanupHelperTempFiles_DeletesNonceBoundFiles(t *testing.T) {
450450
dir := t.TempDir()
451451
const base = "token-usage"
@@ -466,7 +466,7 @@ func TestCleanupHelperTempFiles_DeletesNonceBoundFiles(t *testing.T) {
466466
t.Errorf("临时文件 %s 应被删除", p)
467467
}
468468
}
469-
// result 应保留(供下次 update --check 展示)。
469+
// result 应保留(供下次完整 update 在来源校验通过后消费)。
470470
if _, err := os.Stat(paths.Result); err != nil {
471471
t.Errorf("result 文件应保留,stat err=%v", err)
472472
}

‎internal/update/helper_runner.go‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io"
89

910
"github.com/YuLaiZ/token-usage/internal/config"
1011
"github.com/YuLaiZ/token-usage/internal/control"
@@ -17,7 +18,7 @@ import (
1718
// 2. 等待父进程退出(释放对旧 .exe 的句柄);
1819
// 3. 在 control lock 内:确认 daemon 未在停止后意外运行 → 备份旧 target →
1920
// MoveFileEx(stage→target) → 校验新 hash → 按 wasRunning 重启 daemon;
20-
// 4. 把执行结果写入 result 文件,供父进程下一次 update --check 展示;
21+
// 4. 把执行结果写入 result 文件,供下次完整 update(Apply)在来源校验通过后消费;
2122
// 5. 任一步失败:从 backup 回滚 target、必要时重启旧 daemon、写失败 result。
2223
//
2324
// 所有外部依赖(等待父进程、文件移动、结果写入、control lock)经字段注入,
@@ -38,15 +39,18 @@ type helperRunner struct {
3839
resultWriter ResultWriter // 写 result 文件
3940
controlMgr ControlManager // control lock(daemon 检查 / 重启)
4041
configLoader control.ConfigLoader // 锁内加载有效配置
42+
helperLog *stepLogger // [helper] 步骤日志(从 logWriter 构造,nil=静默)
4143
}
4244

43-
// NewHelperRunner 构造后台 helper 编排器。所有依赖必须非空(否则 Run 返回装配错误)。
45+
// NewHelperRunner 构造后台 helper 编排器。前五个依赖必须非空(否则返回装配错误);
46+
// logWriter 可为 nil(静默),生产由 CLI 注入 os.Stderr(父进程 spawn 时重定向到日志文件)。
4447
func NewHelperRunner(
4548
parentWaiter ParentWaiter,
4649
fileMover FileMover,
4750
resultWriter ResultWriter,
4851
controlMgr ControlManager,
4952
configLoader control.ConfigLoader,
53+
logWriter io.Writer,
5054
) (*helperRunner, error) {
5155
if parentWaiter == nil || fileMover == nil || resultWriter == nil || controlMgr == nil || configLoader == nil {
5256
return nil, errors.New("helperRunner 所有依赖不能为空")
@@ -57,6 +61,7 @@ func NewHelperRunner(
5761
resultWriter: resultWriter,
5862
controlMgr: controlMgr,
5963
configLoader: configLoader,
64+
helperLog: newStepLogger(logWriter, "helper", nil),
6065
}, nil
6166
}
6267

@@ -77,12 +82,14 @@ func (r *helperRunner) Run(ctx context.Context, selfExe, planPath string) error
7782
// execute 在计划校验通过后执行:等待父进程 → 加载配置 → control lock 内替换。
7883
func (r *helperRunner) execute(ctx context.Context, validated validatedHelperPlan) error {
7984
plan, paths := validated.Plan, validated.Paths
85+
r.helperLog.step("started (nonce=%s)", plan.Nonce)
8086

8187
// 2. 等待父进程退出(据 plan 中的显式身份,杜绝 PID 复用 TOCTOU)。
8288
if err := r.parentWaiter.WaitParentExit(ctx, plan.Parent); err != nil {
8389
r.fail(paths.Result, fmt.Errorf("等待父进程失败: %w", err), "")
8490
return err
8591
}
92+
r.helperLog.step("parent exited")
8693

8794
// 3. 加载有效配置(control lock 内复用)。
8895
cfg, err := r.configLoader()
@@ -96,9 +103,20 @@ func (r *helperRunner) execute(ctx context.Context, validated validatedHelperPla
96103
}
97104

98105
// 4. control lock 内完成 daemon 检查 + 替换 + 重启。
99-
return r.controlMgr.WithLock(ctx, func(sess ControlSession) error {
106+
// WithLock 在锁获取失败(超时/context 取消)时不执行回调——此时 executeUnderLock 不会被
107+
// 调用,fail/succeed 也不会写 result。用 lockEntered 跟踪回调是否已进入:未进入时补写
108+
// 失败 result,使下次 update 能消费该失败(父命令已报告 Deferred,用户以为升级在进行中);
109+
// 已进入时 executeUnderLock 内部已通过 fail/succeed 写了 result,不覆盖。
110+
lockEntered := false
111+
lockErr := r.controlMgr.WithLock(ctx, func(sess ControlSession) error {
112+
lockEntered = true
100113
return r.executeUnderLock(ctx, sess, cfg, plan, paths)
101114
})
115+
if lockErr != nil && !lockEntered {
116+
r.fail(paths.Result, fmt.Errorf("获取 control lock 失败: %w", lockErr), "")
117+
return lockErr
118+
}
119+
return lockErr
102120
}
103121

104122
// executeUnderLock 在 control lock 持有期内完成替换与 daemon 切换。
@@ -122,12 +140,14 @@ func (r *helperRunner) executeUnderLock(ctx context.Context, sess ControlSession
122140
r.fail(paths.Result, errors.New("daemon 在停止后意外运行,放弃替换"), "")
123141
return errors.New("daemon 运行中,放弃替换")
124142
}
143+
r.helperLog.step("daemon wasRunning=%v", plan.WasRunning)
125144

126145
// b. 备份旧 target → backup,校验旧 hash。
127146
if err := backupForHelper(paths.Target, paths.Backup, plan.OldSHA256); err != nil {
128147
r.fail(paths.Result, fmt.Errorf("备份旧 target 失败: %w", err), "")
129148
return fmt.Errorf("备份旧 target 失败: %w", err)
130149
}
150+
r.helperLog.step("backup OK")
131151

132152
// c. MoveFileEx(stage → target)。
133153
if err := r.fileMover.MoveReplace(paths.Stage, paths.Target); err != nil {
@@ -136,13 +156,15 @@ func (r *helperRunner) executeUnderLock(ctx context.Context, sess ControlSession
136156
r.fail(paths.Result, fmt.Errorf("MoveFileEx 替换失败: %w", err), errToString(rbErr))
137157
return fmt.Errorf("MoveFileEx 替换失败(已回滚): %w", errors.Join(err, rbErr))
138158
}
159+
r.helperLog.step("MoveFileEx OK")
139160

140161
// 校验新 target hash(防御移动过程中损坏)。
141162
if err := verifyFileHash(paths.Target, plan.NewSHA256); err != nil {
142163
rbErr := rollbackForHelper(paths.Target, paths.Backup, plan.OldSHA256)
143164
r.fail(paths.Result, fmt.Errorf("替换后新 target 校验失败: %w", err), errToString(rbErr))
144165
return fmt.Errorf("替换后新 target 校验失败(已回滚): %w", errors.Join(err, rbErr))
145166
}
167+
r.helperLog.step("hash verified")
146168

147169
// d. wasRunning → 启动新 daemon。
148170
if plan.WasRunning {
@@ -158,6 +180,7 @@ func (r *helperRunner) executeUnderLock(ctx context.Context, sess ControlSession
158180
fmt.Sprintf("rollback=%v restart=%v", rbErr, restartErr))
159181
return fmt.Errorf("启动新 daemon 失败(已回滚重启): %w", errors.Join(serr, rbErr, restartErr))
160182
}
183+
r.helperLog.step("daemon restarted")
161184
}
162185

163186
// e. 成功。
@@ -190,24 +213,35 @@ func rollbackForHelper(target, backup, expectedOldHash string) error {
190213
return verifyFileHash(target, expectedOldHash)
191214
}
192215

193-
// succeed 写成功 result。
216+
// succeed 写成功 result,并据写入结果记终态日志(result=ok 或 result write failed)。
194217
func (r *helperRunner) succeed(resultPath string) {
195-
r.writeResult(resultPath, helperResult{Success: true})
218+
if err := r.writeResult(resultPath, helperResult{Success: true}); err != nil {
219+
r.helperLog.step("result write failed: %v", err)
220+
} else {
221+
r.helperLog.step("result=ok")
222+
}
196223
}
197224

198-
// fail 写失败 result(error + 可选 rollback 信息)。
225+
// fail 写失败 result(error + 可选 rollback 信息),并据写入结果记终态日志。
226+
// 注意:result=ok 表示 result 文件写入成功(终态日志三态:ok/failure recorded/write failed),
227+
// fail 路径用 result=failure recorded 区分于 succeed 路径的 result=ok。
199228
func (r *helperRunner) fail(resultPath string, cause error, rollback string) {
200-
r.writeResult(resultPath, helperResult{Success: false, Error: cause.Error(), Rollback: rollback})
229+
if err := r.writeResult(resultPath, helperResult{Success: false, Error: cause.Error(), Rollback: rollback}); err != nil {
230+
r.helperLog.step("result write failed: %v", err)
231+
} else {
232+
r.helperLog.step("result=failure recorded")
233+
}
201234
}
202235

203-
// writeResult 把 result JSON 序列化后写入 resultPath(0600 权限)。
204-
func (r *helperRunner) writeResult(resultPath string, res helperResult) {
236+
// writeResult 把 result JSON 序列化后写入 resultPath(0600 权限),返回写入错误。
237+
// 序列化失败或写入失败返回 error;调用方据此记终态日志。result 写入失败不改变已完成
238+
// 替换的主成功结果(仅记日志),下次 consume 读不到则留待人工。
239+
func (r *helperRunner) writeResult(resultPath string, res helperResult) error {
205240
data, err := marshalHelperResult(res)
206241
if err != nil {
207-
// 序列化失败属于编程错误;result 写不了,只能放弃(不影响主流程错误)。
208-
return
242+
return fmt.Errorf("序列化 result 失败: %w", err)
209243
}
210-
_ = r.resultWriter.WriteResult(resultPath, data, 0o600)
244+
return r.resultWriter.WriteResult(resultPath, data, 0o600)
211245
}
212246

213247
// marshalHelperResult 序列化 helperResult 为 JSON。

0 commit comments

Comments
 (0)