fix: preserve rollback binaries during ambiguous recovery and YAML settings

This commit is contained in:
Sycun committed 2026-10-09 10:27:17 +08:00
1 parent 36e1d9680b
commit 54d8f1187d
10 files changed
+238 -22

No files matched your search

+2
View File
@@ -77,6 +77,8 @@ func TestUpgradeScriptUpdateConfigVersionPreservesDocumentAndMode(t *testing.T)
wantDocumentStart bool
}{
{name: "document start", original: "---\nserver:\n port: 8088\n", wantDocumentStart: true},
{name: "commented document start", original: "# local settings\n---\nserver:\n port: 8088\n"},
{name: "directive document start", original: "%YAML 1.1\n---\nserver:\n port: 8088\n"},
{name: "private mode", original: "version: \"v1.0.0\"\nserver:\n port: 8088\n"},
} {
t.Run(tc.name, func(t *testing.T) {
+11 -4
View File
@@ -1840,11 +1840,18 @@ func WriteVersion(path, version string) (bool, error) {
}
if !replaced {
versionLine := `version: "` + version + `"`
if len(lines) > 0 && yamlDocumentStart(lines[0]) {
lines = append([]string{lines[0], versionLine}, lines[1:]...)
} else {
lines = append([]string{versionLine}, lines...)
insertAt := 0
for i, line := range lines {
if yamlDocumentStart(line) {
insertAt = i + 1
break
}
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\uFEFF"))
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(line, "%") {
break
}
}
lines = append(lines[:insertAt], append([]string{versionLine}, lines[insertAt:]...)...)
}
out := strings.Join(lines, "\n")
if out == string(data) {
+22
View File
@@ -532,3 +532,25 @@ func readTestFile(t *testing.T, path string) string {
}
return string(data)
}
func TestWriteVersionAfterCommentedDocumentStart(t *testing.T) {
for _, prefix := range []string{"# local settings\n---\n", "\n# local settings\n--- # config\n", "%YAML 1.1\n---\n"} {
t.Run(prefix, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := prefix + "server:\n port: 8088\n"
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
t.Fatal(err)
}
if changed, err := WriteVersion(path, "v1.2.3"); err != nil || !changed {
t.Fatalf("write: %v %v", changed, err)
}
cfg, err := Load(path)
if err != nil || cfg.Server.Port != 8088 {
t.Fatalf("settings lost after version write: cfg=%+v err=%v", cfg, err)
}
if got := readTestFile(t, path); got != prefix+"version: \"v1.2.3\"\nserver:\n port: 8088\n" {
t.Fatalf("unexpected rewrite: %q", got)
}
})
}
}
+37
View File
@@ -390,6 +390,25 @@ func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res *
pending.BackupDir = state.BackupDir
pending.VersionBefore = state.VersionBefore
pending.VersionAfter = state.VersionAfter
pending.PreviousBinarySHA256 = state.PreviousBinarySHA256
if state.BinaryCommit != "" {
binaryCommit, resolveErr := gitCmd(ctx, snap.Root, "rev-parse", state.BinaryCommit)
previous, previousErr := gitCmd(ctx, snap.Root, "rev-parse", state.PreviousCommit)
if resolveErr != nil || previousErr != nil || (binaryCommit != updated && binaryCommit != previous) {
return &Error{Reason: "bad_state", Message: "旧恢复记录的二进制提交不属于这次更新,已保留现场"}
}
if state.BinarySHA256 != "" {
currentHash, hashErr := fileSHA256(bin)
if hashErr != nil || currentHash != state.BinarySHA256 {
return &Error{Reason: "binary_changed", Message: "当前二进制与旧恢复记录不一致,已保留现场"}
}
}
if binaryCommit == updated {
pending.BinarySHA256 = state.BinarySHA256
} else {
pending.PreviousBinarySHA256 = state.BinarySHA256
}
}
}
if pending.Commit == "" {
@@ -534,6 +553,9 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s
currentHash, currentErr := fileSHA256(bin)
previousHash, previousErr := fileSHA256(bin + ".prev")
if currentErr == nil && previousErr == nil && currentHash == newHash {
if pending.PreviousBinarySHA256 != "" && previousHash != pending.PreviousBinarySHA256 {
return &Error{Reason: "binary_changed", Message: "旧二进制备份与恢复记录不一致,已保留现场,拒绝改写回滚记录"}
}
res.BinaryBuilt = true
res.PrevBinary = bin + ".prev"
res.NeedsRestart = true
@@ -551,6 +573,21 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s
return nil
}
}
// A legacy marker without hashes is ambiguous when a rebuild differs from the
// installed binary. Only a clean VCS stamp for the previous commit can prove
// that another swap will preserve the right rollback binary.
if (pending.Legacy || pending.PreviousCommit != "") && pending.PreviousBinarySHA256 == "" {
revision := binaryRevision(bin)
previous, resolveErr := gitCmd(ctx, root, "rev-parse", pending.PreviousCommit)
if revision == "" || resolveErr != nil || revision != previous {
return &Error{Reason: "binary_state_unknown", Message: "旧恢复记录缺少二进制哈希,无法确认当前二进制属于更新前版本;已保留当前二进制和 .prev,请人工核对后恢复"}
}
hash, err := fileSHA256(bin)
if err != nil {
return &Error{Reason: "binary_state_unknown", Message: "无法校验更新前二进制:" + err.Error()}
}
pending.PreviousBinarySHA256 = hash
}
if pending.PreviousBinarySHA256 != "" && fileExists(bin) {
currentHash, hashErr := fileSHA256(bin)
if hashErr != nil || currentHash != pending.PreviousBinarySHA256 {
+10 -5
View File
@@ -66,6 +66,10 @@ func build(ctx context.Context, root, outPath string) error {
// Linux (ETXTBSY) but renaming it aside is always allowed, so the old process keeps its
// inode and the new path is complete before anyone can observe it.
func installBinary(src, dst string) (string, error) {
// Validate the staged file before moving the live executable aside.
if err := os.Chmod(src, 0o755); err != nil {
return "", err
}
prev := ""
if fileExists(dst) {
prev = dst + ".prev"
@@ -76,9 +80,6 @@ func installBinary(src, dst string) (string, error) {
return "", fmt.Errorf("保留旧二进制失败:%w", err)
}
}
if err := os.Chmod(src, 0o755); err != nil {
return prev, err
}
if err := os.Rename(src, dst); err != nil {
// Put the kept-previous binary back rather than leaving the install with no
// executable because one rename failed.
@@ -127,12 +128,16 @@ func binaryRevision(path string) string {
if err != nil {
return ""
}
var revision string
for _, setting := range info.Settings {
if setting.Key == "vcs.modified" && setting.Value == "true" {
return ""
}
if setting.Key == "vcs.revision" {
return strings.TrimSpace(setting.Value)
revision = strings.TrimSpace(setting.Value)
}
}
return ""
return revision
}
// tail keeps the last bytes of a compiler's output: the end of a build log is where the
+136
View File
@@ -586,3 +586,139 @@ func TestRetryAfterMergeRestoresOnlyUntouchedProtectedContent(t *testing.T) {
})
}
}
// Legacy records cannot tell an installed rebuild from a binary still owed.
// A different build of the same source must never be promoted into .prev.
func TestLegacyPendingWithUnprovableBinaryPreservesRollback(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "original binary")
tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"})
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
st, _ := readState(tr.install)
st.BinaryCommit, st.BinarySHA256, st.PreviousBinarySHA256 = "", "", ""
if err := writeState(tr.install, st); err != nil {
t.Fatal(err)
}
writeFile(t, bin, "different build of the updated commit")
writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n")
head := mustGit(t, tr.install, "rev-parse", "HEAD")
_, err := Apply(context.Background(), buildOpts(tr), nil)
if ue, ok := err.(*Error); !ok || ue.Reason != "binary_state_unknown" {
t.Fatalf("want binary_state_unknown, got %v", err)
}
if got := readFile(t, bin+".prev"); got != "original binary" {
t.Fatalf("rollback binary overwritten: %q", got)
}
if got := readFile(t, bin); got != "different build of the updated commit" {
t.Fatalf("live binary overwritten: %q", got)
}
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head {
t.Fatal("refusal moved HEAD")
}
if !buildPending(tr.install) {
t.Fatal("refusal cleared recovery marker")
}
}
func TestLegacyMarkerUsesRecordedHashForADifferentInstalledBuild(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "original binary")
tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"})
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
writeFile(t, bin, "another valid build recorded by the updater")
st, _ := readState(tr.install)
var err error
st.BinarySHA256, err = fileSHA256(bin)
if err != nil {
t.Fatal(err)
}
if err := writeState(tr.install, st); err != nil {
t.Fatal(err)
}
writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n")
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
if got := readFile(t, bin+".prev"); got != "original binary" {
t.Fatalf("rollback binary overwritten: %q", got)
}
if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil {
t.Fatal(err)
}
if got := readFile(t, bin); got != "original binary" {
t.Fatalf("rollback binary = %q", got)
}
}
func TestInstallBinaryMissingStagingKeepsLiveAndPrevious(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, Binary)
writeFile(t, bin, "live binary")
writeFile(t, bin+".prev", "rollback binary")
if _, err := installBinary(filepath.Join(dir, "missing-staging"), bin); err == nil {
t.Fatal("missing staging must fail")
}
if got := readFile(t, bin); got != "live binary" {
t.Fatalf("live binary lost: %q", got)
}
if got := readFile(t, bin+".prev"); got != "rollback binary" {
t.Fatalf("previous binary lost: %q", got)
}
}
func TestLegacyMarkerRefusesContradictoryState(t *testing.T) {
for _, tc := range []struct {
name, reason string
badCommit bool
}{
{name: "unrelated binary commit", reason: "bad_state", badCommit: true},
{name: "changed binary hash", reason: "binary_changed"},
} {
t.Run(tc.name, func(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "original binary")
tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"})
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
st, _ := readState(tr.install)
if tc.badCommit {
st.BinaryCommit = "not-a-commit"
} else {
st.BinarySHA256 = strings.Repeat("0", 64)
}
if err := writeState(tr.install, st); err != nil {
t.Fatal(err)
}
writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n")
live := readFile(t, bin)
stateBytes := readFile(t, statePath(tr.install))
_, err := Apply(context.Background(), buildOpts(tr), nil)
if ue, ok := err.(*Error); !ok || ue.Reason != tc.reason {
t.Fatalf("want %s, got %v", tc.reason, err)
}
if got := readFile(t, bin); got != live {
t.Fatal("refusal changed live binary")
}
if got := readFile(t, bin+".prev"); got != "original binary" {
t.Fatal("refusal changed rollback binary")
}
if got := readFile(t, statePath(tr.install)); got != stateBytes {
t.Fatal("refusal rewrote state")
}
})
}
}
+9 -8
View File
@@ -545,16 +545,17 @@ for i, line in enumerate(lines):
if not refused:
if not replaced:
insert_at = 0
if lines:
first = lines[0].rstrip("\r\n")
if first.startswith("\ufeff"):
first = first[1:]
for i, line in enumerate(lines):
first = line.rstrip("\r\n").lstrip("\ufeff")
rest = first[3:] if first.startswith("---") else None
if rest is not None and (not rest.strip(" \t") or rest[:1] in (" ", "\t") and rest.lstrip(" \t").startswith("#")):
insert_at = 1
if not lines[0].endswith(("\n", "\r")):
lines[0] += "\n"
out[0] = lines[0]
insert_at = i + 1
if not line.endswith(("\n", "\r")):
out[i] += "\n"
break
trimmed = first.strip()
if trimmed and not trimmed.startswith("#") and not first.startswith("%"):
break
out.insert(insert_at, 'version: "%s"\n' % tag)
original_mode = stat.S_IMODE(os.stat(path).st_mode)
+2 -2
View File
@@ -158,7 +158,7 @@ test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
assert.notEqual(conversationsIndex, -1);
assert.ok(projectRetryIndex > conversationsIndex);
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
assert.match(html, /\/static\/js\/auth\.js\?v=20260907-blocked-1/);
assert.match(html, /\/static\/js\/auth\.js\?v=[A-Za-z0-9._-]+/);
});
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
@@ -533,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260907-blocked-1/);
assert.match(html, /style\.css\?v=[A-Za-z0-9._-]+/);
});
+4 -2
View File
@@ -41,7 +41,9 @@ test('刷新恢复会话时先完成权威审批配置同步再允许发送', ()
assert.match(chat, /await waitForHitlConfigReady\(hitlConversationAtSendStart\)/);
assert.match(chat, /hitlConfigSyncConversationId = conversationId;[\s\S]{0,240}await hitlConfigSyncPromise;/);
assert.match(chat, /await hitlConfigSyncPromise;[\s\S]{0,220}seq !== loadConversationRequestSeq/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /window\.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer\(\)/);
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(hitlPage, /window\.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer\(\)/);
assert.match(hitlPage, /window\.csaiHitlDefaultReviewerReady = window\.csaiHitlDefaultConfigReady/);
});
test('同一会话的审批配置写入串行化以防止旧请求后到覆盖新选择', () => {
@@ -294,7 +296,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260907-blocked-1/);
assert.match(template, /style\.css\?v=20260907-blocked-1/);
assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
@@ -25,8 +25,12 @@ test('已有会话缺少本地配置时不会继承其他会话的最近审批
test('服务端默认审批人只更新默认值,不覆盖最近会话选择', () => {
const source = functionSource(hitl, 'applyHitlDefaultReviewerFromServer', 'fetchHitlDefaultReviewer');
assert.match(source, /window\.csaiHitlDefaultReviewer = v/);
assert.match(source, /return applyHitlDefaultConfigFromServer\(\{ defaultReviewer: reviewer \}\)/);
const configSource = functionSource(hitl, 'applyHitlDefaultConfigFromServer', 'fetchHitlDefaultConfig');
assert.match(configSource, /const reviewer = hitlReviewerNormalize\(src\.defaultReviewer \|\| src\.reviewer \|\| 'human'\)/);
assert.match(configSource, /window\.csaiHitlDefaultReviewer = reviewer/);
assert.doesNotMatch(source, /saveHitlLastGlobalConfig/);
assert.doesNotMatch(configSource, /saveHitlLastGlobalConfig|saveHitlConversationConfig/);
});
test('恢复会话审批配置时保留该会话自己的审批人', () => {