Smail/backend/config/transaction_crash_test.go
smail b8dbd3bb2b
Some checks are pending
Security Checks / security (push) Waiting to run
补齐配置切换真实进程终止与损坏拒绝启动测试
2026-09-21 23:50:27 +08:00

309 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"time"
)
const transactionCrashRoot = "SMAIL_TEST_TRANSACTION_CRASH_ROOT"
const transactionCrashPoint = "SMAIL_TEST_TRANSACTION_CRASH_POINT"
func crashOptions() ConfigLoaderOptions {
return ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
}
func crashCandidate(current RuntimeConfig) RuntimeConfig {
candidate := cloneRuntimeConfig(current)
setCandidateGeneration(&candidate, current.Generation+1)
candidate.Email.Policy.MaxMessageBytes = 9 << 20
if candidate.Root.Database.Type == DatabaseTypeSQLite {
candidate.Root.Database.LocalRef = "next.db"
} else {
candidate.Root.Database.Name = "smail_next"
}
return candidate
}
func crashTransactionDirectory(t *testing.T, root string) string {
t.Helper()
base := filepath.Join(root, "data", "transaction")
entries, err := os.ReadDir(base)
if err != nil || len(entries) != 1 {
t.Fatalf("事务目录数量异常:%d,%v", len(entries), err)
}
return filepath.Join(base, entries[0].Name())
}
// TestTransactionCrashChild 只在测试二进制中运行,不向正式程序添加故障注入入口。
func TestTransactionCrashChild(t *testing.T) {
root, point := os.Getenv(transactionCrashRoot), os.Getenv(transactionCrashPoint)
if root == "" {
return
}
current, err := NewConfigLoader(root, crashOptions()).Load()
if err != nil {
t.Fatal(err)
}
phase := TransactionPhase(point)
if point == "partial_sections" {
phase = TransactionPrepared
}
if point == "root_before_manifest" {
phase = TransactionSectionsCommitted
}
store := NewTransactionStore(root, crashOptions().Validation).WithHooks(TransactionHooks{
BeforePhase: func(reached TransactionPhase) error {
if reached != phase {
return nil
}
// 复用正式原子替换函数构造阶段内部的落盘状态;不声称命中了实际指令间隙。
if point == "partial_sections" || point == "root_before_manifest" {
name := "config.json"
if point == "partial_sections" {
name, _ = FixedSectionFile(requiredPartitionSections[0])
}
source := filepath.Join(crashTransactionDirectory(t, root), "candidate", name)
if err := replaceLiveConfigFile(filepath.Join(root, "config"), source, name); err != nil {
return err
}
}
if err := os.Mkdir(filepath.Join(root, "crash-ready"), 0700); err != nil {
return err
}
// 必须等待父进程强制终止,不能返回错误而执行提交函数的 defer。
for {
time.Sleep(time.Hour)
}
},
})
if err := store.Commit(current, crashCandidate(current)); err != nil {
t.Fatal(err)
}
t.Fatal("子进程没有停在要求的提交阶段")
}
func killTransactionChild(t *testing.T, root, point string) {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
command := exec.CommandContext(ctx, executable, "-test.run=^TestTransactionCrashChild$", "-test.timeout=45s")
command.Env = append(os.Environ(), transactionCrashRoot+"="+root, transactionCrashPoint+"="+point)
var output bytes.Buffer
command.Stdout, command.Stderr = &output, &output
if err := command.Start(); err != nil {
t.Fatal(err)
}
wait := make(chan error, 1)
go func() { wait <- command.Wait() }()
finished := false
defer func() {
if !finished {
_ = command.Process.Kill()
<-wait
}
}()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case err := <-wait:
finished = true
t.Fatalf("到达故障点前子进程退出:%v\n%s", err, output.String())
case <-ctx.Done():
t.Fatal("等待事务故障点超时")
case <-ticker.C:
ready, err := os.Stat(filepath.Join(root, "crash-ready"))
if os.IsNotExist(err) {
continue
}
if err != nil || !ready.IsDir() {
t.Fatalf("故障点确认失败:%v", err)
}
if err := command.Process.Kill(); err != nil {
t.Fatal(err)
}
err = <-wait
finished = true
var exitError *exec.ExitError
if !errors.As(err, &exitError) || command.ProcessState.Success() {
t.Fatalf("子进程不是强制终止:%v", err)
}
t.Logf("已确认阶段 %s,子进程 %d,退出状态 %s", point, command.Process.Pid, command.ProcessState)
return
}
}
}
func crashFixture(t *testing.T, kind DatabaseType) (string, RuntimeConfig) {
t.Helper()
root := t.TempDir()
if err := InitializeDeployment(root); err != nil {
t.Fatal(err)
}
current := validRuntimeConfig()
if kind != DatabaseTypeSQLite {
current = validMySQLRuntimeConfig(t, root)
current.Root.Database.Type = kind
// 本专项只验证配置落盘恢复,不访问共享数据库或创建替代 SQLite。
current.Root.Database.Port = 1
}
writeRuntimeFixture(t, root, current)
loaded, err := NewConfigLoader(root, crashOptions()).Load()
if err != nil {
t.Fatal(err)
}
current = loaded
return root, current
}
func readCrashConfigFiles(t *testing.T, directory string) map[string][]byte {
t.Helper()
files := make(map[string][]byte)
for _, name := range transactionFileNames() {
data, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
t.Fatal(err)
}
files[name] = data
}
return files
}
func assertCrashConfigFiles(t *testing.T, root string, expected map[string][]byte) {
t.Helper()
for name, want := range expected {
got, err := os.ReadFile(filepath.Join(root, "config", name))
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("恢复后的配置文件不匹配:%s,%v", name, err)
}
}
}
func TestTransactionProcessCrashRecovery(t *testing.T) {
cases := []struct {
point string
phase TransactionPhase
new bool
}{
{"prepared", TransactionPrepared, false},
{"partial_sections", TransactionPrepared, false},
{"sections_committed", TransactionSectionsCommitted, false},
{"root_before_manifest", TransactionSectionsCommitted, true},
{"root_committed", TransactionRootCommitted, true},
{"verified", TransactionVerified, true},
}
for _, kind := range []DatabaseType{DatabaseTypeSQLite, DatabaseTypeMySQL, DatabaseTypePostgreSQL} {
t.Run(string(kind), func(t *testing.T) {
for _, test := range cases {
t.Run(test.point, func(t *testing.T) {
root, current := crashFixture(t, kind)
killTransactionChild(t, root, test.point)
manifest, err := readTransactionManifest(crashTransactionDirectory(t, root))
if err != nil || manifest.Phase != test.phase {
t.Fatalf("强制退出后事务清单状态异常:%v", err)
}
before, loadErr := NewConfigLoader(root, crashOptions()).Load()
partial := test.point == "partial_sections" || test.point == "sections_committed"
if partial && loadErr == nil {
t.Fatal("尚未恢复的不完整配置被错误接受")
}
want := current
if test.new {
want = crashCandidate(current)
}
part := "original"
if test.new {
part = "candidate"
}
files := readCrashConfigFiles(t, filepath.Join(crashTransactionDirectory(t, root), part))
if !partial && (loadErr != nil || before.Generation != want.Generation) {
t.Fatalf("恢复前完整配置代次异常:%v", loadErr)
}
// 两次走正式启动恢复路径,同时验证锁释放和恢复幂等。
for attempt := 0; attempt < 2; attempt++ {
manager, err := NewConfigManager(root, crashOptions())
if err != nil {
t.Fatalf("第 %d 次启动恢复失败:%v", attempt+1, err)
}
got := manager.Snapshot()
if got.Generation != want.Generation || !reflect.DeepEqual(got.Root.Database, want.Root.Database) {
t.Fatal("启动后选中的数据库或配置代次不匹配")
}
assertCrashConfigFiles(t, root, files)
}
entries, err := os.ReadDir(filepath.Join(root, "data", "transaction"))
if err != nil || len(entries) != 0 {
t.Fatalf("恢复后仍有活动事务:%v", err)
}
entries, err = os.ReadDir(filepath.Join(root, "data"))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.Name() != ".config-state.lock" && entry.Name() != "transaction" {
t.Fatalf("纯配置恢复意外创建数据文件:%s", entry.Name())
}
}
})
}
})
}
}
func TestTransactionProcessCrashRejectsTamperedRecovery(t *testing.T) {
for _, part := range []string{"original", "candidate"} {
t.Run(part, func(t *testing.T) {
root, _ := crashFixture(t, DatabaseTypeSQLite)
files := readCrashConfigFiles(t, filepath.Join(root, "config"))
killTransactionChild(t, root, "prepared")
directory := crashTransactionDirectory(t, root)
path := filepath.Join(directory, part, "config.json")
if err := os.WriteFile(path, []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
if _, err := NewConfigManager(root, crashOptions()); !errors.Is(err, ErrConfigDigestMismatch) {
t.Fatalf("损坏事务没有失败关闭:%v", err)
}
assertCrashConfigFiles(t, root, files)
if _, err := os.Stat(path); err != nil {
t.Fatal("失败关闭不应删除损坏证据")
}
}
})
}
}
// TestTransactionUnpublishedDirectoryFailsClosed 复现首份清单发布前的磁盘状态。
// 无法证明来源的残留必须保留并阻止启动,不能把安全拒绝报告成自动恢复成功。
func TestTransactionUnpublishedDirectoryFailsClosed(t *testing.T) {
root, _ := crashFixture(t, DatabaseTypeSQLite)
files := readCrashConfigFiles(t, filepath.Join(root, "config"))
directory := filepath.Join(root, "data", "transaction", "0123456789abcdef0123456789abcdef")
for _, part := range []string{"original", "candidate"} {
if err := os.MkdirAll(filepath.Join(directory, part), 0700); err != nil {
t.Fatal(err)
}
}
for attempt := 0; attempt < 2; attempt++ {
if _, err := NewConfigManager(root, crashOptions()); !errors.Is(err, ErrTransactionInvalid) {
t.Fatalf("无清单残留没有阻止启动:%v", err)
}
assertCrashConfigFiles(t, root, files)
if _, err := os.Stat(directory); err != nil {
t.Fatal("无清单残留被错误删除")
}
}
}