Files
Elydora-Go-SDK/cmd/elydora/plugins/transaction_subprocess_test.go
T
雷电芽衣 c1caed3d42 chore(go): audit transaction engine
Share change filtering between the durable and test transaction paths,
collapse repeated precondition handling, unify journal sequence parsing,
and split the subprocess worker test file.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:20:38 -04:00

359 lines
11 KiB
Go

package plugins
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestDurableTransactionRecoversAfterProcessTermination(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
target := filepath.Join(t.TempDir(), "managed.json")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
command := durableWorkerCommand(t, home, "crash-update", target)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 86 {
t.Fatalf("crash worker = %v, output=%s", err, output)
}
requireTransactionContent(t, target, "next")
if err := writeChanges(nil, "restart recovery", nil); err != nil {
t.Fatalf("recover interrupted transaction: %v", err)
}
requireTransactionContent(t, target, "original")
requireNoDurableTransactionState(t, home, filepath.Dir(target))
}
func TestDurableTransactionResumesWindowsRollbackHardlink(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows hardlink rollback state")
}
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
crash := durableWorkerCommand(t, home, "crash-update", target)
output, err := crash.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 86 {
t.Fatalf("commit crash worker = %v, output=%s", err, output)
}
hardlinkCrash := durableWorkerCommand(t, home, "crash-rollback-hardlink", target)
output, err = hardlinkCrash.CombinedOutput()
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 91 {
t.Fatalf("rollback hardlink worker = %v, output=%s", err, output)
}
if err := writeChanges(nil, "hardlink restart recovery", nil); err != nil {
t.Fatalf("resume hardlink rollback: %v", err)
}
requireTransactionContent(t, target, "original")
requireNoDurableTransactionState(t, home, directory)
}
func TestDurableTransactionRecoversCompletedAndActiveEntries(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
firstTarget := filepath.Join(directory, "first.json")
secondTarget := filepath.Join(directory, "second.json")
for path, value := range map[string]string{
firstTarget: "first-original", secondTarget: "second-original",
} {
if err := os.WriteFile(path, []byte(value), 0600); err != nil {
t.Fatal(err)
}
}
command := durableWorkerCommand(t, home, "crash-multi-update", firstTarget)
command.Env = append(command.Env, "ELYDORA_DURABLE_SECOND_TARGET="+secondTarget)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 87 {
t.Fatalf("multi crash worker = %v, output=%s", err, output)
}
requireTransactionContent(t, firstTarget, "first-next")
requireTransactionContent(t, secondTarget, "second-next")
if err := writeChanges(nil, "multi-entry restart recovery", nil); err != nil {
t.Fatalf("recover interrupted multi-entry transaction: %v", err)
}
requireTransactionContent(t, firstTarget, "first-original")
requireTransactionContent(t, secondTarget, "second-original")
requireNoDurableTransactionState(t, home, directory)
}
func TestDurableTransactionRecoversCreateAndDeleteBoundaries(t *testing.T) {
for _, testCase := range []struct {
name, mode string
exitCode int
exists bool
}{
{"create", "crash-create", 88, false},
{"delete", "crash-delete", 89, true},
} {
t.Run(testCase.name, func(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
if testCase.exists {
if err := os.WriteFile(target, []byte("delete-original"), 0600); err != nil {
t.Fatal(err)
}
}
command := durableWorkerCommand(t, home, testCase.mode, target)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != testCase.exitCode {
t.Fatalf("%s crash worker = %v, output=%s", testCase.name, err, output)
}
if err := writeChanges(nil, testCase.name+" restart recovery", nil); err != nil {
t.Fatalf("recover interrupted %s: %v", testCase.name, err)
}
if testCase.exists {
requireTransactionContent(t, target, "delete-original")
} else if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("created target remains after recovery: %v", err)
}
requireNoDurableTransactionState(t, home, directory)
})
}
}
func TestDurableTransactionFinishesCommittedCleanupAfterProcessExit(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
command := durableWorkerCommand(t, home, "crash-committed-cleanup", target)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 90 {
t.Fatalf("committed cleanup worker = %v, output=%s", err, output)
}
requireTransactionContent(t, target, "committed")
if err := writeChanges(nil, "committed cleanup restart recovery", nil); err != nil {
t.Fatalf("finish committed cleanup: %v", err)
}
requireTransactionContent(t, target, "committed")
requireNoDurableTransactionState(t, home, directory)
}
func TestDurableTransactionNeverExposesMissingUpdateToSubprocessReader(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
stop := filepath.Join(directory, "stop")
if err := os.WriteFile(target, []byte("value-a"), 0600); err != nil {
t.Fatal(err)
}
reader := durableWorkerCommand(t, home, "reader", target)
var readerOutput bytes.Buffer
reader.Stdout = &readerOutput
reader.Stderr = &readerOutput
reader.Env = append(reader.Env, "ELYDORA_DURABLE_STOP="+stop)
if err := reader.Start(); err != nil {
t.Fatal(err)
}
for index := 0; index < 12; index++ {
value := "value-a"
if index%2 == 0 {
value = "value-b"
}
change, err := prepareFileChange(target, "reader target", []byte(value), 0600)
if err != nil {
t.Fatal(err)
}
if err := writeChanges([]*fileChange{change}, "reader visibility transaction", nil); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(stop, []byte("stop"), 0600); err != nil {
t.Fatal(err)
}
if err := reader.Wait(); err != nil {
t.Fatalf("reader process observed invalid state: %v: %s", err, readerOutput.String())
}
}
func TestDurableTransactionCapabilityGateLeavesTargetAndStateClean(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
change, err := prepareFileChange(target, "capability target", []byte("next"), 0600)
if err != nil {
t.Fatal(err)
}
err = writeChanges([]*fileChange{change}, "capability transaction", nil)
if err == nil {
requireTransactionContent(t, target, "next")
} else {
if !strings.Contains(err.Error(), "lacks required durable transaction primitives") {
t.Fatalf("capability gate error = %v", err)
}
requireTransactionContent(t, target, "original")
}
requireNoDurableTransactionState(t, home, directory)
}
func TestDurableTransactionSerializesSubprocessWriters(t *testing.T) {
home := filepath.Join(t.TempDir(), "home")
if err := createPrivateTransactionDirectory(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
directory := t.TempDir()
target := filepath.Join(directory, "managed.json")
start := filepath.Join(directory, "start")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
type worker struct {
command *exec.Cmd
ready string
}
workers := make([]worker, 0, 2)
for index, value := range []string{"writer-one", "writer-two"} {
ready := filepath.Join(directory, fmt.Sprintf("ready-%d", index))
command := durableWorkerCommand(t, home, "writer", target)
command.Env = append(
command.Env,
"ELYDORA_DURABLE_VALUE="+value,
"ELYDORA_DURABLE_READY="+ready,
"ELYDORA_DURABLE_START="+start,
)
if err := command.Start(); err != nil {
t.Fatal(err)
}
workers = append(workers, worker{command: command, ready: ready})
}
for _, item := range workers {
waitForDurableTestPath(t, item.ready)
}
if err := os.WriteFile(start, []byte("start"), 0600); err != nil {
t.Fatal(err)
}
exitCodes := make([]int, 0, 2)
for _, item := range workers {
err := item.command.Wait()
if err == nil {
exitCodes = append(exitCodes, 0)
continue
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatal(err)
}
exitCodes = append(exitCodes, exitErr.ExitCode())
}
if !(exitCodes[0] == 0 && exitCodes[1] == 23 || exitCodes[0] == 23 && exitCodes[1] == 0) {
t.Fatalf("writer exit codes = %v", exitCodes)
}
content, err := os.ReadFile(target)
if err != nil || !strings.HasPrefix(string(content), "writer-") {
t.Fatalf("serialized target = %q, %v", content, err)
}
}
func durableWorkerCommand(t *testing.T, home, mode, target string) *exec.Cmd {
t.Helper()
command := exec.Command(os.Args[0], "-test.run=^TestDurableTransactionSubprocessWorker$")
command.Env = append(
os.Environ(),
durableWorkerEnvironment+"="+mode,
"ELYDORA_DURABLE_TARGET="+target,
"HOME="+home,
"USERPROFILE="+home,
)
return command
}
func waitForDurableTestPath(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for {
if _, err := os.Lstat(path); err == nil {
return
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", path)
}
time.Sleep(time.Millisecond)
}
}
func requireNoDurableTransactionState(t *testing.T, home string, targetDirectories ...string) {
t.Helper()
stateRoot := filepath.Join(home, ".elydora", "transactions")
entries, err := os.ReadDir(stateRoot)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.Name() != "lock" {
t.Fatalf("durable journal remains: %s", filepath.Join(stateRoot, entry.Name()))
}
}
for _, directory := range targetDirectories {
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".elydora-txn-") {
t.Fatalf("durable workspace remains: %s", filepath.Join(directory, entry.Name()))
}
}
}
}