mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
302 lines
10 KiB
Go
302 lines
10 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLinearTargetCapacityEstimateBoundaries(t *testing.T) {
|
|
// Completed observations keep admission and failure semantics independent of polling latency.
|
|
observationErr := errors.New("capacity observation failed")
|
|
for _, tt := range []struct {
|
|
name string
|
|
content string
|
|
available int64
|
|
queryErr error
|
|
wantCalls int
|
|
}{
|
|
{name: "equal capacity", content: "data", available: 4, wantCalls: 1},
|
|
{name: "empty file", queryErr: observationErr},
|
|
{name: "observation failure", content: "data", queryErr: observationErr, wantCalls: 1},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Observe capacity before creating even the target directory, without allocating large fixtures.
|
|
root := t.TempDir()
|
|
source := writeSourceFile(t, root, "source", []byte(tt.content))
|
|
parent := filepath.Join(root, "destination")
|
|
target := filepath.Join(parent, "target")
|
|
item := newFixtureItem(source, target)
|
|
copyer, err := NewStream(context.Background(), newStreamFixture(item).onResults,
|
|
SetToDevice(LinearDevice(true)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
copyer.getDevice = func(string) (string, error) { return root, nil }
|
|
calls := 0
|
|
copyer.availableSpace = func(device string) (int64, error) {
|
|
calls++
|
|
if device != root {
|
|
t.Errorf("capacity device = %q, want %q", device, root)
|
|
}
|
|
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("target directory exists before capacity observation: %v", err)
|
|
}
|
|
return tt.available, tt.queryErr
|
|
}
|
|
|
|
// Seed a finished observation to exercise cached admission deterministically.
|
|
if tt.content != "" {
|
|
sample := new(spaceEstimate)
|
|
_ = sample.refresh(func() (int64, error) { return copyer.availableSpace(root) })
|
|
copyer.linearSpace = map[string]*spaceEstimate{root: sample}
|
|
}
|
|
|
|
// Use real source metadata so permitted writes complete through the ordinary target lifecycle.
|
|
if err := copyer.Submit(item); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if err := copyer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := copyer.Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Equal capacity and empty files complete; observation errors retain their original identity.
|
|
result, err := item.terminal(t)
|
|
if err != nil || len(result.Targets) != 1 || result.Targets[0].Path != target {
|
|
t.Fatalf("unexpected result: %+v / %v", result, err)
|
|
}
|
|
if calls != tt.wantCalls || copyer.linearTargetStopped() {
|
|
t.Fatalf("capacity calls = %d, want %d; stopped = %t", calls, tt.wantCalls, copyer.linearTargetStopped())
|
|
}
|
|
if tt.queryErr != nil && tt.wantCalls > 0 {
|
|
if !errors.Is(result.Targets[0].Err, observationErr) || errors.Is(result.Targets[0].Err, ErrTargetNoSpace) {
|
|
t.Fatalf("observation error = %v", result.Targets[0].Err)
|
|
}
|
|
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("observation failure created output directory: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if result.Targets[0].Err != nil {
|
|
t.Fatal(result.Targets[0].Err)
|
|
}
|
|
if content, err := os.ReadFile(target); err != nil || string(content) != tt.content {
|
|
t.Fatalf("target content = %q / %v, want %q", content, err, tt.content)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyRejectsSourceAliasesBeforeModification(t *testing.T) {
|
|
for _, mode := range []ReadMode{ReadBuffered, ReadMapped} {
|
|
for _, alias := range []string{"same path", "hardlink", "symlink"} {
|
|
t.Run(fmt.Sprintf("%v/%s", mode, alias), func(t *testing.T) {
|
|
// One alias is unsafe, but another target must still receive the complete source.
|
|
root := t.TempDir()
|
|
content := bytes.Repeat([]byte("unaltered source"), 1000)
|
|
source := writeSourceFile(t, root, "source", content)
|
|
fixed := time.Unix(1700000000, 0)
|
|
if err := os.Chtimes(source, fixed, fixed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := source
|
|
if alias != "same path" {
|
|
target = filepath.Join(root, "alias")
|
|
link := os.Link
|
|
if alias == "symlink" {
|
|
link = os.Symlink
|
|
}
|
|
if err := link(source, target); err != nil {
|
|
t.Skipf("alias unsupported: %v", err)
|
|
}
|
|
}
|
|
good := filepath.Join(root, "good")
|
|
item := newFixtureItem(source, target, good)
|
|
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, Overwrite(true),
|
|
WithHashPolicy(HashRead), SetFromDevice(WithReadMode(mode))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Content and metadata at the source survive; exactly the independent target succeeds.
|
|
result, err := item.terminal(t)
|
|
if err != nil || len(result.Targets) != 2 || result.Targets[0].Err == nil || result.Targets[1].Err != nil {
|
|
t.Fatalf("result=%+v / %v", result, err)
|
|
}
|
|
for _, path := range []string{source, good} {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || !bytes.Equal(data, content) {
|
|
t.Fatalf("content changed at %q: %v", path, err)
|
|
}
|
|
}
|
|
info, err := os.Stat(source)
|
|
if err != nil || !info.ModTime().Equal(fixed) {
|
|
t.Fatalf("source metadata changed: %v / %v", info, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultipleTargetsDoNotQuerySpaceOrWait(t *testing.T) {
|
|
// Ordinary targets use actual allocation errors, with no preflight estimate or reservation.
|
|
root := t.TempDir()
|
|
source := writeSourceFile(t, root, "source", bytes.Repeat([]byte("x"), 4096))
|
|
first, second := filepath.Join(root, "first"), filepath.Join(root, "second")
|
|
item := newFixtureItem(source, first, second)
|
|
fixture := newStreamFixture(item)
|
|
copyer, err := NewStream(context.Background(), fixture.onResults, WithHashPolicy(HashRead))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
copyer.availableSpace = func(string) (int64, error) { panic("unexpected space estimate for random targets") }
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
if err := copyer.Submit(item); err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
_ = copyer.Close()
|
|
done <- copyer.Wait()
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("target preparation waited for the same item's writers")
|
|
}
|
|
|
|
// Both independent real allocations succeeded.
|
|
result, err := item.terminal(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, target := range result.Targets {
|
|
if target.Err != nil {
|
|
t.Fatal(target.Err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreallocationFailureCleansOnlyOwnedOutput(t *testing.T) {
|
|
// Inject a real-classified allocation error while preserving independent targets on the same mount.
|
|
root := t.TempDir()
|
|
source := writeSourceFile(t, root, "source", []byte("new content"))
|
|
existing := writeSourceFile(t, root, "existing", []byte("old content"))
|
|
owned, good := filepath.Join(root, "owned"), filepath.Join(root, "good")
|
|
previous := allocateTarget
|
|
calls := 0
|
|
allocateTarget = func(file *os.File, size int64) error {
|
|
calls++
|
|
if calls == 3 {
|
|
return previous(file, size)
|
|
}
|
|
return syscall.EIO
|
|
}
|
|
t.Cleanup(func() { allocateTarget = previous })
|
|
item := newFixtureItem(source, existing, owned, good)
|
|
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, Overwrite(true)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := item.terminal(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, target := range result.Targets[:2] {
|
|
if !errors.Is(target.Err, syscall.EIO) || !errors.Is(target.Err, ErrTargetIO) {
|
|
t.Fatalf("wrong identity: %v", target.Err)
|
|
}
|
|
}
|
|
if result.Targets[2].Err != nil {
|
|
t.Fatal(result.Targets[2].Err)
|
|
}
|
|
if _, err := os.Stat(existing); err != nil {
|
|
t.Fatalf("preexisting file removed: %v", err)
|
|
}
|
|
if _, err := os.Stat(owned); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("exclusive output survived: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSourceDescriptorClosesWhenCacheOperationPanics(t *testing.T) {
|
|
for _, stage := range []string{"lookup", "publication"} {
|
|
t.Run(stage, func(t *testing.T) {
|
|
// Inject a panic after the item has acquired its real source descriptor.
|
|
root := t.TempDir()
|
|
source := writeSourceFile(t, root, "source", []byte("cache panic fixture"))
|
|
oldRead, oldWrite := readManagedXattr, writeManagedPathXattr
|
|
sentinel := errors.New("cache operation panic")
|
|
var opened *os.File
|
|
policy := HashCachedOnly
|
|
if stage == "lookup" {
|
|
readManagedXattr = func(file *os.File) ([]byte, error) { opened = file; panic(sentinel) }
|
|
} else {
|
|
policy = HashReadRefresh
|
|
readManagedXattr = func(file *os.File) ([]byte, error) { opened = file; return nil, errSignatureXattrUnsupported }
|
|
writeManagedPathXattr = func(string, []byte) error { panic(sentinel) }
|
|
}
|
|
t.Cleanup(func() { readManagedXattr, writeManagedPathXattr = oldRead, oldWrite })
|
|
item := newFixtureItem(source)
|
|
err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(policy))
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("panic identity=%v", err)
|
|
}
|
|
|
|
// A fatal cache failure can abandon a result, but must not abandon the descriptor it owns.
|
|
if opened == nil {
|
|
t.Fatal("cache operation was not reached")
|
|
}
|
|
if _, err := opened.Stat(); !errors.Is(err, os.ErrClosed) {
|
|
t.Fatalf("source descriptor leaked: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type panicSourceReader struct{ *os.File }
|
|
|
|
func (r *panicSourceReader) Read([]byte) (int, error) { panic("source reader panic") }
|
|
|
|
func TestSourceReadPanicReleasesBuffersAndTargets(t *testing.T) {
|
|
// Instrument pooled references and the real source descriptor before injecting a read panic.
|
|
trackChunkPool(t)
|
|
root := t.TempDir()
|
|
source := writeSourceFile(t, root, "source", bytes.Repeat([]byte("x"), 4096))
|
|
target := filepath.Join(root, "target")
|
|
old := openSourceContent
|
|
var opened *os.File
|
|
openSourceContent = func(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
|
|
src, err := old(path, mode, info)
|
|
if err == nil {
|
|
opened = src.file
|
|
src.reader = &panicSourceReader{src.file}
|
|
}
|
|
return src, err
|
|
}
|
|
t.Cleanup(func() { openSourceContent = old })
|
|
item := newFixtureItem(source, target)
|
|
err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(HashRead))
|
|
if err == nil {
|
|
t.Fatal("read panic succeeded")
|
|
}
|
|
if opened == nil {
|
|
t.Fatal("source did not open")
|
|
}
|
|
if _, err := opened.Stat(); !errors.Is(err, os.ErrClosed) {
|
|
t.Fatalf("source leaked: %v", err)
|
|
}
|
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("failed output survived: %v", err)
|
|
}
|
|
}
|