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

@@ -88,6 +88,10 @@ case-variant, or duplicate fields; multiple JSON values; extra entries;
symlinks; and future or otherwise unsupported versions. Treat a bundle that
fails recognition as an ordinary directory, not as a compatible bundle.
When replacing a recognized bundle, cancellation observed before the new
bundle is installed preserves the prior bundle rather than committing the
replacement.
The manifest contains safe operational provenance, but `data-package.yml` and
the generated Markdown can contain sensitive weather or location context. Do
not assume these artifacts are safe for public distribution. Handle retention,

View File

@@ -18,8 +18,9 @@ symlinks and non-directories, accepts a missing or empty directory, and permits
replacement only for a recognized current bundle. Publication rechecks that
authorization immediately before it writes a private sibling staging directory.
For replacement, it moves the prior bundle to a private sibling backup,
reauthorizes that moved entry, and restores it if installing the new bundle
fails.
reauthorizes that moved entry, checks for cancellation, and restores it if
cancellation or installing the new bundle prevents replacement. If guarded
restoration fails, the error retains the prior bundle's recovery path.
Planning also validates the final component and the bounded fixed names used
for private staging and backup siblings. A destination that cannot form those

View File

@@ -123,9 +123,12 @@ rechecked immediately before an atomic publish. A missing or empty directory
is usable. A nonempty directory can be replaced only when `--replace` is given
and it is recognized as a current Weatherreporter comparison bundle; ordinary
directories, symlinks, and unsafe destinations are rejected. Cancellation and
all failures before publication preserve an existing bundle. Profile failures
are different: the command publishes a complete partial bundle, with failed
profiles represented in the manifest and no Markdown file for those profiles.
all failures before publication preserve an existing bundle, including a
cancellation observed while a replacement is being prepared. If guarded
restoration cannot complete, the error names the retained sibling bundle for
manual recovery. Profile failures are different: the command publishes a
complete partial bundle, with failed profiles represented in the manifest and
no Markdown file for those profiles.
Comparison preflight also checks that private publication siblings can be
formed. An infeasible destination name is rejected before a missing parent
directory is created.

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)