Files
rclone/vfs/write_test.go
Nick Craig-Wood 2f0657c35b vfs: add WriteFileHandle.CloseWithError to abandon streaming writes
Closing a streaming write handle sends a clean EOF to the backend
upload, so a writer which knows its data source failed part way through
had no way to stop the truncated file being stored as if it were
complete.

CloseWithError closes the handle failing the upload with the given
reason instead. EOF-like reasons are rewritten so the layers reading
the pipe can't mistake them for a clean end of stream and commit the
partial file.

Needed by serve s3 to abort interrupted PUTs - see #9718
2026-08-11 20:58:48 +01:00

508 lines
14 KiB
Go

package vfs
import (
"context"
"errors"
"io"
"os"
"runtime"
"sync"
"testing"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/lib/random"
"github.com/rclone/rclone/vfs/vfscommon"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Open a file for write
func writeHandleCreate(t *testing.T) (r *fstest.Run, vfs *VFS, fh *WriteFileHandle) {
r, vfs = newTestVFS(t)
h, err := vfs.OpenFile("file1", os.O_WRONLY|os.O_CREATE, 0777)
require.NoError(t, err)
fh, ok := h.(*WriteFileHandle)
require.True(t, ok)
return r, vfs, fh
}
// Test write when underlying storage is readonly, must be run as non-root
func TestWriteFileHandleReadonly(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skipf("Skipping test on %s", runtime.GOOS)
}
if *fstest.RemoteName != "" {
t.Skip("Skipping test on non local remote")
}
r, vfs, fh := writeHandleCreate(t)
// Name
assert.Equal(t, "file1", fh.Name())
// Write a file, so underlying remote will be created
_, err := fh.Write([]byte("hello"))
assert.NoError(t, err)
err = fh.Close()
assert.NoError(t, err)
var info os.FileInfo
info, err = os.Stat(r.FremoteName)
assert.NoError(t, err)
// Remove write permission
oldMode := info.Mode()
err = os.Chmod(r.FremoteName, oldMode^(oldMode&0222))
assert.NoError(t, err)
var h Handle
h, err = vfs.OpenFile("file2", os.O_WRONLY|os.O_CREATE, 0777)
require.NoError(t, err)
var ok bool
fh, ok = h.(*WriteFileHandle)
require.True(t, ok)
// error is propagated to Close()
_, err = fh.Write([]byte("hello"))
assert.NoError(t, err)
err = fh.Close()
assert.NotNil(t, err)
// Remove should fail
err = vfs.Remove("file1")
assert.NotNil(t, err)
// Only file1 should exist
_, err = vfs.Stat("file1")
assert.NoError(t, err)
_, err = vfs.Stat("file2")
assert.Equal(t, true, errors.Is(err, os.ErrNotExist))
// Restore old permission
err = os.Chmod(r.FremoteName, oldMode)
assert.NoError(t, err)
}
func TestWriteFileHandleMethods(t *testing.T) {
r, vfs, fh := writeHandleCreate(t)
// String
assert.Equal(t, "file1 (w)", fh.String())
assert.Equal(t, "<nil *WriteFileHandle>", (*WriteFileHandle)(nil).String())
assert.Equal(t, "<nil *WriteFileHandle.file>", new(WriteFileHandle).String())
// Node
node := fh.Node()
assert.Equal(t, "file1", node.Name())
// Offset #1
assert.Equal(t, int64(0), fh.Offset())
assert.Equal(t, int64(0), node.Size())
// Write (smoke test only since heavy lifting done in WriteAt)
n, err := fh.Write([]byte("hello"))
assert.NoError(t, err)
assert.Equal(t, 5, n)
// Offset #2
assert.Equal(t, int64(5), fh.Offset())
assert.Equal(t, int64(5), node.Size())
// Stat
var fi os.FileInfo
fi, err = fh.Stat()
assert.NoError(t, err)
assert.Equal(t, int64(5), fi.Size())
assert.Equal(t, "file1", fi.Name())
// Read
var buf = make([]byte, 16)
_, err = fh.Read(buf)
assert.Equal(t, EPERM, err)
// ReadAt
_, err = fh.ReadAt(buf, 0)
assert.Equal(t, EPERM, err)
// Sync
err = fh.Sync()
assert.NoError(t, err)
// Truncate - can only truncate where the file pointer is
err = fh.Truncate(5)
assert.NoError(t, err)
err = fh.Truncate(6)
assert.Equal(t, EPERM, err)
// Close
assert.NoError(t, fh.Close())
// Check double close
err = fh.Close()
assert.Equal(t, ECLOSED, err)
// check vfs
root, err := vfs.Root()
require.NoError(t, err)
checkListing(t, root, []string{"file1,5,false"})
// check the underlying r.Fremote but not the modtime
file1 := fstest.NewItem("file1", "hello", t1)
fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file1}, []string{}, fs.ModTimeNotSupported)
// Check trying to open the file now it exists then closing it
// immediately is OK
h, err := vfs.OpenFile("file1", os.O_WRONLY|os.O_CREATE, 0777)
require.NoError(t, err)
assert.NoError(t, h.Close())
checkListing(t, root, []string{"file1,5,false"})
// Check trying to open the file and writing it now it exists
// returns an error
h, err = vfs.OpenFile("file1", os.O_WRONLY|os.O_CREATE, 0777)
require.NoError(t, err)
_, err = h.Write([]byte("hello1"))
require.Equal(t, EPERM, err)
assert.NoError(t, h.Close())
checkListing(t, root, []string{"file1,5,false"})
// Check opening the file with O_TRUNC does actually truncate
// it even if we don't write to it
h, err = vfs.OpenFile("file1", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777)
require.NoError(t, err)
err = h.Close()
if !errors.Is(err, fs.ErrorCantUploadEmptyFiles) {
assert.NoError(t, err)
checkListing(t, root, []string{"file1,0,false"})
}
// Check opening the file with O_TRUNC and writing does work
h, err = vfs.OpenFile("file1", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777)
require.NoError(t, err)
_, err = h.WriteString("hello12")
require.NoError(t, err)
assert.NoError(t, h.Close())
checkListing(t, root, []string{"file1,7,false"})
}
func TestWriteFileHandleWriteAt(t *testing.T) {
r, vfs, fh := writeHandleCreate(t)
// Preconditions
assert.Equal(t, int64(0), fh.offset)
assert.False(t, fh.writeCalled)
// Write the data
n, err := fh.WriteAt([]byte("hello"), 0)
assert.NoError(t, err)
assert.Equal(t, 5, n)
// After write
assert.Equal(t, int64(5), fh.offset)
assert.True(t, fh.writeCalled)
// Check can't seek
n, err = fh.WriteAt([]byte("hello"), 100)
assert.Equal(t, ESPIPE, err)
assert.Equal(t, 0, n)
// Write more data
n, err = fh.WriteAt([]byte(" world"), 5)
assert.NoError(t, err)
assert.Equal(t, 6, n)
// Close
assert.NoError(t, fh.Close())
// Check can't write on closed handle
n, err = fh.WriteAt([]byte("hello"), 0)
assert.Equal(t, ECLOSED, err)
assert.Equal(t, 0, n)
// check vfs
root, err := vfs.Root()
require.NoError(t, err)
checkListing(t, root, []string{"file1,11,false"})
// check the underlying r.Fremote but not the modtime
file1 := fstest.NewItem("file1", "hello world", t1)
fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file1}, []string{}, fs.ModTimeNotSupported)
}
func TestWriteFileHandleCloseWithError(t *testing.T) {
// io.ErrUnexpectedEOF must fail the upload like any other reason.
for _, reason := range []error{errors.New("upload interrupted"), io.ErrUnexpectedEOF} {
t.Run(reason.Error(), func(t *testing.T) {
r, vfs, fh := writeHandleCreate(t)
// Write some data then abandon the write mid-stream
n, err := fh.Write([]byte("hello"))
require.NoError(t, err)
assert.Equal(t, 5, n)
err = fh.CloseWithError(reason)
require.Error(t, err)
assert.ErrorContains(t, err, reason.Error())
// Check double close
assert.Equal(t, ECLOSED, fh.Close())
if *fstest.RemoteName == "" {
_, err = vfs.Stat("file1")
assert.Equal(t, ENOENT, err, "The abandoned file must not exist in the VFS or on the remote")
fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{}, fs.ModTimeNotSupported)
}
})
}
// CloseWithError with a nil reason behaves like Close and commits the file
t.Run("NilReason", func(t *testing.T) {
r, vfs := newTestVFS(t)
h, err := vfs.OpenFile("file2", os.O_WRONLY|os.O_CREATE, 0777)
require.NoError(t, err)
fh2, ok := h.(*WriteFileHandle)
require.True(t, ok)
_, err = fh2.Write([]byte("hello"))
require.NoError(t, err)
require.NoError(t, fh2.CloseWithError(nil))
file2 := fstest.NewItem("file2", "hello", t1)
fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file2}, []string{}, fs.ModTimeNotSupported)
})
}
func TestWriteFileHandleFlush(t *testing.T) {
_, vfs, fh := writeHandleCreate(t)
// Check Flush already creates file for unwritten handles, without closing it
err := fh.Flush()
assert.NoError(t, err)
assert.False(t, fh.closed)
root, err := vfs.Root()
assert.NoError(t, err)
checkListing(t, root, []string{"file1,0,false"})
// Write some data
n, err := fh.Write([]byte("hello"))
assert.NoError(t, err)
assert.Equal(t, 5, n)
// Check Flush closes file if write called
err = fh.Flush()
assert.NoError(t, err)
assert.True(t, fh.closed)
// Check flush does nothing if called again
err = fh.Flush()
assert.NoError(t, err)
assert.True(t, fh.closed)
// Check file was written properly
root, err = vfs.Root()
assert.NoError(t, err)
checkListing(t, root, []string{"file1,5,false"})
}
func TestWriteFileHandleRelease(t *testing.T) {
_, _, fh := writeHandleCreate(t)
// Check Release closes file
err := fh.Release()
if errors.Is(err, fs.ErrorCantUploadEmptyFiles) {
t.Logf("skipping test: %v", err)
return
}
assert.NoError(t, err)
assert.True(t, fh.closed)
// Check Release does nothing if called again
err = fh.Release()
assert.NoError(t, err)
assert.True(t, fh.closed)
}
var (
canSetModTimeOnce sync.Once
canSetModTimeValue = true
)
// returns whether the remote can set modtime
func canSetModTime(t *testing.T, r *fstest.Run) bool {
canSetModTimeOnce.Do(func() {
mtime1 := time.Date(2008, time.November, 18, 17, 32, 31, 0, time.UTC)
_ = r.WriteObject(context.Background(), "time_test", "stuff", mtime1)
obj, err := r.Fremote.NewObject(context.Background(), "time_test")
require.NoError(t, err)
mtime2 := time.Date(2009, time.November, 18, 17, 32, 31, 0, time.UTC)
err = obj.SetModTime(context.Background(), mtime2)
switch err {
case nil:
canSetModTimeValue = true
case fs.ErrorCantSetModTime, fs.ErrorCantSetModTimeWithoutDelete:
canSetModTimeValue = false
default:
require.NoError(t, err)
}
require.NoError(t, obj.Remove(context.Background()))
fs.Debugf(nil, "Can set mod time: %v", canSetModTimeValue)
})
return canSetModTimeValue
}
// tests mod time on open files
func TestWriteFileModTimeWithOpenWriters(t *testing.T) {
r, vfs, fh := writeHandleCreate(t)
if !canSetModTime(t, r) {
t.Skip("can't set mod time")
}
mtime := time.Date(2012, time.November, 18, 17, 32, 31, 0, time.UTC)
_, err := fh.Write([]byte{104, 105})
require.NoError(t, err)
err = fh.Node().SetModTime(mtime)
require.NoError(t, err)
err = fh.Close()
require.NoError(t, err)
info, err := vfs.Stat("file1")
require.NoError(t, err)
if r.Fremote.Precision() != fs.ModTimeNotSupported {
// avoid errors because of timezone differences
assert.Equal(t, info.ModTime().Unix(), mtime.Unix())
}
}
func testFileReadAt(t *testing.T, n int) {
_, vfs, fh := writeHandleCreate(t)
contents := []byte(random.String(n))
if n != 0 {
written, err := fh.Write(contents)
require.NoError(t, err)
assert.Equal(t, n, written)
}
// Close the file without writing to it if n==0
err := fh.Close()
if errors.Is(err, fs.ErrorCantUploadEmptyFiles) {
t.Logf("skipping test: %v", err)
return
}
assert.NoError(t, err)
// read the file back in using ReadAt into a buffer
// this simulates what mount does
rd, err := vfs.OpenFile("file1", os.O_RDONLY, 0)
require.NoError(t, err)
buf := make([]byte, 1024)
read, err := rd.ReadAt(buf, 0)
if err != io.EOF {
assert.NoError(t, err)
}
assert.Equal(t, read, n)
assert.Equal(t, contents, buf[:read])
err = rd.Close()
assert.NoError(t, err)
}
func TestFileReadAtZeroLength(t *testing.T) {
testFileReadAt(t, 0)
}
func TestFileReadAtNonZeroLength(t *testing.T) {
testFileReadAt(t, 100)
}
// stallingFs wraps an Fs so that streaming uploads accept a little data and
// then stall until their context is cancelled, like a remote that has
// stopped accepting data.
type stallingFs struct {
fs.Fs
stalled chan struct{} // closed once the upload has stalled
features *fs.Features
}
func newStallingFs(base fs.Fs) *stallingFs {
f := &stallingFs{Fs: base, stalled: make(chan struct{})}
features := *base.Features()
features.PutStream = f.PutStream
f.features = &features
return f
}
// Features returns the optional features of this Fs
func (f *stallingFs) Features() *fs.Features { return f.features }
// PutStream reads a little of in then stalls until ctx is cancelled.
func (f *stallingFs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
buf := make([]byte, 1024)
_, _ = in.Read(buf)
close(f.stalled)
<-ctx.Done()
return nil, ctx.Err()
}
// TestWriteFileHandleCloseWithErrorStalled checks that CloseWithError
// interrupts an upload the backend has stopped accepting data for. The
// writer is blocked in the pipe holding the handle's lock, so the abandon
// must fail the upload to unblock it, rather than waiting on the lock
// until the backend gives up of its own accord.
func TestWriteFileHandleCloseWithErrorStalled(t *testing.T) {
r := fstest.NewRun(t)
f := newStallingFs(r.Fremote)
// A tweaked option so this VFS is not deduplicated onto an active VFS
// of the unwrapped remote, whose config string it shares.
opt := vfscommon.Opt
opt.WriteWait += fs.Duration(time.Millisecond)
vfs := New(context.Background(), f, &opt)
t.Cleanup(vfs.Shutdown)
h, err := vfs.OpenFile("stalled", os.O_WRONLY|os.O_CREATE, 0777)
require.NoError(t, err)
fh, ok := h.(*WriteFileHandle)
require.True(t, ok)
// Write more than the transfer buffers absorb, so the write blocks on
// the stalled upload while holding the handle's lock.
writeDone := make(chan error, 1)
go func() {
_, err := fh.Write(make([]byte, 20<<20))
writeDone <- err
}()
select {
case <-f.stalled:
case <-time.After(10 * time.Second):
t.Fatal("upload never started")
}
// The abandon must interrupt the stalled upload promptly.
closeDone := make(chan error, 1)
go func() { closeDone <- fh.CloseWithError(errors.New("abandoned")) }()
select {
case err := <-closeDone:
require.Error(t, err)
case <-time.After(10 * time.Second):
t.Fatal("CloseWithError did not interrupt the stalled upload")
}
select {
case err := <-writeDone:
require.Error(t, err)
case <-time.After(10 * time.Second):
t.Fatal("the blocked write was never unblocked")
}
}