fserrors: fix out of space detection on Windows - fixes #8011

IsErrNoSpace compared against syscall.ENOSPC. Go defines that constant on
Windows as a value in its application reserved range which no Windows API
returns, so the comparison could never be true there. A full disk on Windows
reports ERROR_DISK_FULL or ERROR_HANDLE_DISK_FULL instead.

Preallocation failures were still caught, because those return a separate
sentinel, but a disk that is already full fails at the directory creation or
at the open long before preallocation is reached. That is the case reported.

The errors are now held in a list which platform specific files add to in
their init, which is the shape retriable_errors already uses in this package,
and the comparison itself is unchanged. Windows appends the two codes that
lib/file already recognises when preallocation fails. Every other platform
keeps exactly the behaviour it had.

This also reaches the VFS cache, which uses the same helper and has no
preallocation path of its own, so its out of space handling has been inert
on Windows.
This commit is contained in:
ferrumclaudepilgrim
2026-09-08 16:35:36 +01:00
committed by Nick Craig-Wood
parent c875d89033
commit ca41db095b
4 changed files with 123 additions and 2 deletions
@@ -0,0 +1,37 @@
//go:build windows
package local
import (
"os"
"testing"
"github.com/rclone/rclone/fs/fserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func TestUpdateFatalIfNoSpaceWindows(t *testing.T) {
tests := []struct {
name string
err error
}{
{"ERROR_DISK_FULL", windows.ERROR_DISK_FULL},
{"openat PathError", &os.PathError{Op: "openat", Path: "test.txt", Err: windows.ERROR_DISK_FULL}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Run("off", func(t *testing.T) {
err := updateWithReader(t, false, test.err)
require.Error(t, err)
assert.False(t, fserrors.IsFatalError(err))
})
t.Run("on", func(t *testing.T) {
err := updateWithReader(t, true, test.err)
require.Error(t, err)
assert.True(t, fserrors.IsFatalError(err))
})
})
}
}
+10 -2
View File
@@ -3,16 +3,24 @@
package fserrors
import (
"slices"
"syscall"
liberrors "github.com/rclone/rclone/lib/errors"
)
// noSpaceErrors are the errors which mean the disk is full.
//
// Platform specific files add to this list in their init functions.
var noSpaceErrors = []error{
syscall.ENOSPC,
}
// IsErrNoSpace checks a possibly wrapped error to
// see if it contains a ENOSPC error
// see if it contains an out of space error.
func IsErrNoSpace(cause error) (isNoSpc bool) {
liberrors.Walk(cause, func(c error) bool {
if c == syscall.ENOSPC {
if slices.Contains(noSpaceErrors, c) {
isNoSpc = true
return true
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package fserrors
import (
"golang.org/x/sys/windows"
)
func init() {
// Windows does not return syscall.ENOSPC, which Go defines here as a
// value in its application reserved range. A full disk reports these.
// https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
noSpaceErrors = append(noSpaceErrors,
windows.ERROR_DISK_FULL,
windows.ERROR_HANDLE_DISK_FULL,
)
}
+59
View File
@@ -0,0 +1,59 @@
//go:build windows
package fserrors
import (
"os"
"path/filepath"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func TestIsErrNoSpaceRealWindowsError(t *testing.T) {
dir := t.TempDir()
dirp, err := windows.UTF16PtrFromString(dir)
if err != nil {
t.Skipf("cannot convert the temporary directory path: %v", err)
}
var available, total, free uint64
if err := windows.GetDiskFreeSpaceEx(dirp, &available, &total, &free); err != nil {
t.Skipf("cannot read the free space of the temporary directory: %v", err)
}
f, err := os.Create(filepath.Join(dir, "truncate"))
require.NoError(t, err)
defer func() { require.NoError(t, f.Close()) }()
err = f.Truncate(int64(free + 1<<30))
if err == nil {
t.Skip("real Windows disk-full error coverage lost: volume did not enforce the free-space limit when truncating the file")
}
assert.True(t, IsErrNoSpace(err), "error = %v", err)
}
func TestIsErrNoSpaceWindows(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"syscall.ENOSPC", syscall.ENOSPC, true},
{"ERROR_DISK_FULL", windows.ERROR_DISK_FULL, true},
{"ERROR_HANDLE_DISK_FULL", windows.ERROR_HANDLE_DISK_FULL, true},
{"openat PathError", &os.PathError{Op: "openat", Path: "file", Err: windows.ERROR_DISK_FULL}, true},
{"mkdirat PathError", &os.PathError{Op: "mkdirat", Path: "dir", Err: windows.ERROR_DISK_FULL}, true},
{"SyscallError", os.NewSyscallError("write", windows.ERROR_HANDLE_DISK_FULL), true},
{"ERROR_ACCESS_DENIED", windows.ERROR_ACCESS_DENIED, false},
{"access denied PathError", &os.PathError{Op: "openat", Path: "file", Err: windows.ERROR_ACCESS_DENIED}, false},
{"nil", nil, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, IsErrNoSpace(test.err))
})
}
}