From 1bbe758bc554a599a3af3ce43b125dc702a064ce Mon Sep 17 00:00:00 2001 From: ferrumclaudepilgrim Date: Tue, 5 May 2026 22:58:39 -0500 Subject: [PATCH] local: add --local-fatal-if-no-space flag - fixes #8011 When enabled, an out-of-space error during a local write returns a fatal error that aborts the run, instead of being retried. Without this option, ENOSPC errors are treated as retryable and rclone may spin through the retry loop many times on a full disk before giving up. That is fine for transient network errors but unhelpful when the disk is genuinely full and the operator wants the run to fail loudly. Default is off so existing behaviour is unchanged. Implementation follows the pattern suggested in the issue: a defer at the top of Update wraps the error with fserrors.FatalError when the option is on and the error is disk-full. Detection covers both file.ErrDiskFull from the preallocate path and syscall.ENOSPC from io.Copy or Close, via a small helper that uses fserrors.IsErrNoSpace. --- backend/local/local.go | 23 +++++ backend/local/local_internal_diskfull_test.go | 98 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 backend/local/local_internal_diskfull_test.go 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") +}