diff --git a/backend/local/local.go b/backend/local/local.go index e5ad22ab9..2133279d0 100644 --- a/backend/local/local.go +++ b/backend/local/local.go @@ -280,6 +280,17 @@ enabled, rclone will no longer update the modtime after copying a file.`, Default: false, Advanced: true, }, + { + Name: "fatal_if_no_space", + Help: `Make out-of-space errors fatal during transfers. + +When enabled, an ENOSPC error during a write returns a fatal error so +that rclone aborts rather than retrying the operation. Useful for +backup scripts that should halt loudly on a full disk rather than spin +retrying.`, + Default: false, + Advanced: true, + }, { Name: "time_type", Help: `Set what kind of time is returned. @@ -349,6 +360,7 @@ type Options struct { NoPreAllocate bool `config:"no_preallocate"` NoSparse bool `config:"no_sparse"` NoSetModTime bool `config:"no_set_modtime"` + FatalIfNoSpace bool `config:"fatal_if_no_space"` TimeType timeType `config:"time_type"` Hashes fs.CommaSepList `config:"hashes"` Enc encoder.MultiEncoder `config:"encoding"` @@ -1410,8 +1422,19 @@ func (nwc nopWriterCloser) Close() error { return nil } +// isDiskFullError returns true if err indicates the underlying filesystem +// has run out of space. +func isDiskFullError(err error) bool { + return errors.Is(err, file.ErrDiskFull) || fserrors.IsErrNoSpace(err) +} + // Update the object from in with modTime and size func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) { + defer func() { + if err != nil && o.fs.opt.FatalIfNoSpace && isDiskFullError(err) { + err = fserrors.FatalError(err) + } + }() var out io.WriteCloser var hasher *hash.MultiHasher diff --git a/backend/local/local_internal_diskfull_test.go b/backend/local/local_internal_diskfull_test.go new file mode 100644 index 000000000..1c39cb031 --- /dev/null +++ b/backend/local/local_internal_diskfull_test.go @@ -0,0 +1,98 @@ +//go:build !plan9 + +// Tests for the FatalIfNoSpace option and isDiskFullError helper. +// +// Kept in a separate file with a !plan9 build tag because syscall.ENOSPC +// is not portable to plan9, mirroring the split in +// fs/fserrors/enospc_error.go vs fs/fserrors/enospc_error_notsupported.go. + +package local + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/rclone/rclone/fs/fserrors" + "github.com/rclone/rclone/fs/object" + "github.com/rclone/rclone/fstest" + "github.com/rclone/rclone/lib/file" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// errReader is an io.Reader that always returns the configured error, +// used to inject a synthetic disk-full failure into Update's io.Copy. +type errReader struct{ err error } + +func (r errReader) Read(p []byte) (int, error) { return 0, r.err } + +// TestIsDiskFullError covers the helper used by the Update defer. +func TestIsDiskFullError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"unrelated error", errors.New("unrelated error"), false}, + {"syscall.ENOSPC direct", syscall.ENOSPC, true}, + {"syscall.ENOSPC wrapped", fmt.Errorf("io.Copy: %w", syscall.ENOSPC), true}, + {"file.ErrDiskFull direct", file.ErrDiskFull, true}, + {"file.ErrDiskFull wrapped", fmt.Errorf("preallocate: %w", file.ErrDiskFull), true}, + {"os.PathError wrapping ENOSPC", &os.PathError{Op: "write", Path: "/foo", Err: syscall.ENOSPC}, true}, + {"os.SyscallError wrapping ENOSPC", os.NewSyscallError("write", syscall.ENOSPC), true}, + {"os.PathError wrapping unrelated", &os.PathError{Op: "write", Path: "/foo", Err: syscall.EPERM}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, isDiskFullError(c.err)) + }) + } +} + +// updateWithReader runs Update with an injected reader error and the given +// FatalIfNoSpace setting, returning the error from Update. +func updateWithReader(t *testing.T, fatalIfNoSpace bool, readerErr error) error { + t.Helper() + r := fstest.NewRun(t) + f := r.Flocal.(*Fs) + f.opt.FatalIfNoSpace = fatalIfNoSpace + + src := object.NewStaticObjectInfo("test.txt", time.Now(), 100, true, nil, f) + o := &Object{ + fs: f, + remote: "test.txt", + path: filepath.Join(r.LocalName, "test.txt"), + } + return o.Update(context.Background(), errReader{err: readerErr}, src) +} + +// TestUpdateFatalIfNoSpaceOff verifies an ENOSPC during a write is NOT +// wrapped as fatal when the option is off. +func TestUpdateFatalIfNoSpaceOff(t *testing.T) { + err := updateWithReader(t, false, syscall.ENOSPC) + require.Error(t, err) + assert.False(t, fserrors.IsFatalError(err), "ENOSPC must not be fatal when FatalIfNoSpace=false") +} + +// TestUpdateFatalIfNoSpaceOn verifies an ENOSPC during a write IS wrapped as +// fatal when the option is on. +func TestUpdateFatalIfNoSpaceOn(t *testing.T) { + err := updateWithReader(t, true, syscall.ENOSPC) + require.Error(t, err) + assert.True(t, fserrors.IsFatalError(err), "ENOSPC must be fatal when FatalIfNoSpace=true") +} + +// TestUpdateFatalIfNoSpaceOnButNotDiskFull verifies non-disk-full errors are +// NOT wrapped as fatal even when the option is on. +func TestUpdateFatalIfNoSpaceOnButNotDiskFull(t *testing.T) { + err := updateWithReader(t, true, errors.New("unrelated network error")) + require.Error(t, err) + assert.False(t, fserrors.IsFatalError(err), "non-disk-full errors must not be fatal regardless of option") +}