Files
acp/content_test.go
T
Samuel Cui b5f28fa474 Release ACP v0.2.7
Add ordered content sources and targets to the shared copy pipeline. Observe
fs.File metadata before target creation, bind fs.FS sources, and expose
fatal-stop notification while preserving native copy behavior.
2026-10-15 18:18:39 +08:00

536 lines
17 KiB
Go

package acp
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type testContentItem struct {
*SimpleJob
source ContentSource
targets []ContentTarget
}
func (i *testContentItem) ContentSource() ContentSource { return i.source }
func (i *testContentItem) ContentTargets() []ContentTarget { return i.targets }
type testContentSource func(context.Context) (fs.File, error)
func (s testContentSource) Open(ctx context.Context) (fs.File, error) {
return s(ctx)
}
type testContentFile struct {
io.ReadCloser
metadata ContentMetadata
stat func() (fs.FileInfo, error)
xattrs func() (map[string][]byte, error)
}
func (f *testContentFile) Stat() (fs.FileInfo, error) {
if f.stat != nil {
return f.stat()
}
return f.metadata.Info, nil
}
func (f *testContentFile) Xattrs() (map[string][]byte, error) {
if f.xattrs != nil {
return f.xattrs()
}
return f.metadata.Xattrs, nil
}
type testContentTarget func(context.Context, ContentMetadata) (ContentWriter, error)
func (t testContentTarget) Open(ctx context.Context, metadata ContentMetadata) (ContentWriter, error) {
return t(ctx, metadata)
}
type testContentReader struct {
io.Reader
close func() error
}
func (r *testContentReader) Close() error { return r.close() }
type testContentWriter struct {
io.Writer
finish func(error) error
}
func (w *testContentWriter) Finish(cause error) error { return w.finish(cause) }
func TestContentSourceObservedMetadataPrecedesNativeOutput(t *testing.T) {
// The identifier is deliberately not a native path; only Open owns source observation.
root := t.TempDir()
data := []byte("observed content")
mtime := time.Unix(1700000000, 0)
closed := false
item := &testContentItem{SimpleJob: &SimpleJob{Path: "member/a/../b", Dsts: []string{filepath.Join(root, "copy")}}}
item.source = testContentSource(func(context.Context) (fs.File, error) {
if _, err := os.Stat(item.Dsts[0]); !errors.Is(err, os.ErrNotExist) {
t.Errorf("output exists before source Open: %v", err)
}
return &testContentFile{
ReadCloser: &testContentReader{Reader: bytes.NewReader(data), close: func() error { closed = true; return nil }},
metadata: ContentMetadata{Info: contentForeignInfo{contentInfo{
name: "b", size: int64(len(data)), mode: 0600, modTime: mtime,
}}},
}, nil
})
// Real native staging, allocation and publication must consume the observed size and mode.
callback, batches := collectResults()
if err := runStream(
context.Background(),
callback,
[]Item{item},
SetFromDevice(LinearDevice(true)),
WithHashPolicy(HashReadRefresh),
); err != nil {
t.Fatal(err)
}
result := batches()[0][0]
got, err := os.ReadFile(item.Dsts[0])
if err != nil || !bytes.Equal(got, data) || !closed {
t.Fatalf("output=%q, err=%v, source closed=%v", got, err, closed)
}
info, err := os.Stat(item.Dsts[0])
if err != nil || info.Mode().Perm() != 0600 || !info.ModTime().Equal(mtime) {
t.Fatalf("native metadata=%v, err=%v", info, err)
}
digest := sha256.Sum256(data)
if result.Err != nil || result.Targets[0].Err != nil || result.Size != int64(len(data)) ||
!bytes.Equal(result.SHA256, digest[:]) {
t.Fatalf("result=%#v", result)
}
}
func TestContentSourceOpenAndCloseFailuresDoNotPublish(t *testing.T) {
for _, stage := range []string{"open", "close"} {
t.Run(stage, func(t *testing.T) {
// A failed member must not leave an output that a subsequent attempt could adopt.
root := t.TempDir()
failure := errors.New(stage + " failure")
closes := 0
item := &testContentItem{SimpleJob: &SimpleJob{Path: "member", Dsts: []string{filepath.Join(root, "copy")}}}
item.source = testContentSource(func(context.Context) (fs.File, error) {
if stage == "open" {
return nil, failure
}
return &testContentFile{
ReadCloser: &testContentReader{
Reader: strings.NewReader("abc"), close: func() error { closes++; return failure },
},
metadata: ContentMetadata{Info: contentInfo{name: "member", size: 3, mode: 0600}},
}, nil
})
// The exact Close cause reaches both the item and its uncommitted native target.
callback, batches := collectResults()
err := runStream(
context.Background(),
callback,
[]Item{item},
SetFromDevice(LinearDevice(true)),
WithHashPolicy(HashRead),
)
result := batches()[0][0]
if !errors.Is(err, failure) || !errors.Is(result.Err, failure) || !errors.Is(result.Targets[0].Err, failure) {
t.Fatalf("run=%v, result=%#v", err, result)
}
if _, err := os.Stat(item.Dsts[0]); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("failed member was published: %v", err)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 || (stage == "close" && closes != 1) {
t.Fatalf("owned output cleanup: entries=%v, err=%v, closes=%d", entries, err, closes)
}
})
}
}
func TestContentEndpointsSerializeThroughCloseAndFinish(t *testing.T) {
// Synthetic stream members record lifecycle order without filesystem identifiers.
var lock sync.Mutex
var events []string
record := func(event string) { lock.Lock(); events = append(events, event); lock.Unlock() }
var items []Item
for _, name := range []string{"first", "second", "third"} {
item := &testContentItem{SimpleJob: &SimpleJob{Path: name, Dsts: []string{"stream/a/../" + name}}}
item.source = testContentSource(func(context.Context) (fs.File, error) {
record(name + " open source")
return &testContentFile{ReadCloser: &testContentReader{Reader: strings.NewReader(name), close: func() error {
record(name + " close source")
return nil
}}, metadata: ContentMetadata{Info: contentInfo{name: name, size: int64(len(name)), mode: 0640}}}, nil
})
item.targets = []ContentTarget{testContentTarget(func(
_ context.Context, metadata ContentMetadata,
) (ContentWriter, error) {
record(name + " open target")
if metadata.Info.Size() != int64(len(name)) {
t.Errorf("observed metadata size=%d", metadata.Info.Size())
}
return &testContentWriter{Writer: io.Discard, finish: func(cause error) error {
record(name + " finish target")
if cause != nil {
t.Errorf("Finish cause=%v", cause)
}
return nil
}}, nil
})}
items = append(items, item)
}
// Both endpoint sides use one lane even though preparation has multiple workers.
callback, batches := collectResults()
if err := runStream(
context.Background(),
callback,
items,
SetFromDevice(LinearDevice(true)),
SetToDevice(LinearDevice(true)),
WithHashPolicy(HashRead),
); err != nil {
t.Fatal(err)
}
if len(events) != 12 {
t.Fatalf("events=%v", events)
}
for i, name := range []string{"first", "second", "third"} {
member := events[i*4 : (i+1)*4]
if member[0] != name+" open source" || member[3] != name+" finish target" {
t.Fatalf("member lifecycle=%v", member)
}
middle := []string{name + " open target", name + " close source"}
if !reflect.DeepEqual(member[1:3], middle) && !reflect.DeepEqual(member[1:3], []string{middle[1], middle[0]}) {
t.Fatalf("member lifecycle=%v", member)
}
}
for _, batch := range batches() {
for _, result := range batch {
if result.Err != nil || result.Targets[0].Err != nil || result.Targets[0].Path != result.Job.Targets()[0] {
t.Fatalf("result=%#v", result)
}
}
}
}
func TestContentTargetAcceptsNativeMappedSourcesInRequestOrder(t *testing.T) {
// Native sources preserve configured mappings and observed metadata with a single endpoint target.
root := t.TempDir()
var opened []string
var items []Item
for _, name := range []string{"first", "second", "third"} {
source := writeSourceFile(t, root, name, []byte(name))
item := &testContentItem{SimpleJob: &SimpleJob{Path: source, Dsts: []string{"display/a/../" + name}}}
item.targets = []ContentTarget{testContentTarget(func(
_ context.Context, metadata ContentMetadata,
) (ContentWriter, error) {
opened = append(opened, name)
if metadata.Info.Size() != int64(len(name)) {
t.Errorf("size=%d", metadata.Info.Size())
}
return &testContentWriter{Writer: io.Discard, finish: func(error) error { return nil }}, nil
})}
items = append(items, item)
}
// Native source mappings and stream result identifiers remain independent.
callback, batches := collectResults()
if err := runStream(
context.Background(),
callback,
items,
SetFromDevice(WithReadMode(ReadMapped)),
SetToDevice(LinearDevice(true)),
WithHashPolicy(HashRead),
); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opened, []string{"first", "second", "third"}) {
t.Fatalf("Open order=%v", opened)
}
for _, batch := range batches() {
for _, result := range batch {
if result.Err != nil || result.Targets[0].Err != nil {
t.Fatalf("result=%#v", result)
}
}
}
}
func TestContentFailuresFinishExactlyOnceAndStopTheLane(t *testing.T) {
for _, stage := range []string{
"short read", "read", "short write", "write", "close", "finish", "open target", "open target handle",
} {
t.Run(stage, func(t *testing.T) {
// Inject the boundary failure into the first member; the second must remain unopened.
failure := errors.New(stage)
if stage == "short read" {
failure = io.ErrUnexpectedEOF
}
if stage == "short write" {
failure = io.ErrShortWrite
}
var opens, finishes int
var finishCause error
var items []Item
for range 2 {
item := &testContentItem{SimpleJob: &SimpleJob{Path: "source", Dsts: []string{"target"}}}
item.source = testContentSource(func(context.Context) (fs.File, error) {
opens++
var reader io.Reader = strings.NewReader("abc")
if stage == "read" {
reader = contentErrorReader{err: failure}
}
size := int64(3)
if stage == "short read" {
size = 4
}
return &testContentFile{ReadCloser: &testContentReader{Reader: reader, close: func() error {
if stage == "close" {
return failure
}
return nil
}}, metadata: ContentMetadata{Info: contentInfo{name: "source", size: size, mode: 0600}}}, nil
})
item.targets = []ContentTarget{testContentTarget(func(context.Context, ContentMetadata) (ContentWriter, error) {
if stage == "open target" {
return nil, failure
}
var writer io.Writer = io.Discard
if stage == "write" {
writer = contentErrorWriter{err: failure}
}
if stage == "short write" {
writer = contentErrorWriter{}
}
var openErr error
if stage == "open target handle" {
openErr = failure
}
return &testContentWriter{Writer: writer, finish: func(cause error) error {
finishes++
finishCause = cause
if stage == "finish" {
return failure
}
return nil
}}, openErr
})}
items = append(items, item)
}
// Finish observes read, write and Close failures and is never retried by cleanup.
callback, batches := collectResults()
err := runStream(
context.Background(),
callback,
items,
SetFromDevice(LinearDevice(true)),
SetToDevice(LinearDevice(true)),
WithHashPolicy(HashRead),
)
wantFinishes := 1
if stage == "open target" {
wantFinishes = 0
}
if !errors.Is(err, failure) || opens != 1 || finishes != wantFinishes {
t.Fatalf("run=%v, opens=%d, finishes=%d", err, opens, finishes)
}
if stage != "finish" && stage != "open target" && !errors.Is(finishCause, failure) {
t.Fatalf("Finish cause=%v, want=%v", finishCause, failure)
}
if len(batches()) != 2 || !errors.Is(batches()[0][0].Targets[0].Err, failure) ||
!errors.Is(batches()[1][0].Err, failure) {
t.Fatalf("results=%v", batches())
}
})
}
}
type contentErrorReader struct{ err error }
func (r contentErrorReader) Read([]byte) (int, error) { return 0, r.err }
type contentErrorWriter struct{ err error }
func (w contentErrorWriter) Write([]byte) (int, error) { return 0, w.err }
func TestContentSourceHashPoliciesIgnoreForeignCache(t *testing.T) {
for _, policy := range []HashPolicy{
HashOff, HashCachedOnly, HashCachedOrRead, HashCachedOrReadRefresh, HashRead, HashReadRefresh,
} {
t.Run(policy.String(), func(t *testing.T) {
// A valid decoy matches the observed facts but cannot opt a foreign file into cache reuse.
data := []byte("actual content")
mtime := time.Unix(1700000000, 0)
decoy := encodeCachedSignature(CachedSignature{
Size: int64(len(data)), MtimeNS: mtime.UnixNano(), SHA256: [32]byte{1},
})
opens, closes := 0, 0
item := &testContentItem{
SimpleJob: &SimpleJob{Path: "stream member"},
source: testContentSource(func(context.Context) (fs.File, error) {
opens++
return &testContentFile{
ReadCloser: &testContentReader{Reader: bytes.NewReader(data), close: func() error {
closes++
return nil
}},
metadata: ContentMetadata{
Info: contentInfo{name: "member", size: int64(len(data)), mode: 0600, modTime: mtime},
Xattrs: map[string][]byte{"acp.signature": decoy, "user.acp.signature": decoy},
},
}, nil
}),
}
// Content policies hash actual bytes; off and cached-only observe nothing and invent no hash.
callback, batches := collectResults()
if err := runStream(context.Background(), callback, []Item{item},
SetFromDevice(LinearDevice(true)), WithHashPolicy(policy)); err != nil {
t.Fatal(err)
}
var want []byte
wantOpens := 0
if policy != HashOff && policy != HashCachedOnly {
digest := sha256.Sum256(data)
want, wantOpens = digest[:], 1
}
result := batches()[0][0]
if result.Err != nil || !bytes.Equal(result.SHA256, want) || result.SignatureCacheHit ||
opens != wantOpens || closes != wantOpens {
t.Fatalf("opens=%d, closes=%d, result=%#v", opens, closes, result)
}
})
}
}
func TestContentCancellationClosesWithoutDraining(t *testing.T) {
// A reader with a large remaining payload cancels after its first buffer, without draining it.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var reads atomic.Int64
closes := 0
item := &testContentItem{SimpleJob: &SimpleJob{Path: "member", Dsts: []string{"target"}}}
item.source = testContentSource(func(context.Context) (fs.File, error) {
reader := contentCancelReader{cancel: cancel, reads: &reads}
return &testContentFile{
ReadCloser: &testContentReader{Reader: reader, close: func() error { closes++; return nil }},
metadata: ContentMetadata{Info: contentInfo{name: "member", size: 1 << 40, mode: 0600}},
}, nil
})
finishes := 0
item.targets = []ContentTarget{testContentTarget(func(context.Context, ContentMetadata) (ContentWriter, error) {
return &testContentWriter{Writer: io.Discard, finish: func(cause error) error {
finishes++
if !errors.Is(cause, context.Canceled) {
t.Errorf("Finish cause=%v", cause)
}
return nil
}}, nil
})}
// Cancellation keeps its original cause and joins every endpoint handle exactly once.
callback, batches := collectResults()
err := runStream(
ctx,
callback,
[]Item{item},
SetFromDevice(LinearDevice(true)),
SetToDevice(LinearDevice(true)),
WithHashPolicy(HashRead),
)
if !errors.Is(err, context.Canceled) || reads.Load() != 1 || closes != 1 || finishes != 1 {
t.Fatalf("run=%v, reads=%d, closes=%d, finishes=%d, results=%v", err, reads.Load(), closes, finishes, batches())
}
}
type contentCancelReader struct {
cancel context.CancelFunc
reads *atomic.Int64
}
func (r contentCancelReader) Read(data []byte) (int, error) {
r.reads.Add(1)
r.cancel()
clear(data)
return len(data), nil
}
type contentForeignInfo struct{ contentInfo }
func (contentForeignInfo) Sys() any { return "endpoint-owned metadata" }
func TestContentOptionsFailBeforeOpeningEitherSide(t *testing.T) {
for _, tc := range []struct {
name string
from []DeviceOption
to []DeviceOption
source bool
targets int
aligned bool
}{
{name: "random source", source: true, from: []DeviceOption{DeviceThreads(1)}},
{name: "mapped endpoint source", source: true, from: []DeviceOption{LinearDevice(true), WithReadMode(ReadMapped)}},
{name: "random target", targets: 1, to: []DeviceOption{DeviceThreads(1)}},
{name: "multiple stream targets", targets: 2, to: []DeviceOption{LinearDevice(true)}},
{name: "misaligned targets", targets: 1, aligned: true, to: []DeviceOption{LinearDevice(true)}},
} {
t.Run(tc.name, func(t *testing.T) {
// Invalid endpoint configuration is refused during indexing, before native stat or Open.
item := &testContentItem{SimpleJob: &SimpleJob{Path: "not a native source"}}
if tc.source {
item.source = testContentSource(func(context.Context) (fs.File, error) {
t.Error("source opened")
return nil, nil
})
}
for range tc.targets {
item.Dsts = append(item.Dsts, "target")
item.targets = append(item.targets, testContentTarget(func(
context.Context, ContentMetadata,
) (ContentWriter, error) {
t.Error("target opened")
return nil, nil
}))
}
if tc.aligned {
item.Dsts = append(item.Dsts, "native target")
}
// Item failures retain the stream's accepted-result contract.
callback, batches := collectResults()
if err := runStream(
context.Background(),
callback,
[]Item{item},
SetFromDevice(tc.from...),
SetToDevice(tc.to...),
); err != nil {
t.Fatal(err)
}
result := batches()[0][0]
if result.Err == nil || strings.Contains(result.Err.Error(), "stat") {
t.Fatalf("validation result=%#v", result)
}
})
}
}