From 75c417497ffcefc47e0d26f310e16b0237419b36 Mon Sep 17 00:00:00 2001 From: Samuel Cui Date: Wed, 26 Aug 2026 23:25:22 +0800 Subject: [PATCH] fix: propagate stream copy failures --- job.go | 2 ++ stream_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/job.go b/job.go index ce28f23..2478562 100644 --- a/job.go +++ b/job.go @@ -3,6 +3,7 @@ package acp import ( "context" "encoding/hex" + "fmt" "io" "io/fs" "sync" @@ -81,6 +82,7 @@ func (j *baseJob) success(path string) { } func (j *baseJob) fail(path string, err error) { + j.copyer.setError(fmt.Errorf("copy failed, source=%q target=%q, %w", j.path, path, err)) j.lock.Lock() defer j.lock.Unlock() diff --git a/stream_test.go b/stream_test.go index 91ca6a4..2c41d01 100644 --- a/stream_test.go +++ b/stream_test.go @@ -33,10 +33,11 @@ func (s *sliceStreamSource) Next(context.Context) (*StreamRequest, error) { } type collectingStreamSink struct { - results []*StreamResult - writes int - flushes int - err error + results []*StreamResult + writes int + flushes int + err error + flushErr error } func (s *collectingStreamSink) Write(_ context.Context, result *StreamResult) error { @@ -50,7 +51,7 @@ func (s *collectingStreamSink) Write(_ context.Context, result *StreamResult) er func (s *collectingStreamSink) Flush(context.Context) error { s.flushes++ - return nil + return s.flushErr } func TestRunStreamCopiesRequestsToLinearTarget(t *testing.T) { @@ -149,6 +150,47 @@ func TestRunStreamReturnsSourceAndSinkErrors(t *testing.T) { if sink.writes != 1 || sink.flushes != 0 { t.Fatalf("sink calls = writes:%d flushes:%d, want writes:1 flushes:0", sink.writes, sink.flushes) } + + // Verify that final persistence failures cross the synchronous stream interface. + flushErr := errors.New("flush failed") + sink = &collectingStreamSink{flushErr: flushErr} + err = RunStream(context.Background(), &sliceStreamSource{requests: []*StreamRequest{{ + ID: 4, Source: input, + }}}, sink, WithHash(true)) + if !errors.Is(err, flushErr) { + t.Fatalf("RunStream() error = %v, want %v", err, flushErr) + } + if sink.writes != 1 || sink.flushes != 1 { + t.Fatalf("sink calls = writes:%d flushes:%d, want writes:1 flushes:1", sink.writes, sink.flushes) + } +} + +func TestRunStreamReturnsTargetFailure(t *testing.T) { + // Force target creation to fail while allowing the Sink to accept the final Job result. + root := t.TempDir() + input := filepath.Join(root, "source.txt") + target := filepath.Join(root, "target.txt") + if err := os.WriteFile(input, []byte("fixture"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("existing"), 0o644); err != nil { + t.Fatal(err) + } + sink := new(collectingStreamSink) + + // A failed copy is a RunStream error even when the Sink records the failed result. + err := RunStream(context.Background(), &sliceStreamSource{requests: []*StreamRequest{{ + ID: 1, Source: input, Targets: []string{target}, + }}}, sink) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("RunStream() error = %v, want %v", err, os.ErrExist) + } + if sink.writes != 1 || len(sink.results) != 1 { + t.Fatalf("sink results = writes:%d results:%d, want writes:1 results:1", sink.writes, len(sink.results)) + } + if len(sink.results[0].Job.FailTargets) != 1 { + t.Fatalf("failed targets = %v, want one target", sink.results[0].Job.FailTargets) + } } type boundedStreamSource struct {