mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
649 lines
22 KiB
Go
649 lines
22 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"hash"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
mapset "github.com/deckarep/golang-set/v2"
|
|
"github.com/samuelncui/godf"
|
|
)
|
|
|
|
type trackingReadCloser struct {
|
|
closed int
|
|
}
|
|
|
|
func (*trackingReadCloser) Read([]byte) (int, error) {
|
|
return 0, io.EOF
|
|
}
|
|
|
|
func (r *trackingReadCloser) Close() error {
|
|
r.closed++
|
|
return nil
|
|
}
|
|
|
|
type failingReadCloser struct {
|
|
err error
|
|
}
|
|
|
|
func (r *failingReadCloser) Read([]byte) (int, error) {
|
|
return 0, r.err
|
|
}
|
|
|
|
func (*failingReadCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// failingContentReader delivers whole batches and then fails, so target writers still hold
|
|
// source buffers when the read stage stops.
|
|
type failingContentReader struct {
|
|
err error
|
|
batches int
|
|
reads int
|
|
}
|
|
|
|
func (r *failingContentReader) Read(p []byte) (int, error) {
|
|
r.reads++
|
|
if r.reads <= r.batches {
|
|
return copy(p, bytes.Repeat([]byte{'x'}, len(p))), nil
|
|
}
|
|
return 0, r.err
|
|
}
|
|
|
|
func (*failingContentReader) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// trackChunkPool instruments the shared read-buffer pool so a test can prove that every
|
|
// buffer returns to it, including buffers discarded by a failed target writer.
|
|
func trackChunkPool(t *testing.T) {
|
|
t.Helper()
|
|
|
|
var (
|
|
lock sync.Mutex
|
|
buffers []*chunkBuffer
|
|
)
|
|
|
|
// Empty the shared pool first so every buffer this test uses is tracked here. Two
|
|
// collections drop the buffers cached in every processor's private slot, which a drain loop
|
|
// on this goroutine cannot reach: without them the pipeline may reuse an untracked buffer and
|
|
// the assertions below become vacuous.
|
|
previousNew := chunkPool.New
|
|
chunkPool.New = nil
|
|
runtime.GC()
|
|
runtime.GC()
|
|
for chunkPool.Get() != nil {
|
|
}
|
|
t.Cleanup(func() {
|
|
chunkPool.New = previousNew
|
|
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
if len(buffers) == 0 {
|
|
t.Error("the pipeline acquired no read buffer")
|
|
}
|
|
for index, buffer := range buffers {
|
|
if refs := atomic.LoadInt32(&buffer.refs); refs != 0 {
|
|
t.Errorf("read buffer %d still holds %d references after the pipeline stopped", index, refs)
|
|
}
|
|
}
|
|
})
|
|
|
|
chunkPool.New = func() interface{} {
|
|
buffer := &chunkBuffer{data: make([]byte, batchSize)}
|
|
lock.Lock()
|
|
buffers = append(buffers, buffer)
|
|
lock.Unlock()
|
|
return buffer
|
|
}
|
|
}
|
|
|
|
// runWriteJob drives one prepared write Job through the copy and reporting stages. The caller
|
|
// attaches the results callback it wants to observe.
|
|
func runWriteJob(copyer *StreamCopyer, job *writeJob) error {
|
|
// Stage-level fixtures provide synthetic readers; prepare their target facts once here.
|
|
for _, name := range job.targets {
|
|
target, err := copyer.indexTarget(name, nil)
|
|
if err != nil {
|
|
job.fail(name, err)
|
|
continue
|
|
}
|
|
job.outputs = append(job.outputs, target)
|
|
}
|
|
prepared := make(chan *writeJob, 1)
|
|
prepared <- job
|
|
close(prepared)
|
|
|
|
copyed := copyer.copy(context.Background(), prepared)
|
|
runResults(copyer, copyed)
|
|
for range copyed {
|
|
}
|
|
return copyer.Wait()
|
|
}
|
|
|
|
// write drives one synthetic reader through the same transfer implementation as the stream.
|
|
func (c *StreamCopyer) write(ctx context.Context, job *writeJob, out chan<- *baseJob, cntr *counter, exhausted mapset.Set[string]) {
|
|
// Match the copy dispatcher's ownership of capacity workers for this standalone transfer.
|
|
defer c.stopSpaceMonitors()
|
|
t := newTransfer(c, job, newReadAhead(c), 0)
|
|
t.reservePublications(newPublicationOrder())
|
|
t.run(ctx, out, make(chan struct{}, 1), cntr, exhausted)
|
|
}
|
|
|
|
// acquireChunk supplies a standalone consumer fixture without a second production pool path.
|
|
func acquireChunk() *chunkBuffer {
|
|
chunk := chunkPool.New().(*chunkBuffer)
|
|
chunk.refs = 1
|
|
return chunk
|
|
}
|
|
|
|
func TestRunCopiesEmptyFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
}{
|
|
{name: "buffered source"},
|
|
{name: "mapped source", opts: []Option{SetFromDevice(WithReadMode(ReadMapped))}},
|
|
{name: "linear source", opts: []Option{SetFromDevice(LinearDevice(true))}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
source := writeSourceFile(t, root, "src", nil)
|
|
target := filepath.Join(root, "dst")
|
|
|
|
// The report machinery belongs to the af05f05c shell, so the row is asserted
|
|
// through the shell while the read mode stays a device option.
|
|
handler, getter := NewReportGetter()
|
|
opts := append([]Option{AccurateJob(source, []string{target}), WithEventHandler(handler)}, tt.opts...)
|
|
if err := runShell(context.Background(), opts...); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(target)
|
|
if err != nil {
|
|
t.Fatalf("stat dst: %v", err)
|
|
}
|
|
if info.Size() != 0 {
|
|
t.Fatalf("dst size = %d", info.Size())
|
|
}
|
|
|
|
report := getter()
|
|
if len(report.Errors) != 0 {
|
|
t.Fatalf("report errors = %v", report.Errors)
|
|
}
|
|
if len(report.Jobs) != 1 {
|
|
t.Fatalf("report jobs = %d", len(report.Jobs))
|
|
}
|
|
job := report.Jobs[0]
|
|
if job.Status != JobStatusFinished {
|
|
t.Fatalf("job status = %q", job.Status)
|
|
}
|
|
if job.Size != 0 {
|
|
t.Fatalf("job size = %d, want 0", job.Size)
|
|
}
|
|
if len(job.SuccessTargets) != 1 || job.SuccessTargets[0] != target {
|
|
t.Fatalf("success targets = %v", job.SuccessTargets)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteReportsTargetlessReadFailureAsItemFailure(t *testing.T) {
|
|
// Build a target-free hash item whose source fails on its first read.
|
|
readErr := errors.New("read failed")
|
|
copyer := newTestStream(t, WithHashPolicy(HashRead))
|
|
item := newFixtureItem("source")
|
|
fixture := newStreamFixture(item)
|
|
copyer.onResults = fixture.onResults
|
|
job := newWriteJob(&baseJob{
|
|
copyer: copyer,
|
|
item: item,
|
|
path: "source",
|
|
stat: &stat{size: 1},
|
|
}, &failingReadCloser{err: readErr})
|
|
completed := make(chan *baseJob, 1)
|
|
|
|
// An item with no target outcome to report could not be processed at all.
|
|
copyer.write(context.Background(), job, completed, new(counter), mapset.NewSet[string]())
|
|
if err := (<-completed).itemError; !errors.Is(err, readErr) {
|
|
t.Fatalf("item error = %v, want %v", err, readErr)
|
|
}
|
|
|
|
// The results stage must report that item as a failure instead of completing it.
|
|
copyed := make(chan *baseJob, 1)
|
|
copyed <- job.baseJob
|
|
close(copyed)
|
|
runResults(copyer, copyed)
|
|
if result, err := item.terminal(t); !errors.Is(err, readErr) {
|
|
t.Fatalf("terminal outcome = %#v / %v, want failure %v", result, err, readErr)
|
|
}
|
|
if err := copyer.Wait(); err != nil {
|
|
t.Fatalf("Wait() = %v, want nil: a failed item is an item outcome", err)
|
|
}
|
|
}
|
|
|
|
// TestWriteKeepsTargetOutcomesWhenTheReadAlsoFails pins one Result carrying both levels of
|
|
// failure: an item whose every target fails to open and whose source read then fails reports the
|
|
// item error and the target outcomes it did establish.
|
|
func TestWriteKeepsTargetOutcomesWhenTheReadAlsoFails(t *testing.T) {
|
|
root := t.TempDir()
|
|
readErr := errors.New("read failed")
|
|
|
|
// A target that already exists as a directory can never be opened for writing.
|
|
refused := filepath.Join(root, "refused")
|
|
if err := os.MkdirAll(refused, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
copyer := newTestStream(t, WithHashPolicy(HashRead))
|
|
item := newFixtureItem(filepath.Join(root, "source"), refused)
|
|
fixture := newStreamFixture(item)
|
|
copyer.onResults = fixture.onResults
|
|
job := newWriteJob(&baseJob{
|
|
copyer: copyer,
|
|
item: item,
|
|
path: filepath.Join(root, "source"),
|
|
stat: &stat{size: 1, mode: 0o644},
|
|
targets: []string{refused},
|
|
}, &failingReadCloser{err: readErr})
|
|
|
|
if err := runWriteJob(copyer, job); err != nil {
|
|
t.Fatalf("pipeline failed: %v", err)
|
|
}
|
|
if _, err := item.terminal(t); !errors.Is(err, readErr) {
|
|
t.Fatalf("terminal outcome = %v, want failure %v", err, readErr)
|
|
}
|
|
|
|
// The result carries the item-level failure and the target outcome it established.
|
|
result := fixture.delivered()
|
|
if len(result) != 1 {
|
|
t.Fatalf("delivered results = %d, want 1", len(result))
|
|
}
|
|
if !errors.Is(result[0].Err, readErr) {
|
|
t.Fatalf("result error = %v, want %v", result[0].Err, readErr)
|
|
}
|
|
if len(result[0].Targets) != 1 || !errors.Is(result[0].Targets[0].Err, os.ErrExist) {
|
|
t.Fatalf("result targets = %#v, want the refused target %q", result[0].Targets, refused)
|
|
}
|
|
}
|
|
|
|
func TestCopyReportsPrefetchedItemsAfterCancellation(t *testing.T) {
|
|
// Queue one prefetched reader before starting an already-canceled Copy stage.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
copyer := newTestStream(t)
|
|
copyer.toDevice.threads = 1
|
|
reader := new(trackingReadCloser)
|
|
item := newFixtureItem("source")
|
|
fixture := newStreamFixture(item)
|
|
copyer.onResults = fixture.onResults
|
|
job := newWriteJob(&baseJob{copyer: copyer, item: item, path: "source"}, reader)
|
|
prepared := make(chan *writeJob, 1)
|
|
prepared <- job
|
|
close(prepared)
|
|
|
|
// Copy owns accepted readers and must drain and close them during cancellation, then hand
|
|
// the item to the results stage, which reports it exactly once.
|
|
copyed := copyer.copy(ctx, prepared)
|
|
runResults(copyer, copyed)
|
|
for range copyed {
|
|
}
|
|
if reader.closed != 1 {
|
|
t.Fatalf("reader closed %d times, want 1", reader.closed)
|
|
}
|
|
if result, err := item.terminal(t); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("terminal outcome = %#v / %v, want %v", result, err, context.Canceled)
|
|
}
|
|
}
|
|
|
|
func TestWriteReturnsAfterHardStopWithoutPublishing(t *testing.T) {
|
|
// Use an unbuffered completion channel with no receiver to expose a blocked handoff.
|
|
copyer := newTestStream(t)
|
|
copyer.stopHard()
|
|
reader := new(trackingReadCloser)
|
|
job := newWriteJob(&baseJob{
|
|
copyer: copyer,
|
|
item: newFixtureItem("source"),
|
|
stat: &stat{},
|
|
}, reader)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
copyer.write(context.Background(), job, make(chan *baseJob), new(counter), mapset.NewSet[string]())
|
|
close(done)
|
|
}()
|
|
|
|
// A pipeline failure skips the completion handoff while retaining source cleanup.
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("write did not return after the pipeline stopped")
|
|
}
|
|
if reader.closed != 1 {
|
|
t.Fatalf("reader closed %d times, want 1", reader.closed)
|
|
}
|
|
}
|
|
|
|
func TestBaseJobFailureMovesSuccessfulTarget(t *testing.T) {
|
|
// Seed a completed target before applying a metadata-stage failure.
|
|
copyer := newTestStream(t)
|
|
item := newFixtureItem("source", "target")
|
|
job := &baseJob{
|
|
copyer: copyer, item: item, stat: &stat{},
|
|
targets: []string{"target"}, successTargets: []string{"target"},
|
|
}
|
|
|
|
// A late target failure must remove the target from the successful result.
|
|
job.fail("target", syscall.ENOSPC)
|
|
result := job.result()
|
|
if len(result.Targets) != 1 || result.Targets[0].Err == nil {
|
|
t.Fatalf("targets = %#v, want one failed outcome", result.Targets)
|
|
}
|
|
if len(job.successTargets) != 0 {
|
|
t.Fatalf("success targets = %v, want none", job.successTargets)
|
|
}
|
|
if !errors.Is(result.Targets[0].Err, syscall.ENOSPC) {
|
|
t.Fatalf("target failure = %v, want %v", result.Targets[0].Err, syscall.ENOSPC)
|
|
}
|
|
}
|
|
|
|
func TestTargetFailureKeepsItsIdentityAsItemOutcome(t *testing.T) {
|
|
// Record a mapped write failure before a later, unrelated pipeline error.
|
|
copyer := newTestStream(t)
|
|
item := newFixtureItem("source", "target")
|
|
job := &baseJob{
|
|
copyer: copyer, item: item, stat: &stat{}, targets: []string{"target"},
|
|
}
|
|
job.fail("target", mappingError(syscall.ENOSPC))
|
|
copyer.setError(errors.New("remove failed"))
|
|
|
|
// The item outcome keeps the no-space classification the caller classifies on.
|
|
result := job.result()
|
|
if len(result.Targets) != 1 || !errors.Is(result.Targets[0].Err, ErrTargetNoSpace) {
|
|
t.Fatalf("target outcome = %#v, want %v", result.Targets, ErrTargetNoSpace)
|
|
}
|
|
}
|
|
|
|
func TestWriteFailureDrainsBuffersAndTargets(t *testing.T) {
|
|
// Fail the source read after a whole batch, so every target writer runs its failure
|
|
// drain while the pipeline still holds the failed item's buffers.
|
|
trackChunkPool(t)
|
|
|
|
root := t.TempDir()
|
|
readErr := errors.New("read failed")
|
|
targets := []string{filepath.Join(root, "target-1"), filepath.Join(root, "target-2")}
|
|
item := newFixtureItem(filepath.Join(root, "source"), targets...)
|
|
copyer := newTestStream(t, WithHashPolicy(HashRead))
|
|
fixture := newStreamFixture(item)
|
|
copyer.onResults = fixture.onResults
|
|
job := newWriteJob(&baseJob{
|
|
copyer: copyer,
|
|
item: item,
|
|
path: filepath.Join(root, "source"),
|
|
stat: &stat{size: batchSize, mode: 0o644},
|
|
targets: targets,
|
|
}, &failingContentReader{err: readErr, batches: 1})
|
|
|
|
// The pipeline must drain every writer, report exactly one outcome, and return.
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- runWriteJob(copyer, job)
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("pipeline failed: %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the pipeline deadlocked while draining failed target writers")
|
|
}
|
|
|
|
result, err := item.terminal(t)
|
|
if err != nil {
|
|
t.Fatalf("an item whose targets failed must still complete: %v", err)
|
|
}
|
|
if len(result.SHA256) != 0 {
|
|
t.Fatalf("SHA256 = %x, want none: the read stopped before the source ended", result.SHA256)
|
|
}
|
|
if len(result.Targets) != 2 {
|
|
t.Fatalf("targets = %#v, want one outcome per requested target", result.Targets)
|
|
}
|
|
for _, outcome := range result.Targets {
|
|
if !errors.Is(outcome.Err, readErr) {
|
|
t.Fatalf("target %q error = %v, want %v", outcome.Path, outcome.Err, readErr)
|
|
}
|
|
if _, statErr := os.Stat(outcome.Path); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("failed target %q still exists: %v", outcome.Path, statErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// panickingHash panics on its first write, which is how a broken hash implementation dies inside
|
|
// the pipeline.
|
|
type panickingHash struct{ hash.Hash }
|
|
|
|
var errPanickingHash = errors.New("hash fixture panicked")
|
|
|
|
func (panickingHash) Write([]byte) (int, error) {
|
|
panic(errPanickingHash)
|
|
}
|
|
|
|
// TestRunReleasesTheCacheGateWhenTheHashPanics pins the fatal-panic guarantee for the cache gate.
|
|
// The gate is released by a deferred close, so a hash consumer that dies cannot leave a target
|
|
// writer blocked on it: a close at the end of the goroutine's body is skipped by the panic, and the
|
|
// writer, the copy stage and therefore Close and Wait stay parked forever even though the panic was
|
|
// already recovered and recorded as the run error.
|
|
func TestRunReleasesTheCacheGateWhenTheHashPanics(t *testing.T) {
|
|
captureLogs(t)
|
|
requireSignatureXattrSupport(t)
|
|
|
|
root := t.TempDir()
|
|
content := bytes.Repeat([]byte{'c'}, 4096)
|
|
input := writeSourceFile(t, root, "source.txt", content)
|
|
target := filepath.Join(root, "target.txt")
|
|
|
|
previous := sha256Pool
|
|
sha256Pool = &sync.Pool{New: func() interface{} { return panickingHash{Hash: sha256.New()} }}
|
|
t.Cleanup(func() { sha256Pool = previous })
|
|
|
|
item := newFixtureItem(input, target)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(HashReadRefresh))
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, errPanickingHash) {
|
|
t.Fatalf("run error = %v, want the recovered panic %v", err, errPanickingHash)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the run never returned: the hash consumer panicked and a target writer is still blocked on the cache gate")
|
|
}
|
|
}
|
|
|
|
func TestLinearTargetStopsWhenDiskUsageEstimateIsInsufficient(t *testing.T) {
|
|
// Size the Job beyond the filesystem estimate without allocating the source payload.
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "target")
|
|
usage, err := godf.NewDiskUsage(root)
|
|
if err != nil {
|
|
t.Fatalf("read disk usage: %v", err)
|
|
}
|
|
if usage.Available() > math.MaxInt64-(2<<30) {
|
|
t.Fatal("available disk space cannot be represented by the test Job size")
|
|
}
|
|
size := usage.Available() + (2 << 30)
|
|
option, err := buildOption()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
copyer := &StreamCopyer{
|
|
option: option, ctx: context.Background(), readCh: make(chan *baseJob, option.readBuffer),
|
|
eventCh: make(chan Event, 8), hardStop: make(chan struct{}),
|
|
getDevice: func(string) (string, error) { return root, nil },
|
|
availableSpace: availableSpace,
|
|
}
|
|
copyer.toDevice.linear = true
|
|
copyer.linearSpace = map[string]*spaceEstimate{root: {known: true, available: usage.Available()}}
|
|
item := newFixtureItem("source", target)
|
|
job := newWriteJob(&baseJob{
|
|
copyer: copyer, item: item, path: "source",
|
|
stat: &stat{size: size}, targets: []string{target},
|
|
outputs: []targetSpec{{name: target, path: target, device: root}},
|
|
}, io.NopCloser(bytes.NewReader(nil)))
|
|
completed := make(chan *baseJob, 1)
|
|
|
|
// A completed filesystem observation must stop a linear target before the oversized write starts.
|
|
copyer.write(context.Background(), job, completed, new(counter), mapset.NewSet[string]())
|
|
result := (<-completed).result()
|
|
if len(result.Targets) != 1 || !errors.Is(result.Targets[0].Err, ErrTargetNoSpace) {
|
|
t.Fatalf("target outcome = %#v, want %v", result.Targets, ErrTargetNoSpace)
|
|
}
|
|
if !copyer.linearTargetStopped() {
|
|
t.Fatal("linear target continued after the capacity estimate was exhausted")
|
|
}
|
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("stat target error = %v, want %v", err, os.ErrNotExist)
|
|
}
|
|
}
|
|
|
|
// TestStoppedLinearTargetRefusesSubmission pins the feed checkpoint of an exhausted linear
|
|
// target: the run accepts no more items, and an exhausted target stays an item outcome instead
|
|
// of a run error.
|
|
func TestStoppedLinearTargetRefusesSubmission(t *testing.T) {
|
|
copyer := newTestStream(t, SetToDevice(LinearDevice(true)))
|
|
copyer.endLinearTarget(ErrTargetNoSpace)
|
|
|
|
if err := copyer.Submit(newFixtureItem("source")); !errors.Is(err, ErrTargetNoSpace) {
|
|
t.Fatalf("Submit() error = %v, want %v", err, ErrTargetNoSpace)
|
|
}
|
|
if err := copyer.Wait(); err != nil {
|
|
t.Fatalf("Wait() error = %v, want nil: an exhausted target is an item outcome", err)
|
|
}
|
|
}
|
|
|
|
// TestSubmitRejectsANilItem pins the feed's input validation: a nil item is a submission error that
|
|
// is recorded as the run error without ending the feed, and every accepted item keeps its outcome.
|
|
func TestSubmitRejectsANilItem(t *testing.T) {
|
|
root := t.TempDir()
|
|
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
|
|
item := newFixtureItem(input, filepath.Join(root, "target.txt"))
|
|
after := newFixtureItem(input, filepath.Join(root, "target-after.txt"))
|
|
fixture := newStreamFixture(item, after)
|
|
|
|
stream, err := NewStream(context.Background(), fixture.onResults)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := stream.Submit(item); err != nil {
|
|
t.Fatalf("Submit() error = %v, want the item accepted", err)
|
|
}
|
|
if err := stream.Submit(nil); err == nil {
|
|
t.Fatal("Submit(nil) error = nil, want a rejected item")
|
|
}
|
|
// A rejected submission is that submission's error, not a stopping reason: the run still
|
|
// accepts what the caller submits next.
|
|
if err := stream.Submit(after); err != nil {
|
|
t.Fatalf("Submit() after a rejected item error = %v, want the item accepted", err)
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if err := stream.Wait(); err == nil {
|
|
t.Fatal("Wait() error = nil, want the rejected item")
|
|
}
|
|
for _, submitted := range []*fixtureItem{item, after} {
|
|
if _, err := submitted.terminal(t); err != nil {
|
|
t.Fatalf("item failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStreamCopyReleasesAChunkHandoffAfterHardStop pins the fatal-failure rule for the read
|
|
// handoff: a stage that stopped consuming cannot leave the reader blocked on a chunk.
|
|
func TestStreamCopyReleasesAChunkHandoffAfterHardStop(t *testing.T) {
|
|
// A real transfer producer must relinquish its borrowed buffer when its receiver stops.
|
|
trackChunkPool(t)
|
|
copyer := newTestStream(t)
|
|
job := newWriteJob(&baseJob{copyer: copyer, stat: &stat{size: 7}}, io.NopCloser(strings.NewReader("fixture")))
|
|
transfer := newTransfer(copyer, job, newReadAhead(copyer), 0)
|
|
transfer.chunks = make(chan *chunkBuffer)
|
|
go copyer.wrap(context.Background(), transfer.readContent)
|
|
time.Sleep(50 * time.Millisecond)
|
|
copyer.stopHard()
|
|
|
|
// Both content completion and asynchronous source closure must finish before inspection.
|
|
select {
|
|
case <-transfer.readDone:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("source stayed blocked on a chunk handoff")
|
|
}
|
|
<-transfer.sourceDone
|
|
if transfer.readErr == nil {
|
|
t.Fatal("missing hard-stop read error")
|
|
}
|
|
}
|
|
|
|
// TestSubmitDropsAnEventAfterHardStop pins the same rule for the event handoff: a fatal failure
|
|
// ends the run instead of blocking the stage that reports through it.
|
|
func TestSubmitDropsAnEventAfterHardStop(t *testing.T) {
|
|
copyer := newTestStream(t)
|
|
// No dispatch stage drains this channel, so only the hard stop can release the send.
|
|
copyer.eventCh = make(chan Event)
|
|
copyer.stopHard()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
copyer.submit(&EventUpdateCount{})
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("submit stayed blocked on the event handoff after a hard stop")
|
|
}
|
|
}
|
|
|
|
// TestFailAllReportsEveryTargetAsFailed pins the exhausted-device path: when no requested target
|
|
// can be written, the item still completes and every target carries the failure, which is what
|
|
// keeps the caller from reading a phantom success.
|
|
func TestFailAllReportsEveryTargetAsFailed(t *testing.T) {
|
|
copyer := newTestStream(t)
|
|
job := &baseJob{
|
|
copyer: copyer,
|
|
item: newFixtureItem("source"),
|
|
path: "source",
|
|
targets: []string{"first", "second"},
|
|
}
|
|
|
|
job.failAll(ErrTargetNoSpace)
|
|
result := job.result()
|
|
if result.Err != nil {
|
|
t.Fatalf("item error = %v, want nil: an exhausted target is a target outcome", result.Err)
|
|
}
|
|
if len(result.Targets) != 2 {
|
|
t.Fatalf("target outcomes = %#v, want one per requested target", result.Targets)
|
|
}
|
|
for index, target := range result.Targets {
|
|
if target.Path != job.targets[index] || !errors.Is(target.Err, ErrTargetNoSpace) {
|
|
t.Fatalf("target outcome %d = %#v, want %v on %q", index, target, ErrTargetNoSpace, job.targets[index])
|
|
}
|
|
}
|
|
}
|