* cli: magpie webdav — WebDAV sync from the terminal, what Settings › WebDAV sync does, for a machine with no window (WSL, a server over SSH) magpie webdav shows it; on <address> [user=…] [password=…] [keys=no] [agents=no] [library=no] turns it on and syncs at once, asking for the passphrase (and the password, when a user has none); set k=v… changes what is given and keeps the rest; now, dismiss and off are the page's Sync now, OK and Turn off. Through the same davsync.Configure, Now, Dismiss and Off. A sync, Off and Dismiss hold a lock the system keeps on sync.lock (flock; LockFileEx on Windows): the gateway's sync every 3 minutes and one from a terminal — or from the app's window while magpie serve has the gateway — would each save sync-state.json over the other's. The system lets it go when the magpie holding it ends, however it ends. Configure keeps the saved password only for the same server (scheme and host) and user: an address moved to another server, or another user, no longer takes it there — in the app too, where a new address with the password left empty was sent the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * davsync, cli: from the review of #127 — the setup read again under the lock, Configure locked too, the password never an argument, one left behind asked for, a wait that runs out said in the status Now reads the setup again once it has the lock: one that waited while another magpie turned sync off went on with the setup it had read, and saved sync-state.json back after the Off. Configure takes mu and the lock too, after a sync in progress. magpie webdav takes neither the password nor the passphrase as an argument: password= asks for it, as passphrase= does. Configure with the saved password left behind — another server or user, the password empty — says to type it and saves nothing, where the Settings page dropped it and the sync failed with 401 under a field that still said saved. A wait for the lock that runs out is davsync.ErrBusy. Dismiss returns it, and the davsync routes put ErrBusy from a sync in the status they answer with, where the page shows a sync's failures; Save's own errors stay in its form, what was typed in it kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * davsync, cli: the lock before mu, a wait bounded under the page's, one called off not called busy, the password rule in one place, a bare address with = in it The lock comes before mu everywhere, so a wait for another magpie never holds mu, and Configure, Off and Dismiss wait for it 30 seconds at most, less than the Settings page gives a request: before, a request could hang on mu and then on the lock for minutes. A wait for the lock called off, as when the page is closed or the gateway stops, is its context's error, not ErrBusy: nothing said another magpie was syncing. What becomes of the saved password is decided in one place, davsync.SavedPassword, which Configure and magpie webdav both go by, where the two had rules of their own that had come apart: an account with a password and no user name, moved to another server, had its password dropped with nothing asked, and the sync failed with 401. It is asked for now, as for an account with a user. magpie webdav takes a bare argument with :// in it as the address, = in it or not: https://host/dav?token=… was read as a field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
3.5 KiB
Go
133 lines
3.5 KiB
Go
package davsync
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestLockHolder is the other magpie of TestLock: it takes the lock, says
|
|
// so, and holds it until it is killed.
|
|
func TestLockHolder(t *testing.T) {
|
|
if os.Getenv("MAGPIE_LOCK_HOLDER") == "" {
|
|
t.Skip("run by TestLock")
|
|
}
|
|
if _, err := lock(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fmt.Println("locked")
|
|
time.Sleep(time.Minute)
|
|
}
|
|
|
|
func TestLock(t *testing.T) {
|
|
newComputer(t).use(t)
|
|
ctx := context.Background()
|
|
|
|
// another magpie holds it: this one waits, and gives up when told to
|
|
holder := exec.Command(os.Args[0], "-test.run=^TestLockHolder$")
|
|
holder.Env = append(os.Environ(), "MAGPIE_LOCK_HOLDER=1")
|
|
out, _ := holder.StdoutPipe()
|
|
if err := holder.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer holder.Process.Kill()
|
|
if line, _ := bufio.NewReader(out).ReadString('\n'); line != "locked\n" {
|
|
t.Fatalf("the other magpie: %q", line)
|
|
}
|
|
short, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
|
|
defer cancel()
|
|
if _, err := lock(short); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("taken while another magpie held it: %v", err)
|
|
}
|
|
// a wait called off, not run out, isn't another magpie's doing
|
|
called, stop := context.WithCancel(ctx)
|
|
time.AfterFunc(100*time.Millisecond, stop)
|
|
if _, err := lock(called); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("a wait called off: %v", err)
|
|
}
|
|
|
|
// it ends without letting go, as one stopped mid-sync does: the system
|
|
// lets go for it
|
|
holder.Process.Kill()
|
|
holder.Wait()
|
|
short, cancel = context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
unlock, err := lock(short)
|
|
if err != nil {
|
|
t.Fatalf("after the other magpie was killed: %v", err)
|
|
}
|
|
|
|
// Off waits for a sync in progress, and goes on once it is done
|
|
done := make(chan error)
|
|
go func() { done <- locked(func() error { return nil }) }()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("ran while the lock was held: %v", err)
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
unlock()
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// What reads or writes the setup does it under the lock: a sync that
|
|
// waited for another magpie's reads the setup again, and does nothing when
|
|
// that magpie turned sync off meanwhile; Configure waits its turn.
|
|
func TestLockedSetup(t *testing.T) {
|
|
newComputer(t).use(t)
|
|
var reqs atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reqs.Add(1)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
cfg := Config{URL: srv.URL + "/dav/", Passphrase: "correct horse"}
|
|
if err := Configure(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
unlock, err := lock(ctx) // another magpie's sync
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := make(chan error)
|
|
go func() { done <- Now(ctx) }()
|
|
time.Sleep(300 * time.Millisecond) // waiting for it
|
|
os.Remove(path("sync.json")) // which ends in Off
|
|
unlock()
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(path("sync-state.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("a sync turned off while it waited saved its state: %v", err)
|
|
}
|
|
if n := reqs.Load(); n != 0 {
|
|
t.Fatalf("%d requests to the server after sync was turned off", n)
|
|
}
|
|
|
|
unlock, err = lock(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() { done <- Configure(cfg) }()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("configured while another magpie held the lock: %v", err)
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
unlock()
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|