diff options
| author | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:58:06 +0200 |
|---|---|---|
| committer | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:58:06 +0200 |
| commit | 1043b58c164ac48757cdb373967ead2b34aa7388 (patch) | |
| tree | bc8286a404c17a5eec6a0de9b9adb1aa6201a413 | |
| parent | 5a2d70be3ce8524c366f4dd303b950974f05aa7c (diff) | |
| download | sourcewatch-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.go | 82 | ||||
| -rw-r--r-- | errors_test.go | 150 | ||||
| -rw-r--r-- | sync.go | 23 | ||||
| -rw-r--r-- | watcher.go | 40 |
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") + } +} @@ -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 @@ -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 { |
