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.
This commit is contained in:
ferrumclaudepilgrim
2026-05-05 22:58:39 -05:00
committed by Nick Craig-Wood
parent 4c8bfb7500
commit 1bbe758bc5
2 changed files with 121 additions and 0 deletions

View File

@@ -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

View File

@@ -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")
}