Selaa lähdekoodia

lib: Apply config changes sequentially (ref #5298) (#7188)

Simon Frei 4 vuotta sitten
vanhempi
sitoutus
f63cdbfcfa

+ 4 - 4
cmd/strelaysrv/main.go

@@ -186,10 +186,10 @@ func main() {
 	}
 
 	wrapper := config.Wrap("config", config.New(id), id, events.NoopLogger)
-	wrapper.SetOptions(config.OptionsConfiguration{
-		NATLeaseM:   natLease,
-		NATRenewalM: natRenewal,
-		NATTimeoutS: natTimeout,
+	wrapper.Modify(func(cfg *config.Configuration) {
+		cfg.Options.NATLeaseM = natLease
+		cfg.Options.NATRenewalM = natRenewal
+		cfg.Options.NATTimeoutS = natTimeout
 	})
 	natSvc := nat.NewService(id, wrapper)
 	mapping := mapping{natSvc.NewMapping(nat.TCP, addr.IP, addr.Port)}

+ 30 - 29
cmd/syncthing/main.go

@@ -607,7 +607,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	go evLogger.Serve(ctx)
 	defer cancel()
 
-	cfg, err := syncthing.LoadConfigAtStartup(locations.Get(locations.ConfigFile), cert, evLogger, runtimeOptions.allowNewerConfig, noDefaultFolder)
+	cfgWrapper, err := syncthing.LoadConfigAtStartup(locations.Get(locations.ConfigFile), cert, evLogger, runtimeOptions.allowNewerConfig, noDefaultFolder)
 	if err != nil {
 		l.Warnln("Failed to initialize config:", err)
 		os.Exit(svcutil.ExitError.AsInt())
@@ -618,20 +618,20 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	// environment variable is set.
 
 	if build.IsCandidate && !upgrade.DisabledByCompilation && !runtimeOptions.NoUpgrade {
-		l.Infoln("Automatic upgrade is always enabled for candidate releases.")
-		if opts := cfg.Options(); opts.AutoUpgradeIntervalH == 0 || opts.AutoUpgradeIntervalH > 24 {
-			opts.AutoUpgradeIntervalH = 12
-			// Set the option into the config as well, as the auto upgrade
-			// loop expects to read a valid interval from there.
-			cfg.SetOptions(opts)
-			cfg.Save()
-		}
-		// We don't tweak the user's choice of upgrading to pre-releases or
-		// not, as otherwise they cannot step off the candidate channel.
+		cfgWrapper.Modify(func(cfg *config.Configuration) {
+			l.Infoln("Automatic upgrade is always enabled for candidate releases.")
+			if cfg.Options.AutoUpgradeIntervalH == 0 || cfg.Options.AutoUpgradeIntervalH > 24 {
+				cfg.Options.AutoUpgradeIntervalH = 12
+				// Set the option into the config as well, as the auto upgrade
+				// loop expects to read a valid interval from there.
+			}
+			// We don't tweak the user's choice of upgrading to pre-releases or
+			// not, as otherwise they cannot step off the candidate channel.
+		})
 	}
 
 	dbFile := locations.Get(locations.Database)
-	ldb, err := syncthing.OpenDBBackend(dbFile, cfg.Options().DatabaseTuning)
+	ldb, err := syncthing.OpenDBBackend(dbFile, cfgWrapper.Options().DatabaseTuning)
 	if err != nil {
 		l.Warnln("Error opening database:", err)
 		os.Exit(1)
@@ -642,7 +642,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	// later after App is initialised.
 
 	autoUpgradePossible := autoUpgradePossible(runtimeOptions)
-	if autoUpgradePossible && cfg.Options().AutoUpgradeEnabled() {
+	if autoUpgradePossible && cfgWrapper.Options().AutoUpgradeEnabled() {
 		// try to do upgrade directly and log the error if relevant.
 		release, err := initialAutoUpgradeCheck(db.NewMiscDataNamespace(ldb))
 		if err == nil {
@@ -661,9 +661,9 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	}
 
 	if runtimeOptions.unpaused {
-		setPauseState(cfg, false)
+		setPauseState(cfgWrapper, false)
 	} else if runtimeOptions.paused {
-		setPauseState(cfg, true)
+		setPauseState(cfgWrapper, true)
 	}
 
 	appOpts := runtimeOptions.Options
@@ -681,14 +681,14 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 		appOpts.DBIndirectGCInterval = dur
 	}
 
-	app, err := syncthing.New(cfg, ldb, evLogger, cert, appOpts)
+	app, err := syncthing.New(cfgWrapper, ldb, evLogger, cert, appOpts)
 	if err != nil {
 		l.Warnln("Failed to start Syncthing:", err)
 		os.Exit(svcutil.ExitError.AsInt())
 	}
 
 	if autoUpgradePossible {
-		go autoUpgrade(cfg, app, evLogger)
+		go autoUpgrade(cfgWrapper, app, evLogger)
 	}
 
 	setupSignalHandling(app)
@@ -709,7 +709,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 		}
 	}
 
-	go standbyMonitor(app, cfg)
+	go standbyMonitor(app, cfgWrapper)
 
 	if err := app.Start(); err != nil {
 		os.Exit(svcutil.ExitError.AsInt())
@@ -717,10 +717,10 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 
 	cleanConfigDirectory()
 
-	if cfg.Options().StartBrowser && !runtimeOptions.noBrowser && !runtimeOptions.stRestarting {
+	if cfgWrapper.Options().StartBrowser && !runtimeOptions.noBrowser && !runtimeOptions.stRestarting {
 		// Can potentially block if the utility we are invoking doesn't
 		// fork, and just execs, hence keep it in its own routine.
-		go func() { _ = openURL(cfg.GUI().URL()) }()
+		go func() { _ = openURL(cfgWrapper.GUI().URL()) }()
 	}
 
 	status := app.Wait()
@@ -988,15 +988,16 @@ func showPaths(options RuntimeOptions) {
 	fmt.Printf("Default sync folder directory:\n\t%s\n\n", locations.Get(locations.DefFolder))
 }
 
-func setPauseState(cfg config.Wrapper, paused bool) {
-	raw := cfg.RawCopy()
-	for i := range raw.Devices {
-		raw.Devices[i].Paused = paused
-	}
-	for i := range raw.Folders {
-		raw.Folders[i].Paused = paused
-	}
-	if _, err := cfg.Replace(raw); err != nil {
+func setPauseState(cfgWrapper config.Wrapper, paused bool) {
+	_, err := cfgWrapper.Modify(func(cfg *config.Configuration) {
+		for i := range cfg.Devices {
+			cfg.Devices[i].Paused = paused
+		}
+		for i := range cfg.Folders {
+			cfg.Folders[i].Paused = paused
+		}
+	})
+	if err != nil {
 		l.Warnln("Cannot adjust paused state:", err)
 		os.Exit(svcutil.ExitError.AsInt())
 	}

+ 19 - 15
lib/api/api.go

@@ -294,7 +294,6 @@ func (s *service) Serve(ctx context.Context) error {
 		Router: restMux,
 		id:     s.id,
 		cfg:    s.cfg,
-		mut:    sync.NewMutex(),
 	}
 
 	configBuilder.registerConfig("/rest/config")
@@ -1402,31 +1401,36 @@ func (s *service) makeDevicePauseHandler(paused bool) http.HandlerFunc {
 		var qs = r.URL.Query()
 		var deviceStr = qs.Get("device")
 
-		var cfgs []config.DeviceConfiguration
-
-		if deviceStr == "" {
-			for _, cfg := range s.cfg.Devices() {
-				cfg.Paused = paused
-				cfgs = append(cfgs, cfg)
+		var msg string
+		var status int
+		_, err := s.cfg.Modify(func(cfg *config.Configuration) {
+			if deviceStr == "" {
+				for i := range cfg.Devices {
+					cfg.Devices[i].Paused = paused
+				}
+				return
 			}
-		} else {
+
 			device, err := protocol.DeviceIDFromString(deviceStr)
 			if err != nil {
-				http.Error(w, err.Error(), 500)
+				msg = err.Error()
+				status = 500
 				return
 			}
 
-			cfg, ok := s.cfg.Devices()[device]
+			_, i, ok := cfg.Device(device)
 			if !ok {
-				http.Error(w, "not found", http.StatusNotFound)
+				msg = "not found"
+				status = http.StatusNotFound
 				return
 			}
 
-			cfg.Paused = paused
-			cfgs = append(cfgs, cfg)
-		}
+			cfg.Devices[i].Paused = paused
+		})
 
-		if _, err := s.cfg.SetDevices(cfgs); err != nil {
+		if msg != "" {
+			http.Error(w, msg, status)
+		} else if err != nil {
 			http.Error(w, err.Error(), 500)
 		}
 	}

+ 5 - 0
lib/api/api_test.go

@@ -1253,6 +1253,11 @@ func TestConfigChanges(t *testing.T) {
 	defer os.Remove(tmpFile.Name())
 	w := config.Wrap(tmpFile.Name(), cfg, protocol.LocalDeviceID, events.NoopLogger)
 	tmpFile.Close()
+	if cfgService, ok := w.(suture.Service); ok {
+		cfgCtx, cfgCancel := context.WithCancel(context.Background())
+		go cfgService.Serve(cfgCtx)
+		defer cfgCancel()
+	}
 	baseURL, cancel, err := startHTTP(w)
 	if err != nil {
 		t.Fatal("Unexpected error from getting base URL:", err)

+ 51 - 59
lib/api/confighandler.go

@@ -17,14 +17,12 @@ import (
 
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type configMuxBuilder struct {
 	*httprouter.Router
 	id  protocol.DeviceID
 	cfg config.Wrapper
-	mut sync.Mutex
 }
 
 func (c *configMuxBuilder) registerConfig(path string) {
@@ -59,14 +57,14 @@ func (c *configMuxBuilder) registerFolders(path string) {
 	})
 
 	c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) {
-		c.mut.Lock()
-		defer c.mut.Unlock()
 		var folders []config.FolderConfiguration
 		if err := unmarshalTo(r.Body, &folders); err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
 		}
-		waiter, err := c.cfg.SetFolders(folders)
+		waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+			cfg.SetFolders(folders)
+		})
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
@@ -75,19 +73,7 @@ func (c *configMuxBuilder) registerFolders(path string) {
 	})
 
 	c.HandlerFunc(http.MethodPost, path, func(w http.ResponseWriter, r *http.Request) {
-		c.mut.Lock()
-		defer c.mut.Unlock()
-		var folder config.FolderConfiguration
-		if err := unmarshalTo(r.Body, &folder); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		waiter, err := c.cfg.SetFolder(folder)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		c.finish(w, waiter)
+		c.adjustFolder(w, r, config.FolderConfiguration{})
 	})
 }
 
@@ -97,14 +83,14 @@ func (c *configMuxBuilder) registerDevices(path string) {
 	})
 
 	c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) {
-		c.mut.Lock()
-		defer c.mut.Unlock()
 		var devices []config.DeviceConfiguration
 		if err := unmarshalTo(r.Body, &devices); err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
 		}
-		waiter, err := c.cfg.SetDevices(devices)
+		waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+			cfg.SetDevices(devices)
+		})
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
@@ -113,14 +99,14 @@ func (c *configMuxBuilder) registerDevices(path string) {
 	})
 
 	c.HandlerFunc(http.MethodPost, path, func(w http.ResponseWriter, r *http.Request) {
-		c.mut.Lock()
-		defer c.mut.Unlock()
 		var device config.DeviceConfiguration
 		if err := unmarshalTo(r.Body, &device); err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
 		}
-		waiter, err := c.cfg.SetDevice(device)
+		waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+			cfg.SetDevice(device)
+		})
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
@@ -153,8 +139,6 @@ func (c *configMuxBuilder) registerFolder(path string) {
 	})
 
 	c.Handle(http.MethodDelete, path, func(w http.ResponseWriter, _ *http.Request, p httprouter.Params) {
-		c.mut.Lock()
-		defer c.mut.Unlock()
 		waiter, err := c.cfg.RemoveFolder(p.ByName("id"))
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
@@ -196,8 +180,6 @@ func (c *configMuxBuilder) registerDevice(path string) {
 	})
 
 	c.Handle(http.MethodDelete, path, func(w http.ResponseWriter, _ *http.Request, p httprouter.Params) {
-		c.mut.Lock()
-		defer c.mut.Unlock()
 		id, err := protocol.DeviceIDFromString(p.ByName("id"))
 		waiter, err := c.cfg.RemoveDevice(id)
 		if err != nil {
@@ -251,22 +233,27 @@ func (c *configMuxBuilder) registerGUI(path string) {
 }
 
 func (c *configMuxBuilder) adjustConfig(w http.ResponseWriter, r *http.Request) {
-	c.mut.Lock()
-	defer c.mut.Unlock()
-	cfg, err := config.ReadJSON(r.Body, c.id)
+	to, err := config.ReadJSON(r.Body, c.id)
 	r.Body.Close()
 	if err != nil {
 		l.Warnln("Decoding posted config:", err)
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if cfg.GUI.Password, err = checkGUIPassword(c.cfg.GUI().Password, cfg.GUI.Password); err != nil {
-		l.Warnln("bcrypting password:", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	waiter, err := c.cfg.Replace(cfg)
-	if err != nil {
+	var errMsg string
+	var status int
+	waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+		if to.GUI.Password, err = checkGUIPassword(cfg.GUI.Password, to.GUI.Password); err != nil {
+			l.Warnln("bcrypting password:", err)
+			errMsg = err.Error()
+			status = http.StatusInternalServerError
+			return
+		}
+		*cfg = to
+	})
+	if errMsg != "" {
+		http.Error(w, errMsg, status)
+	} else if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -274,13 +261,13 @@ func (c *configMuxBuilder) adjustConfig(w http.ResponseWriter, r *http.Request)
 }
 
 func (c *configMuxBuilder) adjustFolder(w http.ResponseWriter, r *http.Request, folder config.FolderConfiguration) {
-	c.mut.Lock()
-	defer c.mut.Unlock()
 	if err := unmarshalTo(r.Body, &folder); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	waiter, err := c.cfg.SetFolder(folder)
+	waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+		cfg.SetFolder(folder)
+	})
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -289,13 +276,13 @@ func (c *configMuxBuilder) adjustFolder(w http.ResponseWriter, r *http.Request,
 }
 
 func (c *configMuxBuilder) adjustDevice(w http.ResponseWriter, r *http.Request, device config.DeviceConfiguration) {
-	c.mut.Lock()
-	defer c.mut.Unlock()
 	if err := unmarshalTo(r.Body, &device); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	waiter, err := c.cfg.SetDevice(device)
+	waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevice(device)
+	})
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -304,13 +291,13 @@ func (c *configMuxBuilder) adjustDevice(w http.ResponseWriter, r *http.Request,
 }
 
 func (c *configMuxBuilder) adjustOptions(w http.ResponseWriter, r *http.Request, opts config.OptionsConfiguration) {
-	c.mut.Lock()
-	defer c.mut.Unlock()
 	if err := unmarshalTo(r.Body, &opts); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	waiter, err := c.cfg.SetOptions(opts)
+	waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+		cfg.Options = opts
+	})
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -319,21 +306,26 @@ func (c *configMuxBuilder) adjustOptions(w http.ResponseWriter, r *http.Request,
 }
 
 func (c *configMuxBuilder) adjustGUI(w http.ResponseWriter, r *http.Request, gui config.GUIConfiguration) {
-	c.mut.Lock()
-	defer c.mut.Unlock()
 	oldPassword := gui.Password
 	err := unmarshalTo(r.Body, &gui)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if gui.Password, err = checkGUIPassword(oldPassword, gui.Password); err != nil {
-		l.Warnln("bcrypting password:", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	waiter, err := c.cfg.SetGUI(gui)
-	if err != nil {
+	var errMsg string
+	var status int
+	waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+		if gui.Password, err = checkGUIPassword(oldPassword, gui.Password); err != nil {
+			l.Warnln("bcrypting password:", err)
+			errMsg = err.Error()
+			status = http.StatusInternalServerError
+			return
+		}
+		cfg.GUI = gui
+	})
+	if errMsg != "" {
+		http.Error(w, errMsg, status)
+	} else if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -341,13 +333,13 @@ func (c *configMuxBuilder) adjustGUI(w http.ResponseWriter, r *http.Request, gui
 }
 
 func (c *configMuxBuilder) adjustLDAP(w http.ResponseWriter, r *http.Request, ldap config.LDAPConfiguration) {
-	c.mut.Lock()
-	defer c.mut.Unlock()
 	if err := unmarshalTo(r.Body, &ldap); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	waiter, err := c.cfg.SetLDAP(ldap)
+	waiter, err := c.cfg.Modify(func(cfg *config.Configuration) {
+		cfg.LDAP = ldap
+	})
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return

+ 4 - 30
lib/api/mocked_config_test.go

@@ -28,10 +28,6 @@ func (c *mockedConfig) LDAP() config.LDAPConfiguration {
 	return config.LDAPConfiguration{}
 }
 
-func (c *mockedConfig) SetLDAP(config.LDAPConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
 func (c *mockedConfig) RawCopy() config.Configuration {
 	cfg := config.Configuration{}
 	util.SetDefaults(&cfg.Options)
@@ -42,11 +38,13 @@ func (c *mockedConfig) Options() config.OptionsConfiguration {
 	return config.OptionsConfiguration{}
 }
 
-func (c *mockedConfig) Replace(cfg config.Configuration) (config.Waiter, error) {
+func (c *mockedConfig) Modify(config.ModifyFunction) (config.Waiter, error) {
 	return noopWaiter{}, nil
 }
 
-func (c *mockedConfig) Subscribe(cm config.Committer) {}
+func (c *mockedConfig) Subscribe(cm config.Committer) config.Configuration {
+	return config.Configuration{}
+}
 
 func (c *mockedConfig) Unsubscribe(cm config.Committer) {}
 
@@ -62,14 +60,6 @@ func (c *mockedConfig) DeviceList() []config.DeviceConfiguration {
 	return nil
 }
 
-func (c *mockedConfig) SetDevice(config.DeviceConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
-func (c *mockedConfig) SetDevices([]config.DeviceConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
 func (c *mockedConfig) Save() error {
 	return nil
 }
@@ -86,14 +76,6 @@ func (c *mockedConfig) ConfigPath() string {
 	return ""
 }
 
-func (c *mockedConfig) SetGUI(gui config.GUIConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
-func (c *mockedConfig) SetOptions(opts config.OptionsConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
 func (c *mockedConfig) Folder(id string) (config.FolderConfiguration, bool) {
 	return config.FolderConfiguration{}, false
 }
@@ -102,14 +84,6 @@ func (c *mockedConfig) FolderList() []config.FolderConfiguration {
 	return nil
 }
 
-func (c *mockedConfig) SetFolder(fld config.FolderConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
-func (c *mockedConfig) SetFolders(folders []config.FolderConfiguration) (config.Waiter, error) {
-	return noopWaiter{}, nil
-}
-
 func (c *mockedConfig) RemoveFolder(id string) (config.Waiter, error) {
 	return noopWaiter{}, nil
 }

+ 17 - 12
lib/config/commit_test.go

@@ -41,10 +41,20 @@ func (validationError) String() string {
 	return "validationError"
 }
 
-func TestReplaceCommit(t *testing.T) {
-	t.Skip("broken, fails randomly, #3834")
+func replace(t testing.TB, w Wrapper, to Configuration) {
+	t.Helper()
+	waiter, err := w.Modify(func(cfg *Configuration) {
+		*cfg = to
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
+}
 
+func TestReplaceCommit(t *testing.T) {
 	w := wrap("/dev/null", Configuration{Version: 0}, device1)
+	defer w.stop()
 	if w.RawCopy().Version != 0 {
 		t.Fatal("Config incorrect")
 	}
@@ -52,10 +62,7 @@ func TestReplaceCommit(t *testing.T) {
 	// Replace config. We should get back a clean response and the config
 	// should change.
 
-	_, err := w.Replace(Configuration{Version: 1})
-	if err != nil {
-		t.Fatal("Should not have a validation error:", err)
-	}
+	replace(t, w, Configuration{Version: 1})
 	if w.RequiresRestart() {
 		t.Fatal("Should not require restart")
 	}
@@ -69,11 +76,7 @@ func TestReplaceCommit(t *testing.T) {
 	sub0 := requiresRestart{committed: make(chan struct{}, 1)}
 	w.Subscribe(sub0)
 
-	_, err = w.Replace(Configuration{Version: 2})
-	if err != nil {
-		t.Fatal("Should not have a validation error:", err)
-	}
-
+	replace(t, w, Configuration{Version: 1})
 	<-sub0.committed
 	if !w.RequiresRestart() {
 		t.Fatal("Should require restart")
@@ -87,7 +90,9 @@ func TestReplaceCommit(t *testing.T) {
 
 	w.Subscribe(validationError{})
 
-	_, err = w.Replace(Configuration{Version: 3})
+	_, err := w.Modify(func(cfg *Configuration) {
+		*cfg = Configuration{Version: 3}
+	})
 	if err == nil {
 		t.Fatal("Should have a validation error")
 	}

+ 67 - 0
lib/config/config.go

@@ -372,6 +372,15 @@ func (cfg *Configuration) applyMigrations() {
 	migrationsMut.Unlock()
 }
 
+func (cfg *Configuration) Device(id protocol.DeviceID) (DeviceConfiguration, int, bool) {
+	for i, device := range cfg.Devices {
+		if device.DeviceID == id {
+			return device, i, true
+		}
+	}
+	return DeviceConfiguration{}, 0, false
+}
+
 // DeviceMap returns a map of device ID to device configuration for the given configuration.
 func (cfg *Configuration) DeviceMap() map[protocol.DeviceID]DeviceConfiguration {
 	m := make(map[protocol.DeviceID]DeviceConfiguration, len(cfg.Devices))
@@ -381,6 +390,44 @@ func (cfg *Configuration) DeviceMap() map[protocol.DeviceID]DeviceConfiguration
 	return m
 }
 
+func (cfg *Configuration) SetDevice(device DeviceConfiguration) {
+	cfg.SetDevices([]DeviceConfiguration{device})
+}
+
+func (cfg *Configuration) SetDevices(devices []DeviceConfiguration) {
+	inds := make(map[protocol.DeviceID]int, len(cfg.Devices))
+	for i, device := range cfg.Devices {
+		inds[device.DeviceID] = i
+	}
+	filtered := devices[:0]
+	for _, device := range devices {
+		if i, ok := inds[device.DeviceID]; ok {
+			cfg.Devices[i] = device
+		} else {
+			filtered = append(filtered, device)
+		}
+	}
+	cfg.Devices = append(cfg.Devices, filtered...)
+}
+
+func (cfg *Configuration) Folder(id string) (FolderConfiguration, int, bool) {
+	for i, folder := range cfg.Folders {
+		if folder.ID == id {
+			return folder, i, true
+		}
+	}
+	return FolderConfiguration{}, 0, false
+}
+
+// FolderMap returns a map of folder ID to folder configuration for the given configuration.
+func (cfg *Configuration) FolderMap() map[string]FolderConfiguration {
+	m := make(map[string]FolderConfiguration, len(cfg.Folders))
+	for _, folder := range cfg.Folders {
+		m[folder.ID] = folder
+	}
+	return m
+}
+
 // FolderPasswords returns the folder passwords set for this device, for
 // folders that have an encryption password set.
 func (cfg Configuration) FolderPasswords(device protocol.DeviceID) map[string]string {
@@ -397,6 +444,26 @@ nextFolder:
 	return res
 }
 
+func (cfg *Configuration) SetFolder(folder FolderConfiguration) {
+	cfg.SetFolders([]FolderConfiguration{folder})
+}
+
+func (cfg *Configuration) SetFolders(folders []FolderConfiguration) {
+	inds := make(map[string]int, len(cfg.Folders))
+	for i, folder := range cfg.Folders {
+		inds[folder.ID] = i
+	}
+	filtered := folders[:0]
+	for _, folder := range folders {
+		if i, ok := inds[folder.ID]; ok {
+			cfg.Folders[i] = folder
+		} else {
+			filtered = append(filtered, folder)
+		}
+	}
+	cfg.Folders = append(cfg.Folders, filtered...)
+}
+
 func ensureDevicePresent(devices []FolderDeviceConfiguration, myID protocol.DeviceID) []FolderDeviceConfiguration {
 	for _, device := range devices {
 		if device.DeviceID.Equals(myID) {

+ 121 - 32
lib/config/config_test.go

@@ -8,9 +8,11 @@ package config
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"encoding/xml"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -21,6 +23,7 @@ import (
 	"testing"
 
 	"github.com/d4l3k/messagediff"
+	"github.com/thejerf/suture/v4"
 
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
@@ -95,7 +98,8 @@ func TestDeviceConfig(t *testing.T) {
 		}
 
 		os.RemoveAll(filepath.Join("testdata", DefaultMarkerName))
-		wr, err := load(cfgFile, device1)
+		wr, wrCancel, err := copyAndLoad(cfgFile, device1)
+		defer wrCancel()
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -107,7 +111,7 @@ func TestDeviceConfig(t *testing.T) {
 			t.Fatal("Unexpected file")
 		}
 
-		cfg := wr.(*wrapper).cfg
+		cfg := wr.Wrapper.(*wrapper).cfg
 
 		expectedFolders := []FolderConfiguration{
 			{
@@ -170,7 +174,8 @@ func TestDeviceConfig(t *testing.T) {
 }
 
 func TestNoListenAddresses(t *testing.T) {
-	cfg, err := load("testdata/nolistenaddress.xml", device1)
+	cfg, cfgCancel, err := copyAndLoad("testdata/nolistenaddress.xml", device1)
+	defer cfgCancel()
 	if err != nil {
 		t.Error(err)
 	}
@@ -228,7 +233,8 @@ func TestOverriddenValues(t *testing.T) {
 	}
 
 	os.Unsetenv("STNOUPGRADE")
-	cfg, err := load("testdata/overridenvalues.xml", device1)
+	cfg, cfgCancel, err := copyAndLoad("testdata/overridenvalues.xml", device1)
+	defer cfgCancel()
 	if err != nil {
 		t.Error(err)
 	}
@@ -269,7 +275,8 @@ func TestDeviceAddressesDynamic(t *testing.T) {
 		},
 	}
 
-	cfg, err := load("testdata/deviceaddressesdynamic.xml", device4)
+	cfg, cfgCancel, err := copyAndLoad("testdata/deviceaddressesdynamic.xml", device4)
+	defer cfgCancel()
 	if err != nil {
 		t.Error(err)
 	}
@@ -314,7 +321,8 @@ func TestDeviceCompression(t *testing.T) {
 		},
 	}
 
-	cfg, err := load("testdata/devicecompression.xml", device4)
+	cfg, cfgCancel, err := copyAndLoad("testdata/devicecompression.xml", device4)
+	defer cfgCancel()
 	if err != nil {
 		t.Error(err)
 	}
@@ -356,7 +364,8 @@ func TestDeviceAddressesStatic(t *testing.T) {
 		},
 	}
 
-	cfg, err := load("testdata/deviceaddressesstatic.xml", device4)
+	cfg, cfgCancel, err := copyAndLoad("testdata/deviceaddressesstatic.xml", device4)
+	defer cfgCancel()
 	if err != nil {
 		t.Error(err)
 	}
@@ -368,7 +377,8 @@ func TestDeviceAddressesStatic(t *testing.T) {
 }
 
 func TestVersioningConfig(t *testing.T) {
-	cfg, err := load("testdata/versioningconfig.xml", device4)
+	cfg, cfgCancel, err := copyAndLoad("testdata/versioningconfig.xml", device4)
+	defer cfgCancel()
 	if err != nil {
 		t.Error(err)
 	}
@@ -395,7 +405,8 @@ func TestIssue1262(t *testing.T) {
 		t.Skipf("path gets converted to absolute as part of the filesystem initialization on linux")
 	}
 
-	cfg, err := load("testdata/issue-1262.xml", device4)
+	cfg, cfgCancel, err := copyAndLoad("testdata/issue-1262.xml", device4)
+	defer cfgCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -409,7 +420,8 @@ func TestIssue1262(t *testing.T) {
 }
 
 func TestIssue1750(t *testing.T) {
-	cfg, err := load("testdata/issue-1750.xml", device4)
+	cfg, cfgCancel, err := copyAndLoad("testdata/issue-1750.xml", device4)
+	defer cfgCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -505,6 +517,7 @@ func TestFolderCheckPath(t *testing.T) {
 func TestNewSaveLoad(t *testing.T) {
 	path := "testdata/temp.xml"
 	os.Remove(path)
+	defer os.Remove(path)
 
 	exists := func(path string) bool {
 		_, err := os.Stat(path)
@@ -513,6 +526,7 @@ func TestNewSaveLoad(t *testing.T) {
 
 	intCfg := New(device1)
 	cfg := wrap(path, intCfg, device1)
+	defer cfg.stop()
 
 	if exists(path) {
 		t.Error(path, "exists")
@@ -527,6 +541,7 @@ func TestNewSaveLoad(t *testing.T) {
 	}
 
 	cfg2, err := load(path, device1)
+	defer cfg2.stop()
 	if err != nil {
 		t.Error(err)
 	}
@@ -534,8 +549,6 @@ func TestNewSaveLoad(t *testing.T) {
 	if diff, equal := messagediff.PrettyDiff(cfg.RawCopy(), cfg2.RawCopy()); !equal {
 		t.Errorf("Configs are not equal. Diff:\n%s", diff)
 	}
-
-	os.Remove(path)
 }
 
 func TestPrepare(t *testing.T) {
@@ -553,7 +566,8 @@ func TestPrepare(t *testing.T) {
 }
 
 func TestCopy(t *testing.T) {
-	wrapper, err := load("testdata/example.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/example.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -592,7 +606,8 @@ func TestCopy(t *testing.T) {
 }
 
 func TestPullOrder(t *testing.T) {
-	wrapper, err := load("testdata/pullorder.xml", device1)
+	wrapper, wrapperCleanup, err := copyAndLoad("testdata/pullorder.xml", device1)
+	defer wrapperCleanup()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -632,8 +647,9 @@ func TestPullOrder(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	wrapper = wrap("testdata/pullorder.xml", cfg, device1)
-	folders = wrapper.Folders()
+	wrapper2 := wrap(wrapper.ConfigPath(), cfg, device1)
+	defer wrapper2.stop()
+	folders = wrapper2.Folders()
 
 	for _, tc := range expected {
 		if actual := folders[tc.name].Order; actual != tc.order {
@@ -643,7 +659,8 @@ func TestPullOrder(t *testing.T) {
 }
 
 func TestLargeRescanInterval(t *testing.T) {
-	wrapper, err := load("testdata/largeinterval.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/largeinterval.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -681,7 +698,8 @@ func TestGUIConfigURL(t *testing.T) {
 func TestDuplicateDevices(t *testing.T) {
 	// Duplicate devices should be removed
 
-	wrapper, err := load("testdata/dupdevices.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/dupdevices.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -699,7 +717,8 @@ func TestDuplicateDevices(t *testing.T) {
 func TestDuplicateFolders(t *testing.T) {
 	// Duplicate folders are a loading error
 
-	_, err := load("testdata/dupfolders.xml", device1)
+	_, _Cancel, err := copyAndLoad("testdata/dupfolders.xml", device1)
+	defer _Cancel()
 	if err == nil || !strings.Contains(err.Error(), errFolderIDDuplicate.Error()) {
 		t.Fatal(`Expected error to mention "duplicate folder ID":`, err)
 	}
@@ -710,7 +729,8 @@ func TestEmptyFolderPaths(t *testing.T) {
 	// get messed up by the prepare steps (e.g., become the current dir or
 	// get a slash added so that it becomes the root directory or similar).
 
-	_, err := load("testdata/nopath.xml", device1)
+	_, _Cancel, err := copyAndLoad("testdata/nopath.xml", device1)
+	defer _Cancel()
 	if err == nil || !strings.Contains(err.Error(), errFolderPathEmpty.Error()) {
 		t.Fatal("Expected error due to empty folder path, got", err)
 	}
@@ -779,7 +799,8 @@ func TestIgnoredDevices(t *testing.T) {
 	// Verify that ignored devices that are also present in the
 	// configuration are not in fact ignored.
 
-	wrapper, err := load("testdata/ignoreddevices.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/ignoreddevices.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -797,7 +818,8 @@ func TestIgnoredFolders(t *testing.T) {
 	// configuration are not in fact ignored.
 	// Also, verify that folders that are shared with a device are not ignored.
 
-	wrapper, err := load("testdata/ignoredfolders.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/ignoredfolders.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -833,7 +855,8 @@ func TestIgnoredFolders(t *testing.T) {
 func TestGetDevice(t *testing.T) {
 	// Verify that the Device() call does the right thing
 
-	wrapper, err := load("testdata/ignoreddevices.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/ignoreddevices.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -860,7 +883,8 @@ func TestGetDevice(t *testing.T) {
 }
 
 func TestSharesRemovedOnDeviceRemoval(t *testing.T) {
-	wrapper, err := load("testdata/example.xml", device1)
+	wrapper, wrapperCancel, err := copyAndLoad("testdata/example.xml", device1)
+	defer wrapperCancel()
 	if err != nil {
 		t.Errorf("Failed: %s", err)
 	}
@@ -872,10 +896,7 @@ func TestSharesRemovedOnDeviceRemoval(t *testing.T) {
 		t.Error("Should have less devices")
 	}
 
-	_, err = wrapper.Replace(raw)
-	if err != nil {
-		t.Errorf("Failed: %s", err)
-	}
+	replace(t, wrapper, raw)
 
 	raw = wrapper.RawCopy()
 	if len(raw.Folders[0].Devices) > len(raw.Devices) {
@@ -947,6 +968,7 @@ func TestIssue4219(t *testing.T) {
 	}
 
 	w := wrap("/tmp/cfg", cfg, myID)
+	defer w.stop()
 	if !w.IgnoredFolder(device2, "t1") {
 		t.Error("Folder device2 t1 should be ignored")
 	}
@@ -1157,13 +1179,80 @@ func defaultConfigAsMap() map[string]interface{} {
 	return tmp
 }
 
-func load(path string, myID protocol.DeviceID) (Wrapper, error) {
+func copyToTmp(path string) (string, error) {
+	orig, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer orig.Close()
+	temp, err := ioutil.TempFile("", "syncthing-configTest-")
+	if err != nil {
+		return "", err
+	}
+	defer temp.Close()
+	if _, err := io.Copy(temp, orig); err != nil {
+		return "", err
+	}
+	return temp.Name(), nil
+}
+
+func copyAndLoad(path string, myID protocol.DeviceID) (*testWrapper, func(), error) {
+	temp, err := copyToTmp(path)
+	if err != nil {
+		return nil, func() {}, err
+	}
+	wrapper, err := load(temp, myID)
+	if err != nil {
+		return nil, func() {}, err
+	}
+	return wrapper, func() {
+		wrapper.stop()
+		os.Remove(temp)
+	}, nil
+}
+
+func load(path string, myID protocol.DeviceID) (*testWrapper, error) {
 	cfg, _, err := Load(path, myID, events.NoopLogger)
-	return cfg, err
+	if err != nil {
+		return nil, err
+	}
+	return startWrapper(cfg), nil
+}
+
+func wrap(path string, cfg Configuration, myID protocol.DeviceID) *testWrapper {
+	wrapper := Wrap(path, cfg, myID, events.NoopLogger)
+	return startWrapper(wrapper)
+}
+
+type testWrapper struct {
+	Wrapper
+	cancel context.CancelFunc
+	done   chan struct{}
 }
 
-func wrap(path string, cfg Configuration, myID protocol.DeviceID) Wrapper {
-	return Wrap(path, cfg, myID, events.NoopLogger)
+func (w *testWrapper) stop() {
+	w.cancel()
+	<-w.done
+}
+
+func startWrapper(wrapper Wrapper) *testWrapper {
+	tw := &testWrapper{
+		Wrapper: wrapper,
+		done:    make(chan struct{}),
+	}
+	s, ok := wrapper.(suture.Service)
+	if !ok {
+		tw.cancel = func() {}
+		close(tw.done)
+		return tw
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	tw.cancel = cancel
+	go func() {
+		s.Serve(ctx)
+		close(tw.done)
+	}()
+	return tw
 }
 
 func TestInternalVersioningConfiguration(t *testing.T) {

+ 147 - 144
lib/config/wrapper.go

@@ -7,8 +7,12 @@
 package config
 
 import (
+	"context"
+	"errors"
 	"os"
+	"reflect"
 	"sync/atomic"
+	"time"
 
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/osutil"
@@ -16,6 +20,13 @@ import (
 	"github.com/syncthing/syncthing/lib/sync"
 )
 
+const (
+	maxModifications = 1000
+	minSaveInterval  = 5 * time.Second
+)
+
+var errTooManyModifications = errors.New("too many concurrent config modifications")
+
 // The Committer interface is implemented by objects that need to know about
 // or have a say in configuration changes.
 //
@@ -35,6 +46,10 @@ import (
 // false will result in a "restart needed" response to the API/user. Note that
 // the new configuration will still have been applied by those who were
 // capable of doing so.
+//
+// A Committer must take care not to hold any locks while changing the
+// configuration (e.g. calling Wrapper.SetFolder), that are also acquired in any
+// methods of the Committer interface.
 type Committer interface {
 	VerifyConfiguration(from, to Configuration) error
 	CommitConfiguration(from, to Configuration) (handled bool)
@@ -50,45 +65,47 @@ type noopWaiter struct{}
 
 func (noopWaiter) Wait() {}
 
-// A Wrapper around a Configuration that manages loads, saves and published
-// notifications of changes to registered Handlers
+// ModifyFunction gets a pointer to a copy of the currently active configuration
+// for modification.
+type ModifyFunction func(*Configuration)
+
+// Wrapper handles a Configuration, i.e. it provides methods to access, change
+// and save the config, and notifies registered subscribers (Committer) of
+// changes.
+//
+// Modify allows changing the currently active configuration through the given
+// ModifyFunction. It can be called concurrently: All calls will be queued and
+// called in order.
 type Wrapper interface {
 	ConfigPath() string
 	MyID() protocol.DeviceID
 
 	RawCopy() Configuration
-	Replace(cfg Configuration) (Waiter, error)
 	RequiresRestart() bool
 	Save() error
 
+	Modify(ModifyFunction) (Waiter, error)
+	RemoveFolder(id string) (Waiter, error)
+	RemoveDevice(id protocol.DeviceID) (Waiter, error)
+
 	GUI() GUIConfiguration
-	SetGUI(gui GUIConfiguration) (Waiter, error)
 	LDAP() LDAPConfiguration
-	SetLDAP(ldap LDAPConfiguration) (Waiter, error)
-
 	Options() OptionsConfiguration
-	SetOptions(opts OptionsConfiguration) (Waiter, error)
 
 	Folder(id string) (FolderConfiguration, bool)
 	Folders() map[string]FolderConfiguration
 	FolderList() []FolderConfiguration
-	RemoveFolder(id string) (Waiter, error)
-	SetFolder(fld FolderConfiguration) (Waiter, error)
-	SetFolders(folders []FolderConfiguration) (Waiter, error)
 	FolderPasswords(device protocol.DeviceID) map[string]string
 
 	Device(id protocol.DeviceID) (DeviceConfiguration, bool)
 	Devices() map[protocol.DeviceID]DeviceConfiguration
 	DeviceList() []DeviceConfiguration
-	RemoveDevice(id protocol.DeviceID) (Waiter, error)
-	SetDevice(DeviceConfiguration) (Waiter, error)
-	SetDevices([]DeviceConfiguration) (Waiter, error)
 
 	IgnoredDevices() []ObservedDevice
 	IgnoredDevice(id protocol.DeviceID) bool
 	IgnoredFolder(device protocol.DeviceID, folder string) bool
 
-	Subscribe(c Committer)
+	Subscribe(c Committer) Configuration
 	Unsubscribe(c Committer)
 }
 
@@ -97,6 +114,7 @@ type wrapper struct {
 	path     string
 	evLogger events.Logger
 	myID     protocol.DeviceID
+	queue    chan modifyEntry
 
 	waiter Waiter // Latest ongoing config change
 	subs   []Committer
@@ -107,12 +125,15 @@ type wrapper struct {
 
 // Wrap wraps an existing Configuration structure and ties it to a file on
 // disk.
+// The returned Wrapper is a suture.Service, thus needs to be started (added to
+// a supervisor).
 func Wrap(path string, cfg Configuration, myID protocol.DeviceID, evLogger events.Logger) Wrapper {
 	w := &wrapper{
 		cfg:      cfg,
 		path:     path,
 		evLogger: evLogger,
 		myID:     myID,
+		queue:    make(chan modifyEntry, maxModifications),
 		waiter:   noopWaiter{}, // Noop until first config change
 		mut:      sync.NewMutex(),
 	}
@@ -121,6 +142,8 @@ func Wrap(path string, cfg Configuration, myID protocol.DeviceID, evLogger event
 
 // Load loads an existing file on disk and returns a new configuration
 // wrapper.
+// The returned Wrapper is a suture.Service, thus needs to be started (added to
+// a supervisor).
 func Load(path string, myID protocol.DeviceID, evLogger events.Logger) (Wrapper, int, error) {
 	fd, err := os.Open(path)
 	if err != nil {
@@ -145,11 +168,13 @@ func (w *wrapper) MyID() protocol.DeviceID {
 }
 
 // Subscribe registers the given handler to be called on any future
-// configuration changes.
-func (w *wrapper) Subscribe(c Committer) {
+// configuration changes. It returns the config that is in effect while
+// subscribing, that can be used for initial setup.
+func (w *wrapper) Subscribe(c Committer) Configuration {
 	w.mut.Lock()
+	defer w.mut.Unlock()
 	w.subs = append(w.subs, c)
-	w.mut.Unlock()
+	return w.cfg.Copy()
 }
 
 // Unsubscribe de-registers the given handler from any future calls to
@@ -179,11 +204,84 @@ func (w *wrapper) RawCopy() Configuration {
 	return w.cfg.Copy()
 }
 
-// Replace swaps the current configuration object for the given one.
-func (w *wrapper) Replace(cfg Configuration) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-	return w.replaceLocked(cfg.Copy())
+func (w *wrapper) Modify(fn ModifyFunction) (Waiter, error) {
+	return w.modifyQueued(fn)
+}
+
+func (w *wrapper) modifyQueued(modifyFunc ModifyFunction) (Waiter, error) {
+	e := modifyEntry{
+		modifyFunc: modifyFunc,
+		res:        make(chan modifyResult),
+	}
+	select {
+	case w.queue <- e:
+	default:
+		return noopWaiter{}, errTooManyModifications
+	}
+	res := <-e.res
+	return res.w, res.err
+}
+
+func (w *wrapper) Serve(ctx context.Context) error {
+	defer w.serveSave()
+
+	var e modifyEntry
+	saveTimer := time.NewTimer(0)
+	<-saveTimer.C
+	saveTimerRunning := false
+	for {
+		select {
+		case e = <-w.queue:
+		case <-saveTimer.C:
+			w.serveSave()
+			saveTimerRunning = false
+			continue
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+
+		var waiter Waiter = noopWaiter{}
+		var err error
+
+		// Let the caller modify the config.
+		to := w.RawCopy()
+		e.modifyFunc(&to)
+
+		// Check if the config was actually changed at all.
+		w.mut.Lock()
+		if !reflect.DeepEqual(w.cfg, to) {
+			waiter, err = w.replaceLocked(to)
+			if !saveTimerRunning {
+				saveTimer.Reset(minSaveInterval)
+				saveTimerRunning = true
+			}
+		}
+		w.mut.Unlock()
+
+		e.res <- modifyResult{
+			w:   waiter,
+			err: err,
+		}
+
+		// Wait for all subscriber to handle the config change before continuing
+		// to process the next change.
+		done := make(chan struct{})
+		go func() {
+			waiter.Wait()
+			close(done)
+		}()
+		select {
+		case <-done:
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}
+
+func (w *wrapper) serveSave() {
+	if err := w.Save(); err != nil {
+		l.Warnln("Failed to save config:", err)
+	}
 }
 
 func (w *wrapper) replaceLocked(to Configuration) (Waiter, error) {
@@ -246,55 +344,16 @@ func (w *wrapper) DeviceList() []DeviceConfiguration {
 	return w.cfg.Copy().Devices
 }
 
-// SetDevices adds new devices to the configuration, or overwrites existing
-// devices with the same ID.
-func (w *wrapper) SetDevices(devs []DeviceConfiguration) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-
-	newCfg := w.cfg.Copy()
-	var replaced bool
-	for oldIndex := range devs {
-		replaced = false
-		for newIndex := range newCfg.Devices {
-			if newCfg.Devices[newIndex].DeviceID == devs[oldIndex].DeviceID {
-				newCfg.Devices[newIndex] = devs[oldIndex].Copy()
-				replaced = true
-				break
-			}
-		}
-		if !replaced {
-			newCfg.Devices = append(newCfg.Devices, devs[oldIndex].Copy())
-		}
-	}
-
-	return w.replaceLocked(newCfg)
-}
-
-// SetDevice adds a new device to the configuration, or overwrites an existing
-// device with the same ID.
-func (w *wrapper) SetDevice(dev DeviceConfiguration) (Waiter, error) {
-	return w.SetDevices([]DeviceConfiguration{dev})
-}
-
 // RemoveDevice removes the device from the configuration
 func (w *wrapper) RemoveDevice(id protocol.DeviceID) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-
-	newCfg := w.cfg.Copy()
-	for i := range newCfg.Devices {
-		if newCfg.Devices[i].DeviceID == id {
-			newCfg.Devices = append(newCfg.Devices[:i], newCfg.Devices[i+1:]...)
-			return w.replaceLocked(newCfg)
+	return w.modifyQueued(func(cfg *Configuration) {
+		if _, i, ok := cfg.Device(id); ok {
+			cfg.Devices = append(cfg.Devices[:i], cfg.Devices[i+1:]...)
 		}
-	}
-
-	return noopWaiter{}, nil
+	})
 }
 
-// Folders returns a map of folders. Folder structures should not be changed,
-// other than for the purpose of updating via SetFolder().
+// Folders returns a map of folders.
 func (w *wrapper) Folders() map[string]FolderConfiguration {
 	w.mut.Lock()
 	defer w.mut.Unlock()
@@ -312,51 +371,13 @@ func (w *wrapper) FolderList() []FolderConfiguration {
 	return w.cfg.Copy().Folders
 }
 
-// SetFolder adds a new folder to the configuration, or overwrites an existing
-// folder with the same ID.
-func (w *wrapper) SetFolder(fld FolderConfiguration) (Waiter, error) {
-	return w.SetFolders([]FolderConfiguration{fld})
-}
-
-// SetFolders adds new folders to the configuration, or overwrites existing
-// folders with the same ID.
-func (w *wrapper) SetFolders(folders []FolderConfiguration) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-
-	newCfg := w.cfg.Copy()
-
-	inds := make(map[string]int, len(w.cfg.Folders))
-	for i, folder := range newCfg.Folders {
-		inds[folder.ID] = i
-	}
-	filtered := folders[:0]
-	for _, folder := range folders {
-		if i, ok := inds[folder.ID]; ok {
-			newCfg.Folders[i] = folder
-		} else {
-			filtered = append(filtered, folder)
-		}
-	}
-	newCfg.Folders = append(newCfg.Folders, filtered...)
-
-	return w.replaceLocked(newCfg)
-}
-
 // RemoveFolder removes the folder from the configuration
 func (w *wrapper) RemoveFolder(id string) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-
-	newCfg := w.cfg.Copy()
-	for i := range newCfg.Folders {
-		if newCfg.Folders[i].ID == id {
-			newCfg.Folders = append(newCfg.Folders[:i], newCfg.Folders[i+1:]...)
-			return w.replaceLocked(newCfg)
+	return w.modifyQueued(func(cfg *Configuration) {
+		if _, i, ok := cfg.Folder(id); ok {
+			cfg.Folders = append(cfg.Folders[:i], cfg.Folders[i+1:]...)
 		}
-	}
-
-	return noopWaiter{}, nil
+	})
 }
 
 // FolderPasswords returns the folder passwords set for this device, for
@@ -374,29 +395,12 @@ func (w *wrapper) Options() OptionsConfiguration {
 	return w.cfg.Options.Copy()
 }
 
-// SetOptions replaces the current options configuration object.
-func (w *wrapper) SetOptions(opts OptionsConfiguration) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-	newCfg := w.cfg.Copy()
-	newCfg.Options = opts.Copy()
-	return w.replaceLocked(newCfg)
-}
-
 func (w *wrapper) LDAP() LDAPConfiguration {
 	w.mut.Lock()
 	defer w.mut.Unlock()
 	return w.cfg.LDAP.Copy()
 }
 
-func (w *wrapper) SetLDAP(ldap LDAPConfiguration) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-	newCfg := w.cfg.Copy()
-	newCfg.LDAP = ldap.Copy()
-	return w.replaceLocked(newCfg)
-}
-
 // GUI returns the current GUI configuration object.
 func (w *wrapper) GUI() GUIConfiguration {
 	w.mut.Lock()
@@ -404,15 +408,6 @@ func (w *wrapper) GUI() GUIConfiguration {
 	return w.cfg.GUI.Copy()
 }
 
-// SetGUI replaces the current GUI configuration object.
-func (w *wrapper) SetGUI(gui GUIConfiguration) (Waiter, error) {
-	w.mut.Lock()
-	defer w.mut.Unlock()
-	newCfg := w.cfg.Copy()
-	newCfg.GUI = gui.Copy()
-	return w.replaceLocked(newCfg)
-}
-
 // IgnoredDevice returns whether or not connection attempts from the given
 // device should be silently ignored.
 func (w *wrapper) IgnoredDevice(id protocol.DeviceID) bool {
@@ -449,24 +444,22 @@ func (w *wrapper) IgnoredFolder(device protocol.DeviceID, folder string) bool {
 func (w *wrapper) Device(id protocol.DeviceID) (DeviceConfiguration, bool) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
-	for _, device := range w.cfg.Devices {
-		if device.DeviceID == id {
-			return device.Copy(), true
-		}
+	device, _, ok := w.cfg.Device(id)
+	if !ok {
+		return DeviceConfiguration{}, false
 	}
-	return DeviceConfiguration{}, false
+	return device.Copy(), ok
 }
 
 // Folder returns the configuration for the given folder and an "ok" bool.
 func (w *wrapper) Folder(id string) (FolderConfiguration, bool) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
-	for _, folder := range w.cfg.Folders {
-		if folder.ID == id {
-			return folder.Copy(), true
-		}
+	fcfg, _, ok := w.cfg.Folder(id)
+	if !ok {
+		return FolderConfiguration{}, false
 	}
-	return FolderConfiguration{}, false
+	return fcfg.Copy(), ok
 }
 
 // Save writes the configuration to disk, and generates a ConfigSaved event.
@@ -502,3 +495,13 @@ func (w *wrapper) RequiresRestart() bool {
 func (w *wrapper) setRequiresRestart() {
 	atomic.StoreUint32(&w.requiresRestart, 1)
 }
+
+type modifyEntry struct {
+	modifyFunc ModifyFunction
+	res        chan modifyResult
+}
+
+type modifyResult struct {
+	w   Waiter
+	err error
+}

+ 41 - 19
lib/connections/limiter_test.go

@@ -8,6 +8,7 @@ package connections
 
 import (
 	"bytes"
+	"context"
 	crand "crypto/rand"
 	"io"
 	"math/rand"
@@ -16,6 +17,7 @@ import (
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/protocol"
+	"github.com/thejerf/suture/v4"
 	"golang.org/x/time/rate"
 )
 
@@ -29,24 +31,34 @@ func init() {
 	device4, _ = protocol.DeviceIDFromString("P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2")
 }
 
-func initConfig() config.Wrapper {
-	cfg := config.Wrap("/dev/null", config.New(device1), device1, events.NoopLogger)
+func initConfig() (config.Wrapper, context.CancelFunc) {
+	wrapper := config.Wrap("/dev/null", config.New(device1), device1, events.NoopLogger)
 	dev1Conf = config.NewDeviceConfiguration(device1, "device1")
 	dev2Conf = config.NewDeviceConfiguration(device2, "device2")
 	dev3Conf = config.NewDeviceConfiguration(device3, "device3")
 	dev4Conf = config.NewDeviceConfiguration(device4, "device4")
 
+	var cancel context.CancelFunc = func() {}
+	if wrapperService, ok := wrapper.(suture.Service); ok {
+		var ctx context.Context
+		ctx, cancel = context.WithCancel(context.Background())
+		go wrapperService.Serve(ctx)
+	}
+
 	dev2Conf.MaxRecvKbps = rand.Int() % 100000
 	dev2Conf.MaxSendKbps = rand.Int() % 100000
 
-	waiter, _ := cfg.SetDevices([]config.DeviceConfiguration{dev1Conf, dev2Conf, dev3Conf, dev4Conf})
+	waiter, _ := wrapper.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevices([]config.DeviceConfiguration{dev1Conf, dev2Conf, dev3Conf, dev4Conf})
+	})
 	waiter.Wait()
-	return cfg
+	return wrapper, cancel
 }
 
 func TestLimiterInit(t *testing.T) {
-	cfg := initConfig()
-	lim := newLimiter(device1, cfg)
+	wrapper, wrapperCancel := initConfig()
+	defer wrapperCancel()
+	lim := newLimiter(device1, wrapper)
 
 	device2ReadLimit := dev2Conf.MaxRecvKbps
 	device2WriteLimit := dev2Conf.MaxSendKbps
@@ -70,8 +82,9 @@ func TestLimiterInit(t *testing.T) {
 }
 
 func TestSetDeviceLimits(t *testing.T) {
-	cfg := initConfig()
-	lim := newLimiter(device1, cfg)
+	wrapper, wrapperCancel := initConfig()
+	defer wrapperCancel()
+	lim := newLimiter(device1, wrapper)
 
 	// should still be inf/inf because this is local device
 	dev1ReadLimit := rand.Int() % 100000
@@ -87,7 +100,9 @@ func TestSetDeviceLimits(t *testing.T) {
 	dev3ReadLimit := rand.Int() % 10000
 	dev3Conf.MaxRecvKbps = dev3ReadLimit
 
-	waiter, _ := cfg.SetDevices([]config.DeviceConfiguration{dev1Conf, dev2Conf, dev3Conf, dev4Conf})
+	waiter, _ := wrapper.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevices([]config.DeviceConfiguration{dev1Conf, dev2Conf, dev3Conf, dev4Conf})
+	})
 	waiter.Wait()
 
 	expectedR := map[protocol.DeviceID]*rate.Limiter{
@@ -108,10 +123,11 @@ func TestSetDeviceLimits(t *testing.T) {
 }
 
 func TestRemoveDevice(t *testing.T) {
-	cfg := initConfig()
-	lim := newLimiter(device1, cfg)
+	wrapper, wrapperCancel := initConfig()
+	defer wrapperCancel()
+	lim := newLimiter(device1, wrapper)
 
-	waiter, _ := cfg.RemoveDevice(device3)
+	waiter, _ := wrapper.RemoveDevice(device3)
 	waiter.Wait()
 	expectedR := map[protocol.DeviceID]*rate.Limiter{
 		device2: rate.NewLimiter(rate.Limit(dev2Conf.MaxRecvKbps*1024), limiterBurstSize),
@@ -128,15 +144,18 @@ func TestRemoveDevice(t *testing.T) {
 }
 
 func TestAddDevice(t *testing.T) {
-	cfg := initConfig()
-	lim := newLimiter(device1, cfg)
+	wrapper, wrapperCancel := initConfig()
+	defer wrapperCancel()
+	lim := newLimiter(device1, wrapper)
 
 	addedDevice, _ := protocol.DeviceIDFromString("XZJ4UNS-ENI7QGJ-J45DT6G-QSGML2K-6I4XVOG-NAZ7BF5-2VAOWNT-TFDOMQU")
 	addDevConf := config.NewDeviceConfiguration(addedDevice, "addedDevice")
 	addDevConf.MaxRecvKbps = 120
 	addDevConf.MaxSendKbps = 240
 
-	waiter, _ := cfg.SetDevice(addDevConf)
+	waiter, _ := wrapper.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevice(addDevConf)
+	})
 	waiter.Wait()
 
 	expectedR := map[protocol.DeviceID]*rate.Limiter{
@@ -159,17 +178,20 @@ func TestAddDevice(t *testing.T) {
 }
 
 func TestAddAndRemove(t *testing.T) {
-	cfg := initConfig()
-	lim := newLimiter(device1, cfg)
+	wrapper, wrapperCancel := initConfig()
+	defer wrapperCancel()
+	lim := newLimiter(device1, wrapper)
 
 	addedDevice, _ := protocol.DeviceIDFromString("XZJ4UNS-ENI7QGJ-J45DT6G-QSGML2K-6I4XVOG-NAZ7BF5-2VAOWNT-TFDOMQU")
 	addDevConf := config.NewDeviceConfiguration(addedDevice, "addedDevice")
 	addDevConf.MaxRecvKbps = 120
 	addDevConf.MaxSendKbps = 240
 
-	waiter, _ := cfg.SetDevice(addDevConf)
+	waiter, _ := wrapper.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevice(addDevConf)
+	})
 	waiter.Wait()
-	waiter, _ = cfg.RemoveDevice(device3)
+	waiter, _ = wrapper.RemoveDevice(device3)
 	waiter.Wait()
 
 	expectedR := map[protocol.DeviceID]*rate.Limiter{

+ 14 - 9
lib/model/folder_recvonly_test.go

@@ -25,7 +25,8 @@ func TestRecvOnlyRevertDeletes(t *testing.T) {
 
 	// Get us a model up and running
 
-	m, f := setupROFolder(t)
+	m, f, wcfgCancel := setupROFolder(t)
+	defer wcfgCancel()
 	ffs := f.Filesystem()
 	defer cleanupModel(m)
 
@@ -105,7 +106,8 @@ func TestRecvOnlyRevertNeeds(t *testing.T) {
 
 	// Get us a model up and running
 
-	m, f := setupROFolder(t)
+	m, f, wcfgCancel := setupROFolder(t)
+	defer wcfgCancel()
 	ffs := f.Filesystem()
 	defer cleanupModel(m)
 
@@ -193,7 +195,8 @@ func TestRecvOnlyRevertNeeds(t *testing.T) {
 func TestRecvOnlyUndoChanges(t *testing.T) {
 	// Get us a model up and running
 
-	m, f := setupROFolder(t)
+	m, f, wcfgCancel := setupROFolder(t)
+	defer wcfgCancel()
 	ffs := f.Filesystem()
 	defer cleanupModel(m)
 
@@ -261,7 +264,8 @@ func TestRecvOnlyUndoChanges(t *testing.T) {
 func TestRecvOnlyDeletedRemoteDrop(t *testing.T) {
 	// Get us a model up and running
 
-	m, f := setupROFolder(t)
+	m, f, wcfgCancel := setupROFolder(t)
+	defer wcfgCancel()
 	ffs := f.Filesystem()
 	defer cleanupModel(m)
 
@@ -324,7 +328,8 @@ func TestRecvOnlyDeletedRemoteDrop(t *testing.T) {
 func TestRecvOnlyRemoteUndoChanges(t *testing.T) {
 	// Get us a model up and running
 
-	m, f := setupROFolder(t)
+	m, f, wcfgCancel := setupROFolder(t)
+	defer wcfgCancel()
 	ffs := f.Filesystem()
 	defer cleanupModel(m)
 
@@ -443,17 +448,17 @@ func setupKnownFiles(t *testing.T, ffs fs.Filesystem, data []byte) []protocol.Fi
 	return knownFiles
 }
 
-func setupROFolder(t *testing.T) (*testModel, *receiveOnlyFolder) {
+func setupROFolder(t *testing.T) (*testModel, *receiveOnlyFolder, context.CancelFunc) {
 	t.Helper()
 
-	w := createTmpWrapper(defaultCfg)
+	w, cancel := createTmpWrapper(defaultCfg)
 	cfg := w.RawCopy()
 	fcfg := testFolderConfigFake()
 	fcfg.ID = "ro"
 	fcfg.Label = "ro"
 	fcfg.Type = config.FolderTypeReceiveOnly
 	cfg.Folders = []config.FolderConfiguration{fcfg}
-	w.Replace(cfg)
+	replace(t, w, cfg)
 
 	m := newModel(t, w, myID, "syncthing", "dev", nil)
 	m.ServeBackground()
@@ -464,7 +469,7 @@ func setupROFolder(t *testing.T) (*testModel, *receiveOnlyFolder) {
 	defer m.fmut.RUnlock()
 	f := m.folderRunners["ro"].(*receiveOnlyFolder)
 
-	return m, f
+	return m, f, cancel
 }
 
 func writeFile(fs fs.Filesystem, filename string, data []byte, perm fs.FileMode) error {

+ 49 - 48
lib/model/folder_sendrecv_test.go

@@ -92,8 +92,8 @@ func createFile(t *testing.T, name string, fs fs.Filesystem) protocol.FileInfo {
 }
 
 // Sets up a folder and model, but makes sure the services aren't actually running.
-func setupSendReceiveFolder(t testing.TB, files ...protocol.FileInfo) (*testModel, *sendReceiveFolder) {
-	w, fcfg := tmpDefaultWrapper()
+func setupSendReceiveFolder(t testing.TB, files ...protocol.FileInfo) (*testModel, *sendReceiveFolder, context.CancelFunc) {
+	w, fcfg, wCancel := tmpDefaultWrapper()
 	// Initialise model and stop immediately.
 	model := setupModel(t, w)
 	model.cancel()
@@ -107,10 +107,11 @@ func setupSendReceiveFolder(t testing.TB, files ...protocol.FileInfo) (*testMode
 		f.updateLocalsFromScanning(files)
 	}
 
-	return model, f
+	return model, f, wCancel
 }
 
-func cleanupSRFolder(f *sendReceiveFolder, m *testModel) {
+func cleanupSRFolder(f *sendReceiveFolder, m *testModel, wrapperCancel context.CancelFunc) {
+	wrapperCancel()
 	os.Remove(m.cfg.ConfigPath())
 	os.RemoveAll(f.Filesystem().URI())
 }
@@ -130,8 +131,8 @@ func TestHandleFile(t *testing.T) {
 	requiredFile := existingFile
 	requiredFile.Blocks = blocks[1:]
 
-	m, f := setupSendReceiveFolder(t, existingFile)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t, existingFile)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	copyChan := make(chan copyBlocksState, 1)
 
@@ -172,8 +173,8 @@ func TestHandleFileWithTemp(t *testing.T) {
 	requiredFile := existingFile
 	requiredFile.Blocks = blocks[1:]
 
-	m, f := setupSendReceiveFolder(t, existingFile)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t, existingFile)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	if _, err := prepareTmpFile(f.Filesystem()); err != nil {
 		t.Fatal(err)
@@ -228,10 +229,10 @@ func TestCopierFinder(t *testing.T) {
 			requiredFile.Blocks = blocks[1:]
 			requiredFile.Name = "file2"
 
-			m, f := setupSendReceiveFolder(t, existingFile)
+			m, f, wcfgCancel := setupSendReceiveFolder(t, existingFile)
 			f.CopyRangeMethod = method
 
-			defer cleanupSRFolder(f, m)
+			defer cleanupSRFolder(f, m, wcfgCancel)
 
 			if _, err := prepareTmpFile(f.Filesystem()); err != nil {
 				t.Fatal(err)
@@ -309,8 +310,8 @@ func TestCopierFinder(t *testing.T) {
 
 func TestWeakHash(t *testing.T) {
 	// Setup the model/pull environment
-	model, fo := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(fo, model)
+	model, fo, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(fo, model, wcfgCancel)
 	ffs := fo.Filesystem()
 
 	tempFile := fs.TempName("weakhash")
@@ -438,8 +439,8 @@ func TestCopierCleanup(t *testing.T) {
 	// Create a file
 	file := setupFile("test", []int{0})
 	file.Size = 1
-	m, f := setupSendReceiveFolder(t, file)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t, file)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	file.Blocks = []protocol.BlockInfo{blocks[1]}
 	file.Version = file.Version.Update(myID.Short())
@@ -471,8 +472,8 @@ func TestCopierCleanup(t *testing.T) {
 func TestDeregisterOnFailInCopy(t *testing.T) {
 	file := setupFile("filex", []int{0, 2, 0, 0, 5, 0, 0, 8})
 
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	// Set up our evet subscription early
 	s := m.evLogger.Subscribe(events.ItemFinished)
@@ -571,8 +572,8 @@ func TestDeregisterOnFailInCopy(t *testing.T) {
 func TestDeregisterOnFailInPull(t *testing.T) {
 	file := setupFile("filex", []int{0, 2, 0, 0, 5, 0, 0, 8})
 
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	// Set up our evet subscription early
 	s := m.evLogger.Subscribe(events.ItemFinished)
@@ -674,8 +675,8 @@ func TestDeregisterOnFailInPull(t *testing.T) {
 }
 
 func TestIssue3164(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 	tmpDir := ffs.URI()
 
@@ -765,8 +766,8 @@ func TestDiffEmpty(t *testing.T) {
 // option is true and the permissions do not match between the file on disk and
 // in the db.
 func TestDeleteIgnorePerms(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 	f.IgnorePerms = true
 
@@ -803,8 +804,8 @@ func TestCopyOwner(t *testing.T) {
 	// Set up a folder with the CopyParentOwner bit and backed by a fake
 	// filesystem.
 
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	f.folder.FolderConfiguration = config.NewFolderConfiguration(m.id, f.ID, f.Label, fs.FilesystemTypeFake, "/TestCopyOwner")
 	f.folder.FolderConfiguration.CopyOwnershipFromParent = true
 
@@ -906,8 +907,8 @@ func TestCopyOwner(t *testing.T) {
 // TestSRConflictReplaceFileByDir checks that a conflict is created when an existing file
 // is replaced with a directory and versions are conflicting
 func TestSRConflictReplaceFileByDir(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 
 	name := "foo"
@@ -938,8 +939,8 @@ func TestSRConflictReplaceFileByDir(t *testing.T) {
 // TestSRConflictReplaceFileByLink checks that a conflict is created when an existing file
 // is replaced with a link and versions are conflicting
 func TestSRConflictReplaceFileByLink(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 
 	name := "foo"
@@ -971,8 +972,8 @@ func TestSRConflictReplaceFileByLink(t *testing.T) {
 // TestDeleteBehindSymlink checks that we don't delete or schedule a scan
 // when trying to delete a file behind a symlink.
 func TestDeleteBehindSymlink(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 
 	destDir := createTmpDir()
@@ -1022,8 +1023,8 @@ func TestDeleteBehindSymlink(t *testing.T) {
 
 // Reproduces https://github.com/syncthing/syncthing/issues/6559
 func TestPullCtxCancel(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	pullChan := make(chan pullBlockState)
 	finisherChan := make(chan *sharedPullerState)
@@ -1064,8 +1065,8 @@ func TestPullCtxCancel(t *testing.T) {
 }
 
 func TestPullDeleteUnscannedDir(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 
 	dir := "foobar"
@@ -1093,8 +1094,8 @@ func TestPullDeleteUnscannedDir(t *testing.T) {
 }
 
 func TestPullCaseOnlyPerformFinish(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 
 	name := "foo"
@@ -1154,8 +1155,8 @@ func TestPullCaseOnlySymlink(t *testing.T) {
 }
 
 func testPullCaseOnlyDirOrSymlink(t *testing.T, dir bool) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 	ffs := f.Filesystem()
 
 	name := "foo"
@@ -1209,8 +1210,8 @@ func testPullCaseOnlyDirOrSymlink(t *testing.T, dir bool) {
 }
 
 func TestPullTempFileCaseConflict(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	copyChan := make(chan copyBlocksState, 1)
 
@@ -1235,8 +1236,8 @@ func TestPullTempFileCaseConflict(t *testing.T) {
 }
 
 func TestPullCaseOnlyRename(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	// tempNameConfl := fs.TempName(confl)
 
@@ -1278,8 +1279,8 @@ func TestPullSymlinkOverExistingWindows(t *testing.T) {
 		t.Skip()
 	}
 
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	name := "foo"
 	if fd, err := f.mtimefs.Create(name); err != nil {
@@ -1318,8 +1319,8 @@ func TestPullSymlinkOverExistingWindows(t *testing.T) {
 }
 
 func TestPullDeleteCaseConflict(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	name := "foo"
 	fi := protocol.FileInfo{Name: "Foo"}
@@ -1352,8 +1353,8 @@ func TestPullDeleteCaseConflict(t *testing.T) {
 }
 
 func TestPullDeleteIgnoreChildDir(t *testing.T) {
-	m, f := setupSendReceiveFolder(t)
-	defer cleanupSRFolder(f, m)
+	m, f, wcfgCancel := setupSendReceiveFolder(t)
+	defer cleanupSRFolder(f, m, wcfgCancel)
 
 	parent := "parent"
 	del := "ignored"

+ 59 - 52
lib/model/model.go

@@ -136,6 +136,7 @@ type model struct {
 	// such as scans and pulls.
 	folderIOLimiter *byteSemaphore
 	fatalChan       chan error
+	started         chan struct{}
 
 	// fields protected by fmut
 	fmut                           sync.RWMutex
@@ -162,7 +163,6 @@ type model struct {
 
 	// for testing only
 	foldersRunning int32
-	started        chan struct{}
 }
 
 type folderFactory func(*model, *db.FileSet, *ignore.Matcher, config.FolderConfiguration, versioner.Versioner, events.Logger, *byteSemaphore) service
@@ -221,6 +221,7 @@ func NewModel(cfg config.Wrapper, id protocol.DeviceID, clientName, clientVersio
 		globalRequestLimiter: newByteSemaphore(1024 * cfg.Options().MaxConcurrentIncomingRequestKiB()),
 		folderIOLimiter:      newByteSemaphore(cfg.Options().MaxFolderConcurrency()),
 		fatalChan:            make(chan error),
+		started:              make(chan struct{}),
 
 		// fields protected by fmut
 		fmut:                           sync.NewRWMutex(),
@@ -243,9 +244,6 @@ func NewModel(cfg config.Wrapper, id protocol.DeviceID, clientName, clientVersio
 		deviceDownloads:     make(map[protocol.DeviceID]*deviceDownloadState),
 		remotePausedFolders: make(map[protocol.DeviceID]map[string]struct{}),
 		indexSenders:        make(map[protocol.DeviceID]*indexSenderRegistry),
-
-		// for testing only
-		started: make(chan struct{}),
 	}
 	for devID := range cfg.Devices() {
 		m.deviceStatRefs[devID] = stats.NewDeviceStatisticsReference(m.db, devID)
@@ -259,10 +257,10 @@ func NewModel(cfg config.Wrapper, id protocol.DeviceID, clientName, clientVersio
 func (m *model) serve(ctx context.Context) error {
 	defer m.closeAllConnectionsAndWait()
 
-	m.cfg.Subscribe(m)
+	cfg := m.cfg.Subscribe(m)
 	defer m.cfg.Unsubscribe(m)
 
-	if err := m.initFolders(); err != nil {
+	if err := m.initFolders(cfg); err != nil {
 		close(m.started)
 		return svcutil.AsFatalErr(err, svcutil.ExitError)
 	}
@@ -277,17 +275,14 @@ func (m *model) serve(ctx context.Context) error {
 	}
 }
 
-func (m *model) initFolders() error {
-	cacheIgnoredFiles := m.cfg.Options().CacheIgnoredFiles
-	existingDevices := m.cfg.Devices()
-	existingFolders := m.cfg.Folders()
-	clusterConfigDevices := make(deviceIDSet, len(existingDevices))
-	for _, folderCfg := range existingFolders {
+func (m *model) initFolders(cfg config.Configuration) error {
+	clusterConfigDevices := make(deviceIDSet, len(cfg.Devices))
+	for _, folderCfg := range cfg.Folders {
 		if folderCfg.Paused {
 			folderCfg.CreateRoot()
 			continue
 		}
-		err := m.newFolder(folderCfg, cacheIgnoredFiles)
+		err := m.newFolder(folderCfg, cfg.Options.CacheIgnoredFiles)
 		if err != nil {
 			return err
 		}
@@ -295,7 +290,7 @@ func (m *model) initFolders() error {
 	}
 
 	ignoredDevices := observedDeviceSet(m.cfg.IgnoredDevices())
-	m.cleanPending(existingDevices, existingFolders, ignoredDevices, nil)
+	m.cleanPending(cfg.DeviceMap(), cfg.FolderMap(), ignoredDevices, nil)
 
 	m.resendClusterConfig(clusterConfigDevices.AsSlice())
 	return nil
@@ -1184,7 +1179,6 @@ func (m *model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 		panic("bug: ClusterConfig called on closed or nonexistent connection")
 	}
 
-	changed := false
 	deviceCfg, ok := m.cfg.Device(deviceID)
 	if !ok {
 		l.Debugln("Device disappeared from config while processing cluster-config")
@@ -1219,21 +1213,33 @@ func (m *model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 
 	// Needs to happen outside of the fmut, as can cause CommitConfiguration
 	if deviceCfg.AutoAcceptFolders {
-		changedFolders := make([]config.FolderConfiguration, 0, len(cm.Folders))
-		for _, folder := range cm.Folders {
-			if fcfg, fchanged := m.handleAutoAccepts(deviceID, folder, ccDeviceInfos[folder.ID]); fchanged {
-				changedFolders = append(changedFolders, fcfg)
+		w, _ := m.cfg.Modify(func(cfg *config.Configuration) {
+			changedFcfg := make(map[string]config.FolderConfiguration)
+			haveFcfg := cfg.FolderMap()
+			for _, folder := range cm.Folders {
+				from, ok := haveFcfg[folder.ID]
+				if to, changed := m.handleAutoAccepts(deviceID, folder, ccDeviceInfos[folder.ID], from, ok, cfg.Options.DefaultFolderPath); changed {
+					changedFcfg[folder.ID] = to
+				}
 			}
-		}
-		if len(changedFolders) > 0 {
-			// Need to wait for the waiter, as this calls CommitConfiguration,
-			// which sets up the folder and as we return from this call,
-			// ClusterConfig starts poking at m.folderFiles and other things
-			// that might not exist until the config is committed.
-			w, _ := m.cfg.SetFolders(changedFolders)
-			w.Wait()
-			changed = true
-		}
+			if len(changedFcfg) == 0 {
+				return
+			}
+			for i := range cfg.Folders {
+				if fcfg, ok := changedFcfg[cfg.Folders[i].ID]; ok {
+					cfg.Folders[i] = fcfg
+					delete(changedFcfg, cfg.Folders[i].ID)
+				}
+			}
+			for _, fcfg := range changedFcfg {
+				cfg.Folders = append(cfg.Folders, fcfg)
+			}
+		})
+		// Need to wait for the waiter, as this calls CommitConfiguration,
+		// which sets up the folder and as we return from this call,
+		// ClusterConfig starts poking at m.folderFiles and other things
+		// that might not exist until the config is committed.
+		w.Wait()
 	}
 
 	tempIndexFolders, paused, err := m.ccHandleFolders(cm.Folders, deviceCfg, ccDeviceInfos, indexSenderRegistry)
@@ -1257,11 +1263,12 @@ func (m *model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 	}
 
 	if deviceCfg.Introducer {
-		folders, devices, foldersDevices, introduced := m.handleIntroductions(deviceCfg, cm)
-		folders, devices, deintroduced := m.handleDeintroductions(deviceCfg, foldersDevices, folders, devices)
-		if introduced || deintroduced {
-			changed = true
-			cfg := m.cfg.RawCopy()
+		m.cfg.Modify(func(cfg *config.Configuration) {
+			folders, devices, foldersDevices, introduced := m.handleIntroductions(deviceCfg, cm, cfg.FolderMap(), cfg.DeviceMap())
+			folders, devices, deintroduced := m.handleDeintroductions(deviceCfg, foldersDevices, folders, devices)
+			if !introduced && !deintroduced {
+				return
+			}
 			cfg.Folders = make([]config.FolderConfiguration, 0, len(folders))
 			for _, fcfg := range folders {
 				cfg.Folders = append(cfg.Folders, fcfg)
@@ -1270,14 +1277,7 @@ func (m *model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 			for _, dcfg := range devices {
 				cfg.Devices = append(cfg.Devices, dcfg)
 			}
-			m.cfg.Replace(cfg)
-		}
-	}
-
-	if changed {
-		if err := m.cfg.Save(); err != nil {
-			l.Warnln("Failed to save config", err)
-		}
+		})
 	}
 
 	return nil
@@ -1492,10 +1492,8 @@ func (m *model) resendClusterConfig(ids []protocol.DeviceID) {
 }
 
 // handleIntroductions handles adding devices/folders that are shared by an introducer device
-func (m *model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm protocol.ClusterConfig) (map[string]config.FolderConfiguration, map[protocol.DeviceID]config.DeviceConfiguration, folderDeviceSet, bool) {
+func (m *model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm protocol.ClusterConfig, folders map[string]config.FolderConfiguration, devices map[protocol.DeviceID]config.DeviceConfiguration) (map[string]config.FolderConfiguration, map[protocol.DeviceID]config.DeviceConfiguration, folderDeviceSet, bool) {
 	changed := false
-	folders := m.cfg.Folders()
-	devices := m.cfg.Devices()
 
 	foldersDevices := make(folderDeviceSet)
 
@@ -1521,7 +1519,7 @@ func (m *model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
 
 			foldersDevices.set(device.ID, folder.ID)
 
-			if _, ok := m.cfg.Device(device.ID); !ok {
+			if _, ok := devices[device.ID]; !ok {
 				// The device is currently unknown. Add it to the config.
 				devices[device.ID] = m.introduceDevice(device, introducerCfg)
 			} else if fcfg.SharedWith(device.ID) {
@@ -1602,9 +1600,8 @@ func (m *model) handleDeintroductions(introducerCfg config.DeviceConfiguration,
 
 // handleAutoAccepts handles adding and sharing folders for devices that have
 // AutoAcceptFolders set to true.
-func (m *model) handleAutoAccepts(deviceID protocol.DeviceID, folder protocol.Folder, ccDeviceInfos *indexSenderStartInfo) (config.FolderConfiguration, bool) {
-	if cfg, ok := m.cfg.Folder(folder.ID); !ok {
-		defaultPath := m.cfg.Options().DefaultFolderPath
+func (m *model) handleAutoAccepts(deviceID protocol.DeviceID, folder protocol.Folder, ccDeviceInfos *indexSenderStartInfo, cfg config.FolderConfiguration, haveCfg bool, defaultPath string) (config.FolderConfiguration, bool) {
+	if !haveCfg {
 		defaultPathFs := fs.NewFilesystem(fs.FilesystemTypeBasic, defaultPath)
 		pathAlternatives := []string{
 			fs.SanitizePath(folder.Label),
@@ -2179,9 +2176,16 @@ func (m *model) AddConnection(conn protocol.Connection, hello protocol.Hello) {
 	conn.ClusterConfig(cm)
 
 	if (device.Name == "" || m.cfg.Options().OverwriteRemoteDevNames) && hello.DeviceName != "" {
-		device.Name = hello.DeviceName
-		m.cfg.SetDevice(device)
-		m.cfg.Save()
+		m.cfg.Modify(func(cfg *config.Configuration) {
+			for i := range cfg.Devices {
+				if cfg.Devices[i].DeviceID == deviceID {
+					if cfg.Devices[i].Name == "" || cfg.Options.OverwriteRemoteDevNames {
+						cfg.Devices[i].Name = hello.DeviceName
+					}
+					return
+				}
+			}
+		})
 	}
 
 	m.deviceWasSeen(deviceID)
@@ -2651,6 +2655,9 @@ func (m *model) VerifyConfiguration(from, to config.Configuration) error {
 func (m *model) CommitConfiguration(from, to config.Configuration) bool {
 	// TODO: This should not use reflect, and should take more care to try to handle stuff without restart.
 
+	// Delay processing config changes until after the initial setup
+	<-m.started
+
 	// Go through the folder configs and figure out if we need to restart or not.
 
 	// Tracks devices affected by any configuration change to resend ClusterConfig.

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 169 - 191
lib/model/model_test.go


+ 17 - 7
lib/model/progressemitter_test.go

@@ -60,11 +60,16 @@ func TestProgressEmitter(t *testing.T) {
 
 	w := evLogger.Subscribe(events.DownloadProgress)
 
-	c := createTmpWrapper(config.Configuration{})
+	c, cfgCancel := createTmpWrapper(config.Configuration{})
 	defer os.Remove(c.ConfigPath())
-	c.SetOptions(config.OptionsConfiguration{
-		ProgressUpdateIntervalS: 60, // irrelevant, but must be positive
+	defer cfgCancel()
+	waiter, err := c.Modify(func(cfg *config.Configuration) {
+		cfg.Options.ProgressUpdateIntervalS = 60 // irrelevant, but must be positive
 	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
 
 	p := NewProgressEmitter(c, evLogger)
 	go p.Serve(ctx)
@@ -109,12 +114,17 @@ func TestProgressEmitter(t *testing.T) {
 }
 
 func TestSendDownloadProgressMessages(t *testing.T) {
-	c := createTmpWrapper(config.Configuration{})
+	c, cfgCancel := createTmpWrapper(config.Configuration{})
 	defer os.Remove(c.ConfigPath())
-	c.SetOptions(config.OptionsConfiguration{
-		ProgressUpdateIntervalS: 60, // irrelevant, but must be positive
-		TempIndexMinBlocks:      10,
+	defer cfgCancel()
+	waiter, err := c.Modify(func(cfg *config.Configuration) {
+		cfg.Options.ProgressUpdateIntervalS = 60 // irrelevant, but must be positive
+		cfg.Options.TempIndexMinBlocks = 10
 	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
 
 	fc := &fakeConnection{}
 

+ 45 - 37
lib/model/requests_test.go

@@ -29,7 +29,8 @@ func TestRequestSimple(t *testing.T) {
 	// Verify that the model performs a request and creates a file based on
 	// an incoming index update.
 
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	defer cleanupModelAndRemoveDir(m, tfs.URI())
 
@@ -72,7 +73,8 @@ func TestSymlinkTraversalRead(t *testing.T) {
 		return
 	}
 
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	defer cleanupModelAndRemoveDir(m, fcfg.Filesystem().URI())
 
 	// We listen for incoming index updates and trigger when we see one for
@@ -115,7 +117,8 @@ func TestSymlinkTraversalWrite(t *testing.T) {
 		return
 	}
 
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	defer cleanupModelAndRemoveDir(m, fcfg.Filesystem().URI())
 
 	// We listen for incoming index updates and trigger when we see one for
@@ -174,7 +177,8 @@ func TestSymlinkTraversalWrite(t *testing.T) {
 func TestRequestCreateTmpSymlink(t *testing.T) {
 	// Test that an update for a temporary file is invalidated
 
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	defer cleanupModelAndRemoveDir(m, fcfg.Filesystem().URI())
 
 	// We listen for incoming index updates and trigger when we see one for
@@ -216,14 +220,15 @@ func TestRequestVersioningSymlinkAttack(t *testing.T) {
 	// Sets up a folder with trashcan versioning and tries to use a
 	// deleted symlink to escape
 
-	w, fcfg := tmpDefaultWrapper()
+	w, fcfg, wCancel := tmpDefaultWrapper()
+	defer wCancel()
 	defer func() {
 		os.RemoveAll(fcfg.Filesystem().URI())
 		os.Remove(w.ConfigPath())
 	}()
 
 	fcfg.Versioning = config.VersioningConfiguration{Type: "trashcan"}
-	w.SetFolder(fcfg)
+	setFolder(t, w, fcfg)
 	m, fc := setupModelWithConnectionFromWrapper(t, w)
 	defer cleanupModel(m)
 
@@ -293,11 +298,12 @@ func TestPullInvalidIgnoredSR(t *testing.T) {
 
 // This test checks that (un-)ignored/invalid/deleted files are treated as expected.
 func pullInvalidIgnored(t *testing.T, ft config.FolderType) {
-	w := createTmpWrapper(defaultCfgWrapper.RawCopy())
+	w, wCancel := createTmpWrapper(defaultCfgWrapper.RawCopy())
+	defer wCancel()
 	fcfg := testFolderConfigTmp()
 	fss := fcfg.Filesystem()
 	fcfg.Type = ft
-	w.SetFolder(fcfg)
+	setFolder(t, w, fcfg)
 	m := setupModel(t, w)
 	defer cleanupModelAndRemoveDir(m, fss.URI())
 
@@ -420,7 +426,8 @@ func pullInvalidIgnored(t *testing.T, ft config.FolderType) {
 }
 
 func TestIssue4841(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	defer cleanupModelAndRemoveDir(m, fcfg.Filesystem().URI())
 
 	received := make(chan []protocol.FileInfo)
@@ -464,7 +471,8 @@ func TestIssue4841(t *testing.T) {
 }
 
 func TestRescanIfHaveInvalidContent(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	defer cleanupModelAndRemoveDir(m, tfs.URI())
 
@@ -530,7 +538,8 @@ func TestRescanIfHaveInvalidContent(t *testing.T) {
 }
 
 func TestParentDeletion(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	testFs := fcfg.Filesystem()
 	defer cleanupModelAndRemoveDir(m, testFs.URI())
 
@@ -609,7 +618,8 @@ func TestRequestSymlinkWindows(t *testing.T) {
 		t.Skip("windows specific test")
 	}
 
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	defer cleanupModelAndRemoveDir(m, fcfg.Filesystem().URI())
 
 	received := make(chan []protocol.FileInfo)
@@ -677,7 +687,8 @@ func equalContents(path string, contents []byte) error {
 }
 
 func TestRequestRemoteRenameChanged(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	tmpDir := tfs.URI()
 	defer cleanupModelAndRemoveDir(m, tfs.URI())
@@ -812,7 +823,8 @@ func TestRequestRemoteRenameChanged(t *testing.T) {
 }
 
 func TestRequestRemoteRenameConflict(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	tmpDir := tfs.URI()
 	defer cleanupModelAndRemoveDir(m, tmpDir)
@@ -903,7 +915,8 @@ func TestRequestRemoteRenameConflict(t *testing.T) {
 }
 
 func TestRequestDeleteChanged(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	defer cleanupModelAndRemoveDir(m, tfs.URI())
 
@@ -972,7 +985,8 @@ func TestRequestDeleteChanged(t *testing.T) {
 }
 
 func TestNeedFolderFiles(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	tmpDir := tfs.URI()
 	defer cleanupModelAndRemoveDir(m, tmpDir)
@@ -1020,7 +1034,8 @@ func TestNeedFolderFiles(t *testing.T) {
 // propagated upon un-ignoring.
 // https://github.com/syncthing/syncthing/issues/6038
 func TestIgnoreDeleteUnignore(t *testing.T) {
-	w, fcfg := tmpDefaultWrapper()
+	w, fcfg, wCancel := tmpDefaultWrapper()
+	defer wCancel()
 	m := setupModel(t, w)
 	fss := fcfg.Filesystem()
 	tmpDir := fss.URI()
@@ -1120,7 +1135,8 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
 // TestRequestLastFileProgress checks that the last pulled file (here only) is registered
 // as in progress.
 func TestRequestLastFileProgress(t *testing.T) {
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	defer cleanupModelAndRemoveDir(m, tfs.URI())
 
@@ -1156,7 +1172,8 @@ func TestRequestIndexSenderPause(t *testing.T) {
 	done := make(chan struct{})
 	defer close(done)
 
-	m, fc, fcfg := setupModelWithConnection(t)
+	m, fc, fcfg, wcfgCancel := setupModelWithConnection(t)
+	defer wcfgCancel()
 	tfs := fcfg.Filesystem()
 	defer cleanupModelAndRemoveDir(m, tfs.URI())
 
@@ -1215,13 +1232,8 @@ func TestRequestIndexSenderPause(t *testing.T) {
 
 	// Local paused and resume
 
-	fcfg.Paused = true
-	waiter, _ := m.cfg.SetFolder(fcfg)
-	waiter.Wait()
-
-	fcfg.Paused = false
-	waiter, _ = m.cfg.SetFolder(fcfg)
-	waiter.Wait()
+	pauseFolder(t, m.cfg, fcfg.ID, true)
+	pauseFolder(t, m.cfg, fcfg.ID, false)
 
 	seq++
 	files[0].Sequence = seq
@@ -1238,16 +1250,12 @@ func TestRequestIndexSenderPause(t *testing.T) {
 	cc.Folders[0].Paused = true
 	m.ClusterConfig(device1, cc)
 
-	fcfg.Paused = true
-	waiter, _ = m.cfg.SetFolder(fcfg)
-	waiter.Wait()
+	pauseFolder(t, m.cfg, fcfg.ID, true)
 
 	cc.Folders[0].Paused = false
 	m.ClusterConfig(device1, cc)
 
-	fcfg.Paused = false
-	waiter, _ = m.cfg.SetFolder(fcfg)
-	waiter.Wait()
+	pauseFolder(t, m.cfg, fcfg.ID, false)
 
 	seq++
 	files[0].Sequence = seq
@@ -1277,7 +1285,8 @@ func TestRequestIndexSenderPause(t *testing.T) {
 }
 
 func TestRequestIndexSenderClusterConfigBeforeStart(t *testing.T) {
-	w, fcfg := tmpDefaultWrapper()
+	w, fcfg, wCancel := tmpDefaultWrapper()
+	defer wCancel()
 	tfs := fcfg.Filesystem()
 	dir1 := "foo"
 	dir2 := "bar"
@@ -1340,12 +1349,11 @@ func TestRequestIndexSenderClusterConfigBeforeStart(t *testing.T) {
 }
 
 func TestRequestReceiveEncryptedLocalNoSend(t *testing.T) {
-	w, fcfg := tmpDefaultWrapper()
+	w, fcfg, wCancel := tmpDefaultWrapper()
+	defer wCancel()
 	tfs := fcfg.Filesystem()
 	fcfg.Type = config.FolderTypeReceiveEncrypted
-	waiter, err := w.SetFolder(fcfg)
-	must(t, err)
-	waiter.Wait()
+	setFolder(t, w, fcfg)
 
 	encToken := protocol.PasswordToken(fcfg.ID, "pw")
 	must(t, tfs.Mkdir(config.DefaultMarkerName, 0777))

+ 107 - 19
lib/model/testutils_test.go

@@ -13,6 +13,8 @@ import (
 	"testing"
 	"time"
 
+	"github.com/thejerf/suture/v4"
+
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/db"
 	"github.com/syncthing/syncthing/lib/db/backend"
@@ -24,12 +26,13 @@ import (
 )
 
 var (
-	myID, device1, device2 protocol.DeviceID
-	defaultCfgWrapper      config.Wrapper
-	defaultFolderConfig    config.FolderConfiguration
-	defaultFs              fs.Filesystem
-	defaultCfg             config.Configuration
-	defaultAutoAcceptCfg   config.Configuration
+	myID, device1, device2  protocol.DeviceID
+	defaultCfgWrapper       config.Wrapper
+	defaultCfgWrapperCancel context.CancelFunc
+	defaultFolderConfig     config.FolderConfiguration
+	defaultFs               fs.Filesystem
+	defaultCfg              config.Configuration
+	defaultAutoAcceptCfg    config.Configuration
 )
 
 func init() {
@@ -40,12 +43,13 @@ func init() {
 	defaultFolderConfig = testFolderConfig("testdata")
 	defaultFs = defaultFolderConfig.Filesystem()
 
-	defaultCfgWrapper = createTmpWrapper(config.New(myID))
-	_, _ = defaultCfgWrapper.SetDevice(config.NewDeviceConfiguration(device1, "device1"))
-	_, _ = defaultCfgWrapper.SetFolder(defaultFolderConfig)
-	opts := defaultCfgWrapper.Options()
-	opts.KeepTemporariesH = 1
-	_, _ = defaultCfgWrapper.SetOptions(opts)
+	defaultCfgWrapper, defaultCfgWrapperCancel = createTmpWrapper(config.New(myID))
+	waiter, _ := defaultCfgWrapper.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevice(config.NewDeviceConfiguration(device1, "device1"))
+		cfg.SetFolder(defaultFolderConfig)
+		cfg.Options.KeepTemporariesH = 1
+	})
+	waiter.Wait()
 
 	defaultCfg = defaultCfgWrapper.RawCopy()
 
@@ -70,11 +74,28 @@ func init() {
 	}
 }
 
-func tmpDefaultWrapper() (config.Wrapper, config.FolderConfiguration) {
-	w := createTmpWrapper(defaultCfgWrapper.RawCopy())
+func createTmpWrapper(cfg config.Configuration) (config.Wrapper, context.CancelFunc) {
+	tmpFile, err := ioutil.TempFile("", "syncthing-testConfig-")
+	if err != nil {
+		panic(err)
+	}
+	wrapper := config.Wrap(tmpFile.Name(), cfg, myID, events.NoopLogger)
+	tmpFile.Close()
+	if cfgService, ok := wrapper.(suture.Service); ok {
+		ctx, cancel := context.WithCancel(context.Background())
+		go cfgService.Serve(ctx)
+		return wrapper, cancel
+	}
+	return wrapper, func() {}
+}
+
+func tmpDefaultWrapper() (config.Wrapper, config.FolderConfiguration, context.CancelFunc) {
+	w, cancel := createTmpWrapper(defaultCfgWrapper.RawCopy())
 	fcfg := testFolderConfigTmp()
-	_, _ = w.SetFolder(fcfg)
-	return w, fcfg
+	_, _ = w.Modify(func(cfg *config.Configuration) {
+		cfg.SetFolder(fcfg)
+	})
+	return w, fcfg, cancel
 }
 
 func testFolderConfigTmp() config.FolderConfiguration {
@@ -96,11 +117,11 @@ func testFolderConfigFake() config.FolderConfiguration {
 	return cfg
 }
 
-func setupModelWithConnection(t testing.TB) (*testModel, *fakeConnection, config.FolderConfiguration) {
+func setupModelWithConnection(t testing.TB) (*testModel, *fakeConnection, config.FolderConfiguration, context.CancelFunc) {
 	t.Helper()
-	w, fcfg := tmpDefaultWrapper()
+	w, fcfg, cancel := tmpDefaultWrapper()
 	m, fc := setupModelWithConnectionFromWrapper(t, w)
-	return m, fc, fcfg
+	return m, fc, fcfg, cancel
 }
 
 func setupModelWithConnectionFromWrapper(t testing.TB, w config.Wrapper) (*testModel, *fakeConnection) {
@@ -313,3 +334,70 @@ func newFileSet(t testing.TB, folder string, fs fs.Filesystem, ldb *db.Lowlevel)
 	}
 	return fset
 }
+
+func replace(t testing.TB, w config.Wrapper, to config.Configuration) {
+	t.Helper()
+	waiter, err := w.Modify(func(cfg *config.Configuration) {
+		*cfg = to
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
+}
+
+func pauseFolder(t testing.TB, w config.Wrapper, id string, paused bool) {
+	t.Helper()
+	waiter, err := w.Modify(func(cfg *config.Configuration) {
+		_, i, _ := cfg.Folder(id)
+		cfg.Folders[i].Paused = paused
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
+}
+
+func setFolder(t testing.TB, w config.Wrapper, fcfg config.FolderConfiguration) {
+	t.Helper()
+	waiter, err := w.Modify(func(cfg *config.Configuration) {
+		cfg.SetFolder(fcfg)
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
+}
+
+func pauseDevice(t testing.TB, w config.Wrapper, id protocol.DeviceID, paused bool) {
+	t.Helper()
+	waiter, err := w.Modify(func(cfg *config.Configuration) {
+		_, i, _ := cfg.Device(id)
+		cfg.Devices[i].Paused = paused
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
+}
+
+func setDevice(t testing.TB, w config.Wrapper, device config.DeviceConfiguration) {
+	t.Helper()
+	waiter, err := w.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevice(device)
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	waiter.Wait()
+}
+
+func addDevice2(t testing.TB, w config.Wrapper, fcfg config.FolderConfiguration) {
+	waiter, err := w.Modify(func(cfg *config.Configuration) {
+		cfg.SetDevice(config.NewDeviceConfiguration(device2, "device2"))
+		fcfg.Devices = append(fcfg.Devices, config.FolderDeviceConfiguration{DeviceID: device2})
+		cfg.SetFolder(fcfg)
+	})
+	must(t, err)
+	waiter.Wait()
+}

+ 17 - 16
lib/syncthing/syncthing.go

@@ -121,6 +121,10 @@ func (a *App) Start() error {
 }
 
 func (a *App) startup() error {
+	if cfgService, ok := a.cfg.(suture.Service); ok {
+		a.mainService.Add(cfgService)
+	}
+
 	a.mainService.Add(ur.NewFailureHandler(a.cfg, a.evLogger))
 
 	a.mainService.Add(a.ll)
@@ -277,24 +281,21 @@ func (a *App) startup() error {
 	a.mainService.Add(discoveryManager)
 	a.mainService.Add(connectionsService)
 
-	// Candidate builds always run with usage reporting.
-
-	if opts := a.cfg.Options(); build.IsCandidate {
-		l.Infoln("Anonymous usage reporting is always enabled for candidate releases.")
-		if opts.URAccepted != ur.Version {
-			opts.URAccepted = ur.Version
-			a.cfg.SetOptions(opts)
-			a.cfg.Save()
-			// Unique ID will be set and config saved below if necessary.
+	a.cfg.Modify(func(cfg *config.Configuration) {
+		// Candidate builds always run with usage reporting.
+		if build.IsCandidate {
+			l.Infoln("Anonymous usage reporting is always enabled for candidate releases.")
+			if cfg.Options.URAccepted != ur.Version {
+				cfg.Options.URAccepted = ur.Version
+				// Unique ID will be set and config saved below if necessary.
+			}
 		}
-	}
 
-	// If we are going to do usage reporting, ensure we have a valid unique ID.
-	if opts := a.cfg.Options(); opts.URAccepted > 0 && opts.URUniqueID == "" {
-		opts.URUniqueID = rand.String(8)
-		a.cfg.SetOptions(opts)
-		a.cfg.Save()
-	}
+		// If we are going to do usage reporting, ensure we have a valid unique ID.
+		if cfg.Options.URAccepted > 0 && cfg.Options.URUniqueID == "" {
+			cfg.Options.URUniqueID = rand.String(8)
+		}
+	})
 
 	usageReportingSvc := ur.New(a.cfg, m, connectionsService, a.opts.NoUpgrade)
 	a.mainService.Add(usageReportingSvc)

+ 20 - 25
lib/ur/failurereporting.go

@@ -67,37 +67,17 @@ type failureStat struct {
 }
 
 func (h *failureHandler) Serve(ctx context.Context) error {
-	go func() {
-		select {
-		case h.optsChan <- h.cfg.Options():
-		case <-ctx.Done():
-		}
-	}()
-	h.cfg.Subscribe(h)
+	cfg := h.cfg.Subscribe(h)
 	defer h.cfg.Unsubscribe(h)
+	url, sub, evChan := h.applyOpts(cfg.Options, nil)
 
-	var url string
 	var err error
-	var sub events.Subscription
-	var evChan <-chan events.Event
 	timer := time.NewTimer(minDelay)
 	resetTimer := make(chan struct{})
-outer:
-	for {
+	for err == nil {
 		select {
 		case opts := <-h.optsChan:
-			// Sub nil checks just for safety - config updates can be racy.
-			if opts.URAccepted > 0 {
-				if sub == nil {
-					sub = h.evLogger.Subscribe(events.Failure)
-					evChan = sub.C()
-				}
-			} else if sub != nil {
-				sub.Unsubscribe()
-				sub = nil
-				evChan = nil
-			}
-			url = opts.CRURL + "/failure"
+			url, sub, evChan = h.applyOpts(opts, sub)
 		case e, ok := <-evChan:
 			if !ok {
 				// Just to be safe - shouldn't ever happen, as
@@ -137,7 +117,7 @@ outer:
 		case <-resetTimer:
 			timer.Reset(minDelay)
 		case <-ctx.Done():
-			break outer
+			err = ctx.Err()
 		}
 	}
 
@@ -154,6 +134,21 @@ outer:
 	return err
 }
 
+func (h *failureHandler) applyOpts(opts config.OptionsConfiguration, sub events.Subscription) (string, events.Subscription, <-chan events.Event) {
+	// Sub nil checks just for safety - config updates can be racy.
+	url := opts.CRURL + "/failure"
+	if opts.URAccepted > 0 {
+		if sub == nil {
+			sub = h.evLogger.Subscribe(events.Failure)
+		}
+		return url, sub, sub.C()
+	}
+	if sub != nil {
+		sub.Unsubscribe()
+	}
+	return url, nil, nil
+}
+
 func (h *failureHandler) addReport(descr string, evTime time.Time) {
 	if stat, ok := h.buf[descr]; ok {
 		stat.last = evTime

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä