Honor cancellation during comparison replacement

This commit is contained in:
2026-08-13 03:13:17 +00:00
parent 0314a302f1
commit 302f5aba2d
5 changed files with 164 additions and 5 deletions

View File

@@ -550,6 +550,9 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
if err := authorizeMovedDestination(currentPlan, backupDirectory); err != nil {
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
}
if err := ctx.Err(); err != nil {
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
}
if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil {
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, fmt.Errorf("replace comparison destination %q: %w", currentPlan.Target, err))
}

View File

@@ -685,6 +685,154 @@ func TestPublishCancellationBeforeCommitLeavesNoDestination(t *testing.T) {
}
}
func TestPublishRestoresPriorDestinationWhenCanceledAfterBackup(t *testing.T) {
for _, test := range []struct {
name string
prepare func(t *testing.T, workingDirectory, target string)
}{
{
name: "empty directory",
prepare: func(t *testing.T, _, target string) {
t.Helper()
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
},
},
{
name: "recognized bundle",
prepare: func(t *testing.T, workingDirectory, target string) {
t.Helper()
plan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), plan, testBundle()); err != nil {
t.Fatal(err)
}
},
},
} {
t.Run(test.name, func(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
test.prepare(t, workingDirectory, target)
before := directorySnapshot(t, target)
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
next := testBundle()
next.Reports[0].Markdown = []byte("# Replacement\n")
publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error {
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
if oldPath == target {
cancel()
}
return nil
}})
if publication.Committed || !errors.Is(err, context.Canceled) {
t.Fatalf("publish() result/error = %#v/%v, want canceled uncommitted publication", publication, err)
}
if after := directorySnapshot(t, target); !equalSnapshots(before, after) {
t.Fatalf("restored destination = %#v, want %#v", after, before)
}
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target))
})
}
}
func TestPublishRetainsBackupWhenCancellationRestorationFails(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
restoreCause := errors.New("restore failed")
var backupPath string
calls := 0
publication, err := publish(ctx, plan, testBundle(), publishOperations{rename: func(oldPath, newPath string) error {
calls++
if calls == 2 {
return restoreCause
}
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
if calls == 1 {
backupPath = newPath
cancel()
}
return nil
}})
if publication.Committed || !errors.Is(err, context.Canceled) || !errors.Is(err, restoreCause) {
t.Fatalf("publish() result/error = %#v/%v, want cancellation and restoration failure", publication, err)
}
if info, statErr := os.Stat(backupPath); statErr != nil || !info.IsDir() {
t.Fatalf("backup stat = %v, %v, want retained directory", info, statErr)
}
if _, statErr := os.Lstat(target); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("destination stat error = %v, want not exist", statErr)
}
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(backupPath))
}
func TestPublishReturnsCommittedBundleWhenCanceledAfterInstall(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
next := testBundle()
next.Reports[0].Markdown = []byte("# Next\n")
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error {
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
if newPath == target && oldPath != target {
cancel()
}
return nil
}})
if !publication.Committed || err != nil {
t.Fatalf("publish() result/error = %#v/%v, want committed bundle", publication, err)
}
if err := ctx.Err(); !errors.Is(err, context.Canceled) {
t.Fatalf("context error = %v, want cancellation", err)
}
if got := string(readFile(t, filepath.Join(target, next.Reports[0].Path))); got != string(next.Reports[0].Markdown) {
t.Fatalf("published report = %q, want %q", got, next.Reports[0].Markdown)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("committed bundle recognition error = %v", err)
}
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target))
}
func assertDestinationErrorKind(t *testing.T, workingDirectory, target string, replace bool, want DestinationErrorKind) {
t.Helper()
_, err := PlanDestination(workingDirectory, target, replace)