Honor cancellation throughout publication recovery Git operations
This commit is contained in:
147
internal/publish/cancellation_test.go
Normal file
147
internal/publish/cancellation_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRecoveryAlreadyCanceledDiscovery(t *testing.T) {
|
||||
for _, override := range []string{"", "relative"} {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
result, err := ListAttempts(ctx, t.TempDir(), override)
|
||||
requireCanceledRecovery(t, err)
|
||||
if len(result.Attempts) != 0 {
|
||||
t.Fatalf("unexpected attempts: %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireCanceledRecovery(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var recovery *RecoveryError
|
||||
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation = %v; want canceled kind and cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCancelsActiveGitOperations(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fixture uses a POSIX Git launcher")
|
||||
}
|
||||
tests := []struct {
|
||||
name, command string
|
||||
run func(context.Context, string) error
|
||||
}{
|
||||
{"default discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }},
|
||||
{"relative discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, "records"); return err }},
|
||||
{"default discovery metadata", "--git-common-dir", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }},
|
||||
{"record metadata", "--git-common-dir", func(ctx context.Context, repo string) error {
|
||||
_, err := SaveEvidence(ctx, repo, "", Evidence{})
|
||||
return err
|
||||
}},
|
||||
{"record ignore guard", "check-ignore", func(ctx context.Context, repo string) error {
|
||||
_, err := SaveEvidence(ctx, repo, "records", Evidence{})
|
||||
return err
|
||||
}},
|
||||
{"publication metadata", "--git-common-dir", func(ctx context.Context, repo string) error {
|
||||
result, err := Publish(ctx, Request{RepositoryDir: repo, Product: "Sample", Version: "v1.2.3"})
|
||||
if !errors.Is(result.RecordError, context.Canceled) {
|
||||
return fmt.Errorf("record cancellation lost: %v", result.RecordError)
|
||||
}
|
||||
return err
|
||||
}},
|
||||
{"tag validation", "check-ref-format", func(ctx context.Context, repo string) error {
|
||||
_, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"})
|
||||
return err
|
||||
}},
|
||||
{"tag query", "ls-remote", func(ctx context.Context, repo string) error {
|
||||
_, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"})
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := recordRepo(t)
|
||||
if test.name == "publication metadata" {
|
||||
if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module example.com/sample\n\ngo 1.26.0\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(repo, "docs", "releases"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, "docs", "releases", "v1.2.3.md"), []byte("# Sample v1.2.3\n\nChanges.\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordGit(t, repo, "checkout", "-B", "main")
|
||||
recordGit(t, repo, "add", ".")
|
||||
recordGit(t, repo, "commit", "-qm", "Release candidate")
|
||||
}
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
ready := filepath.Join(t.TempDir(), "ready")
|
||||
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
|
||||
script := "#!/bin/sh\nfor arg in \"$@\"; do\n if [ \"$arg\" = " + quote(test.command) + " ]; then\n echo $$ > " + quote(ready) + "\n exec sleep 600\n fi\ndone\nexec " + quote(realGit) + " \"$@\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- test.run(ctx, repo) }()
|
||||
// Synchronize cancellation with the intercepted operation, rather than timing its start.
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
timeout := time.NewTimer(10 * time.Second)
|
||||
defer timeout.Stop()
|
||||
var pid int
|
||||
waiting:
|
||||
for {
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("operation finished before interception: %v", err)
|
||||
case <-timeout.C:
|
||||
t.Fatal("Git operation was never intercepted")
|
||||
case <-ticker.C:
|
||||
data, err := os.ReadFile(ready)
|
||||
if err == nil {
|
||||
pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err == nil {
|
||||
break waiting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("lost context cause: %v", err)
|
||||
}
|
||||
if strings.Contains(test.name, "discovery") || test.command == "check-ref-format" || test.command == "ls-remote" {
|
||||
requireCanceledRecovery(t, err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
process, _ := os.FindProcess(pid)
|
||||
if process != nil {
|
||||
_ = process.Kill()
|
||||
}
|
||||
<-done
|
||||
t.Fatal("cancellation did not stop the Git operation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user