Browse Source

Stop folder when running out of disk space (fixes #2057)

& tweaks by calmh
Lode Hoste 10 years ago
parent
commit
dfaa999291

+ 4 - 0
Godeps/Godeps.json

@@ -9,6 +9,10 @@
 			"ImportPath": "github.com/bkaradzic/go-lz4",
 			"Rev": "4f7c2045dbd17b802370e2e6022200468abf02ba"
 		},
+		{
+			"ImportPath": "github.com/calmh/du",
+			"Rev": "3c0690cca16228b97741327b1b6781397afbdb24"
+		},
 		{
 			"ImportPath": "github.com/calmh/logger",
 			"Rev": "c96f6a1a8c7b6bf2f4860c667867d90174799eb2"

+ 24 - 0
Godeps/_workspace/src/github.com/calmh/du/LICENSE

@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org>

+ 14 - 0
Godeps/_workspace/src/github.com/calmh/du/README.md

@@ -0,0 +1,14 @@
+du
+==
+
+Get total and available disk space on a given volume.
+
+Documentation
+-------------
+
+http://godoc.org/github.com/calmh/du
+
+License
+-------
+
+Public Domain

+ 21 - 0
Godeps/_workspace/src/github.com/calmh/du/cmd/du/main.go

@@ -0,0 +1,21 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+
+	"github.com/calmh/du"
+)
+
+var KB = int64(1024)
+
+func main() {
+	usage, err := du.Get(os.Args[1])
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println("Free:", usage.FreeBytes/(KB*KB), "MiB")
+	fmt.Println("Available:", usage.AvailBytes/(KB*KB), "MiB")
+	fmt.Println("Size:", usage.TotalBytes/(KB*KB), "MiB")
+}

+ 8 - 0
Godeps/_workspace/src/github.com/calmh/du/diskusage.go

@@ -0,0 +1,8 @@
+package du
+
+// Usage holds information about total and available storage on a volume.
+type Usage struct {
+	TotalBytes int64 // Size of volume
+	FreeBytes  int64 // Unused size
+	AvailBytes int64 // Available to a non-privileged user
+}

+ 24 - 0
Godeps/_workspace/src/github.com/calmh/du/diskusage_posix.go

@@ -0,0 +1,24 @@
+// +build !windows,!netbsd,!openbsd,!solaris
+
+package du
+
+import (
+	"path/filepath"
+	"syscall"
+)
+
+// Get returns the Usage of a given path, or an error if usage data is
+// unavailable.
+func Get(path string) (Usage, error) {
+	var stat syscall.Statfs_t
+	err := syscall.Statfs(filepath.Clean(path), &stat)
+	if err != nil {
+		return Usage{}, err
+	}
+	u := Usage{
+		FreeBytes:  int64(stat.Bfree) * int64(stat.Bsize),
+		TotalBytes: int64(stat.Blocks) * int64(stat.Bsize),
+		AvailBytes: int64(stat.Bavail) * int64(stat.Bsize),
+	}
+	return u, nil
+}

+ 13 - 0
Godeps/_workspace/src/github.com/calmh/du/diskusage_unsupported.go

@@ -0,0 +1,13 @@
+// +build netbsd openbsd solaris
+
+package du
+
+import "errors"
+
+var ErrUnsupported = errors.New("unsupported platform")
+
+// Get returns the Usage of a given path, or an error if usage data is
+// unavailable.
+func Get(path string) (Usage, error) {
+	return Usage{}, ErrUnsupported
+}

+ 27 - 0
Godeps/_workspace/src/github.com/calmh/du/diskusage_windows.go

@@ -0,0 +1,27 @@
+package du
+
+import (
+	"syscall"
+	"unsafe"
+)
+
+// Get returns the Usage of a given path, or an error if usage data is
+// unavailable.
+func Get(path string) (Usage, error) {
+	h := syscall.MustLoadDLL("kernel32.dll")
+	c := h.MustFindProc("GetDiskFreeSpaceExW")
+
+	var u Usage
+
+	ret, _, err := c.Call(
+		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(path))),
+		uintptr(unsafe.Pointer(&u.FreeBytes)),
+		uintptr(unsafe.Pointer(&u.TotalBytes)),
+		uintptr(unsafe.Pointer(&u.AvailBytes)))
+
+	if ret == 0 {
+		return u, err
+	}
+
+	return u, nil
+}

+ 1 - 0
cmd/syncthing/main.go

@@ -837,6 +837,7 @@ func defaultConfig(myName string) config.Configuration {
 			ID:              "default",
 			RawPath:         locations[locDefFolder],
 			RescanIntervalS: 60,
+			MinDiskFreePct:  1,
 			Devices:         []config.FolderDeviceConfiguration{{DeviceID: myID}},
 		},
 	}

+ 13 - 0
internal/config/testdata/v11.xml

@@ -0,0 +1,13 @@
+<configuration version="11">
+    <folder id="test" path="testdata" ro="true" ignorePerms="false" rescanIntervalS="600" autoNormalize="true">
+        <device id="AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR"></device>
+        <device id="P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2"></device>
+        <minDiskFreePct>1</minDiskFreePct>
+    </folder>
+    <device id="AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR" name="node one" compression="metadata">
+        <address>a</address>
+    </device>
+    <device id="P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2" name="node two" compression="metadata">
+        <address>b</address>
+    </device>
+</configuration>

+ 13 - 1
lib/config/config.go

@@ -26,7 +26,7 @@ import (
 
 const (
 	OldestHandledVersion = 5
-	CurrentVersion       = 10
+	CurrentVersion       = 11
 	MaxRescanIntervalS   = 365 * 24 * 60 * 60
 )
 
@@ -74,6 +74,7 @@ type FolderConfiguration struct {
 	RescanIntervalS int                         `xml:"rescanIntervalS,attr" json:"rescanIntervalS"`
 	IgnorePerms     bool                        `xml:"ignorePerms,attr" json:"ignorePerms"`
 	AutoNormalize   bool                        `xml:"autoNormalize,attr" json:"autoNormalize"`
+	MinDiskFreePct  int                         `xml:"minDiskFreePct" json:"minDiskFreePct"`
 	Versioning      VersioningConfiguration     `xml:"versioning" json:"versioning"`
 	Copiers         int                         `xml:"copiers" json:"copiers"` // This defines how many files are handled concurrently.
 	Pullers         int                         `xml:"pullers" json:"pullers"` // Defines how many blocks are fetched at the same time, possibly between separate copier routines.
@@ -364,6 +365,9 @@ func (cfg *Configuration) prepare(myID protocol.DeviceID) {
 	if cfg.Version == 9 {
 		convertV9V10(cfg)
 	}
+	if cfg.Version == 10 {
+		convertV10V11(cfg)
+	}
 
 	// Hash old cleartext passwords
 	if len(cfg.GUI.Password) > 0 && cfg.GUI.Password[0] != '$' {
@@ -460,6 +464,14 @@ func ChangeRequiresRestart(from, to Configuration) bool {
 	return false
 }
 
+func convertV10V11(cfg *Configuration) {
+	// Set minimum disk free of existing folders to 1%
+	for i := range cfg.Folders {
+		cfg.Folders[i].MinDiskFreePct = 1
+	}
+	cfg.Version = 11
+}
+
 func convertV9V10(cfg *Configuration) {
 	// Enable auto normalization on existing folders.
 	for i := range cfg.Folders {

+ 1 - 0
lib/config/config_test.go

@@ -92,6 +92,7 @@ func TestDeviceConfig(t *testing.T) {
 				Pullers:         16,
 				Hashers:         0,
 				AutoNormalize:   true,
+				MinDiskFreePct:  1,
 			},
 		}
 		expectedDevices := []DeviceConfiguration{

+ 4 - 0
lib/config/wrapper.go

@@ -96,6 +96,10 @@ func Load(path string, myID protocol.DeviceID) (*Wrapper, error) {
 	return Wrap(path, cfg), nil
 }
 
+func (w *Wrapper) ConfigPath() string {
+	return w.path
+}
+
 // Stop stops the Serve() loop. Set and Replace operations will panic after a
 // Stop.
 func (w *Wrapper) Stop() {

+ 11 - 0
lib/model/model.go

@@ -45,6 +45,7 @@ const (
 	indexBatchSize         = 1000       // Either way, don't include more files than this
 	reqValidationTime      = time.Hour  // How long to cache validation entries for Request messages
 	reqValidationCacheSize = 1000       // How many entries to aim for in the validation cache size
+	minHomeDiskFreePct     = 1.0        // Stop when less space than this is available on the home (config & db) disk
 )
 
 type service interface {
@@ -1230,6 +1231,10 @@ func (m *Model) internalScanFolderSubs(folder string, subs []string) error {
 		return errors.New("no such folder")
 	}
 
+	if err := m.CheckFolderHealth(folder); err != nil {
+		return err
+	}
+
 	_ = ignores.Load(filepath.Join(folderCfg.Path(), ".stignore")) // Ignore error, there might not be an .stignore
 
 	// Required to make sure that we start indexing at a directory we're already
@@ -1658,6 +1663,10 @@ func (m *Model) BringToFront(folder, file string) {
 // CheckFolderHealth checks the folder for common errors and returns the
 // current folder error, or nil if the folder is healthy.
 func (m *Model) CheckFolderHealth(id string) error {
+	if free, err := osutil.DiskFreePercentage(m.cfg.ConfigPath()); err == nil && free < minHomeDiskFreePct {
+		return errors.New("out of disk space")
+	}
+
 	folder, ok := m.cfg.Folders()[id]
 	if !ok {
 		return errors.New("folder does not exist")
@@ -1673,6 +1682,8 @@ func (m *Model) CheckFolderHealth(id string) error {
 			err = errors.New("folder path missing")
 		} else if !folder.HasMarker() {
 			err = errors.New("folder marker missing")
+		} else if free, errDfp := osutil.DiskFreePercentage(folder.Path()); errDfp == nil && free < float64(folder.MinDiskFreePct) {
+			err = errors.New("out of disk space")
 		}
 	} else if os.IsNotExist(err) {
 		// If we don't have any files in the index, and the directory

+ 13 - 0
lib/model/rwfolder.go

@@ -437,6 +437,7 @@ func (p *rwFolder) pullerIteration(ignores *ignore.Matcher) int {
 	// !!!
 
 	changed := 0
+	pullFileSize := int64(0)
 
 	fileDeletions := map[string]protocol.FileInfo{}
 	dirDeletions := []protocol.FileInfo{}
@@ -485,6 +486,7 @@ func (p *rwFolder) pullerIteration(ignores *ignore.Matcher) int {
 		default:
 			// A new or changed file or symlink. This is the only case where we
 			// do stuff concurrently in the background
+			pullFileSize += file.Size()
 			p.queue.Push(file.Name, file.Size(), file.Modified)
 		}
 
@@ -492,6 +494,17 @@ func (p *rwFolder) pullerIteration(ignores *ignore.Matcher) int {
 		return true
 	})
 
+	// Check if we are able to store all files on disk
+	if pullFileSize > 0 {
+		folder, ok := p.model.cfg.Folders()[p.folder]
+		if ok {
+			if free, err := osutil.DiskFreeBytes(folder.Path()); err == nil && free < pullFileSize {
+				l.Infof("Puller (folder %q): insufficient disk space available to pull %d files (%.2fMB)", p.folder, changed, float64(pullFileSize)/1024/1024)
+				return 0
+			}
+		}
+	}
+
 	// Reorder the file queue according to configuration
 
 	switch p.order {

+ 11 - 0
lib/osutil/osutil.go

@@ -16,6 +16,7 @@ import (
 	"runtime"
 	"strings"
 
+	"github.com/calmh/du"
 	"github.com/syncthing/syncthing/lib/sync"
 )
 
@@ -210,3 +211,13 @@ func init() {
 func IsWindowsExecutable(path string) bool {
 	return execExts[strings.ToLower(filepath.Ext(path))]
 }
+
+func DiskFreeBytes(path string) (free int64, err error) {
+	u, err := du.Get(path)
+	return u.FreeBytes, err
+}
+
+func DiskFreePercentage(path string) (freePct float64, err error) {
+	u, err := du.Get(path)
+	return (float64(u.FreeBytes) / float64(u.TotalBytes)) * 100, err
+}

+ 15 - 0
lib/osutil/osutil_test.go

@@ -164,3 +164,18 @@ func TestInWritableDirWindowsRename(t *testing.T) {
 		}
 	}
 }
+
+func TestDiskUsage(t *testing.T) {
+	free, err := osutil.DiskFreePercentage(".")
+	if err != nil {
+		if runtime.GOOS == "netbsd" ||
+			runtime.GOOS == "openbsd" ||
+			runtime.GOOS == "solaris" {
+			t.Skip()
+		}
+		t.Errorf("Unexpected error: %s", err)
+	}
+	if free < 1 {
+		t.Error("Disk is full?", free)
+	}
+}