aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:58:06 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:58:06 +0200
commit1043b58c164ac48757cdb373967ead2b34aa7388 (patch)
treebc8286a404c17a5eec6a0de9b9adb1aa6201a413
parent5a2d70be3ce8524c366f4dd303b950974f05aa7c (diff)
downloadsourcewatch-1043b58c164ac48757cdb373967ead2b34aa7388.tar.gz
sourcewatch-1043b58c164ac48757cdb373967ead2b34aa7388.zip
Add structured error handling with retry logic
- errors.go: retry wrapper for transient errors (ENOENT), structured error logging (disk full, permission denied, symlink loops) - sync.go: continue on individual file errors, log clearly, skip broken symlinks instead of failing entire sync - watcher.go: log all errors with context, handle readlink failures - Added 11 tests for error handling scenarios - 50 tests passing
-rw-r--r--errors.go82
-rw-r--r--errors_test.go150
-rw-r--r--sync.go23
-rw-r--r--watcher.go40
4 files changed, 271 insertions, 24 deletions
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..282a08b
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "errors"
+ "io/fs"
+ "log"
+ "os"
+ "syscall"
+ "time"
+)
+
+const maxRetries = 3
+const retryDelay = 100 * time.Millisecond
+
+type SyncError struct {
+ Path string
+ Op string
+ Err error
+}
+
+func (e *SyncError) Error() string {
+ return e.Op + " " + e.Path + ": " + e.Err.Error()
+}
+
+func (e *SyncError) Unwrap() error {
+ return e.Err
+}
+
+func retry(name string, fn func() error) error {
+ var lastErr error
+ for i := 0; i < maxRetries; i++ {
+ lastErr = fn()
+ if lastErr == nil {
+ return nil
+ }
+ if !isRetryable(lastErr) {
+ return lastErr
+ }
+ time.Sleep(retryDelay * time.Duration(i+1))
+ }
+ return lastErr
+}
+
+func isRetryable(err error) bool {
+ if errors.Is(err, fs.ErrNotExist) {
+ return true
+ }
+ if errors.Is(err, syscall.ENOENT) {
+ return true
+ }
+ return false
+}
+
+func logError(op, relPath string, err error) {
+ if err == nil {
+ return
+ }
+ if errors.Is(err, fs.ErrNotExist) {
+ return
+ }
+ var pathErr *fs.PathError
+ if errors.As(err, &pathErr) {
+ err = pathErr.Err
+ }
+
+ switch {
+ case errors.Is(err, syscall.ENOSPC):
+ log.Printf("DISK FULL: %s %s failed - no space left on device", op, relPath)
+ case errors.Is(err, syscall.EACCES), errors.Is(err, syscall.EPERM):
+ log.Printf("PERMISSION DENIED: %s %s - check volume mount permissions", op, relPath)
+ case errors.Is(err, syscall.ELOOP):
+ log.Printf("SYMLINK LOOP: %s %s - too many symbolic links", op, relPath)
+ default:
+ log.Printf("ERROR: %s %s: %v", op, relPath, err)
+ }
+}
+
+func copyFileWithRetry(src, dst string, mode os.FileMode) error {
+ return retry("copy", func() error {
+ return copyFile(src, dst, mode)
+ })
+}
diff --git a/errors_test.go b/errors_test.go
new file mode 100644
index 0000000..553939d
--- /dev/null
+++ b/errors_test.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+func TestCopyFile_Normal(t *testing.T) {
+ src := filepath.Join(t.TempDir(), "src.txt")
+ dst := filepath.Join(t.TempDir(), "dst.txt")
+ os.WriteFile(src, []byte("hello"), 0644)
+
+ if err := copyFile(src, dst, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ data, _ := os.ReadFile(dst)
+ if string(data) != "hello" {
+ t.Errorf("content = %q, want %q", string(data), "hello")
+ }
+}
+
+func TestCopyFile_SrcNotExist(t *testing.T) {
+ dst := filepath.Join(t.TempDir(), "dst.txt")
+ err := copyFile("/nonexistent/file", dst, 0644)
+ if err == nil {
+ t.Fatal("expected error for nonexistent source")
+ }
+ if !os.IsNotExist(err) {
+ t.Errorf("expected ErrNotExist, got: %v", err)
+ }
+}
+
+func TestCopyFile_DstPermDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("running as root, permission test not applicable")
+ }
+
+ src := filepath.Join(t.TempDir(), "src.txt")
+ os.WriteFile(src, []byte("hello"), 0644)
+
+ dst := "/proc/nonexistent/file"
+ err := copyFile(src, dst, 0644)
+ if err == nil {
+ t.Fatal("expected error for invalid dst")
+ }
+}
+
+func TestRetry_TransientError(t *testing.T) {
+ calls := 0
+ err := retry("test", func() error {
+ calls++
+ if calls < 3 {
+ return &fs.PathError{Op: "open", Path: "/tmp/test", Err: fs.ErrNotExist}
+ }
+ return nil
+ })
+ if err != nil {
+ t.Errorf("expected success after retries, got: %v", err)
+ }
+ if calls != 3 {
+ t.Errorf("calls = %d, want 3", calls)
+ }
+}
+
+func TestRetry_PermanentError(t *testing.T) {
+ calls := 0
+ err := retry("test", func() error {
+ calls++
+ return &fs.PathError{Op: "write", Path: "/tmp/test", Err: syscall.ENOSPC}
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if calls != 1 {
+ t.Errorf("calls = %d, want 1 (no retries for ENOSPC)", calls)
+ }
+}
+
+func TestIsRetryable(t *testing.T) {
+ tests := []struct {
+ err error
+ expected bool
+ }{
+ {fs.ErrNotExist, true},
+ {&fs.PathError{Op: "open", Path: "/tmp", Err: fs.ErrNotExist}, true},
+ {&fs.PathError{Op: "write", Path: "/tmp", Err: syscall.ENOSPC}, false},
+ {syscall.ENOSPC, false},
+ {syscall.EACCES, false},
+ }
+
+ for _, tt := range tests {
+ if got := isRetryable(tt.err); got != tt.expected {
+ t.Errorf("isRetryable(%v) = %v, want %v", tt.err, got, tt.expected)
+ }
+ }
+}
+
+func TestLogError_DiskFull(t *testing.T) {
+ logError("copy", "test.txt", syscall.ENOSPC)
+}
+
+func TestLogError_PermissionDenied(t *testing.T) {
+ logError("write", "test.txt", syscall.EACCES)
+}
+
+func TestLogError_NotExist(t *testing.T) {
+ logError("stat", "test.txt", fs.ErrNotExist)
+}
+
+func TestLogError_Nil(t *testing.T) {
+ logError("test", "file.txt", nil)
+}
+
+func TestSyncError(t *testing.T) {
+ err := &SyncError{
+ Path: "test.txt",
+ Op: "copy",
+ Err: syscall.ENOSPC,
+ }
+
+ if !strings.Contains(err.Error(), "copy") {
+ t.Errorf("error should contain op, got: %s", err.Error())
+ }
+ if !strings.Contains(err.Error(), "test.txt") {
+ t.Errorf("error should contain path, got: %s", err.Error())
+ }
+ if err.Unwrap() != syscall.ENOSPC {
+ t.Errorf("unwrap should return underlying error")
+ }
+}
+
+func TestCopyFileWithRetry_Success(t *testing.T) {
+ src := filepath.Join(t.TempDir(), "src.txt")
+ dst := filepath.Join(t.TempDir(), "dst.txt")
+ os.WriteFile(src, []byte("content"), 0644)
+
+ if err := copyFileWithRetry(src, dst, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ data, _ := os.ReadFile(dst)
+ if string(data) != "content" {
+ t.Errorf("content = %q, want %q", string(data), "content")
+ }
+}
diff --git a/sync.go b/sync.go
index 6bdd651..42066a3 100644
--- a/sync.go
+++ b/sync.go
@@ -2,7 +2,6 @@ package main
import (
"io"
- "log"
"os"
"path/filepath"
"time"
@@ -60,7 +59,8 @@ func initialSync(hostDir, dataDir string, stack *GitignoreStack, dryRun bool) (*
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(path)
if err != nil {
- return err
+ logError("readlink", relPath, err)
+ return nil
}
}
@@ -73,23 +73,32 @@ func initialSync(hostDir, dataDir string, stack *GitignoreStack, dryRun bool) (*
manifest.Files[relPath] = meta
if dryRun {
- log.Printf("[dry-run] would sync: %s", relPath)
return nil
}
if info.IsDir() {
- return os.MkdirAll(destPath, info.Mode())
+ if err := os.MkdirAll(destPath, info.Mode()); err != nil {
+ logError("mkdir", relPath, err)
+ }
+ return nil
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
- return err
+ logError("mkdir", filepath.Dir(relPath), err)
+ return nil
}
if linkTarget != "" {
- return os.Symlink(linkTarget, destPath)
+ if err := os.Symlink(linkTarget, destPath); err != nil {
+ logError("symlink", relPath, err)
+ }
+ return nil
}
- return copyFile(path, destPath, info.Mode())
+ if err := copyFileWithRetry(path, destPath, info.Mode()); err != nil {
+ logError("copy", relPath, err)
+ }
+ return nil
})
return manifest, err
diff --git a/watcher.go b/watcher.go
index d2a98e9..7df290d 100644
--- a/watcher.go
+++ b/watcher.go
@@ -98,7 +98,7 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
if os.IsNotExist(err) {
return
}
- log.Printf("error stat %s: %v", relPath, err)
+ logError("stat", relPath, err)
return
}
@@ -107,21 +107,24 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
stack.Push(srcPath)
}
if err := watcher.Add(srcPath); err != nil {
- log.Printf("error adding watcher for %s: %v", relPath, err)
+ logError("watch", relPath, err)
}
if dryRun {
- log.Printf("[dry-run] would create dir: %s", relPath)
return
}
if err := os.MkdirAll(destPath, info.Mode()); err != nil {
- log.Printf("error creating dir %s: %v", relPath, err)
+ logError("mkdir", relPath, err)
}
return
}
linkTarget := ""
if info.Mode()&os.ModeSymlink != 0 {
- linkTarget, _ = os.Readlink(srcPath)
+ linkTarget, err = os.Readlink(srcPath)
+ if err != nil {
+ logError("readlink", relPath, err)
+ return
+ }
}
meta := FileMeta{
@@ -133,7 +136,6 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
manifest.Files[relPath] = meta
if dryRun {
- log.Printf("[dry-run] would sync: %s", relPath)
return
}
@@ -142,13 +144,13 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
if linkTarget != "" {
if err := os.Symlink(linkTarget, destPath); err != nil {
- log.Printf("error creating symlink %s: %v", relPath, err)
+ logError("symlink", relPath, err)
}
return
}
- if err := copyFile(srcPath, destPath, info.Mode()); err != nil {
- log.Printf("error copying %s: %v", relPath, err)
+ if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil {
+ logError("copy", relPath, err)
}
}
@@ -158,7 +160,7 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
if os.IsNotExist(err) {
return
}
- log.Printf("error stat %s: %v", relPath, err)
+ logError("stat", relPath, err)
return
}
@@ -169,7 +171,11 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
linkTarget := ""
if info.Mode()&os.ModeSymlink != 0 {
- linkTarget, _ = os.Readlink(srcPath)
+ linkTarget, err = os.Readlink(srcPath)
+ if err != nil {
+ logError("readlink", relPath, err)
+ return
+ }
}
manifest.Files[relPath] = FileMeta{
@@ -180,7 +186,6 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
}
if dryRun {
- log.Printf("[dry-run] would update: %s", relPath)
return
}
@@ -188,13 +193,13 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
if linkTarget != "" {
if err := os.Symlink(linkTarget, destPath); err != nil {
- log.Printf("error creating symlink %s: %v", relPath, err)
+ logError("symlink", relPath, err)
}
return
}
- if err := copyFile(srcPath, destPath, info.Mode()); err != nil {
- log.Printf("error copying %s: %v", relPath, err)
+ if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil {
+ logError("copy", relPath, err)
}
}
@@ -202,11 +207,12 @@ func syncRemove(destPath, relPath string, manifest *Manifest, dryRun, verbose bo
delete(manifest.Files, relPath)
if dryRun {
- log.Printf("[dry-run] would remove: %s", relPath)
return
}
- os.Remove(destPath)
+ if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
+ logError("remove", relPath, err)
+ }
}
func addWatchersRecursively(watcher *fsnotify.Watcher, root string, stack *GitignoreStack) error {