summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 07:19:29 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 07:19:29 +0300
commitc75d7a6eab16ccb0d75270c841821c9b47ebba7b (patch)
treed8c165d527bb78e49a18fe426fa38a17e293623a
parent925ef996f2661f5e5cf57a1cd5a842303578330c (diff)
Check copyFile destination Close() error (u9)
The deferred out.Close() silently dropped disk-full, quota, and I/O errors surfaced when the OS flushes buffered writes. Capture the Close error explicitly, remove the partial destination on failure, and add copyFile success / missing-source unit tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-rw-r--r--player-server/internal/service/write.go16
-rw-r--r--player-server/internal/service/write_test.go38
2 files changed, 50 insertions, 4 deletions
diff --git a/player-server/internal/service/write.go b/player-server/internal/service/write.go
index 3fe4d47..d76dd87 100644
--- a/player-server/internal/service/write.go
+++ b/player-server/internal/service/write.go
@@ -237,15 +237,23 @@ func copyFile(src, dst string) error {
}
defer in.Close()
+ // Do NOT defer out.Close(): for the destination file we must observe the
+ // Close error explicitly. When the OS flushes buffered writes during Close
+ // it can surface disk-full, quota, or I/O errors that a deferred close
+ // would silently swallow, leaving a truncated/corrupt destination behind.
out, err := os.Create(dst)
if err != nil {
return err
}
- defer out.Close()
-
- if _, err := io.Copy(out, in); err != nil {
+ _, copyErr := io.Copy(out, in)
+ closeErr := out.Close()
+ if copyErr != nil {
_ = os.Remove(dst)
- return err
+ return copyErr
+ }
+ if closeErr != nil {
+ _ = os.Remove(dst)
+ return fmt.Errorf("close %q: %w", dst, closeErr)
}
return nil
}
diff --git a/player-server/internal/service/write_test.go b/player-server/internal/service/write_test.go
index 122ca78..3cf276b 100644
--- a/player-server/internal/service/write_test.go
+++ b/player-server/internal/service/write_test.go
@@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
+ "os"
+ "path/filepath"
"testing"
"codeberg.org/snonux/player/internal/clock"
@@ -98,3 +100,39 @@ func TestWriteService_RestoreMedia(t *testing.T) {
})
}
}
+
+// TestCopyFile_Success verifies that copyFile creates the destination with
+// the exact contents of the source on a clean run (and returns no error).
+func TestCopyFile_Success(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "src.bin")
+ dst := filepath.Join(dir, "dst.bin")
+ payload := []byte("hello copyFile world")
+ if err := os.WriteFile(src, payload, 0o644); err != nil {
+ t.Fatalf("write src: %v", err)
+ }
+ if err := copyFile(src, dst); err != nil {
+ t.Fatalf("copyFile: %v", err)
+ }
+ got, err := os.ReadFile(dst)
+ if err != nil {
+ t.Fatalf("read dst: %v", err)
+ }
+ if string(got) != string(payload) {
+ t.Fatalf("dst contents mismatch: got %q want %q", got, payload)
+ }
+}
+
+// TestCopyFile_MissingSource verifies the source-open error path propagates
+// cleanly (no destination should be created or leaked).
+func TestCopyFile_MissingSource(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "does-not-exist.bin")
+ dst := filepath.Join(dir, "dst.bin")
+ if err := copyFile(src, dst); err == nil {
+ t.Fatal("expected error for missing source, got nil")
+ }
+ if _, err := os.Stat(dst); !os.IsNotExist(err) {
+ t.Fatalf("expected dst to not exist, stat err=%v", err)
+ }
+}