mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(daemon): survive a reboot — login autostart on macOS/Linux/Windows, and a lock instead of a pidfile (#88)
* feat(daemon): bring sync back after a reboot, and stop trusting the pidfile A reboot killed every daemon and nothing restarted them. Agent hooks still synced per turn, which is what made it easy to miss: a folder looked fine while an agent worked in it and went stale the moment one didn't. `bdrive init` now registers a login item (macOS: a user LaunchAgent) that runs the new `bdrive resume` — one registration per machine, which starts a daemon for every enrolled, unpaused mount, so adding a project later needs no re-registration and `bdrive stop` still means stay stopped. `--no-autostart` opts out, `bdrive autostart install|uninstall` manages it. Writing the plist is the whole job: no `launchctl` shell-out. launchd loads agents at login anyway, the caller has just started the daemon for this session, and shelling out would let a test or a packaging script register a real login item as a side effect. The recovery path was also broken, which is why this is one change. Liveness came from `kill(pid, 0)` on daemon.pid — but that file lives in $BDRIVE_HOME and survives the reboot that killed its process, so any same-user process recycling the pid read as a live daemon. `bdrive status` said "running", and worse `daemon.Start` returned early, so the one documented recovery (`bdrive init`) reported success and started nothing. Liveness is now an flock held for the daemon's lifetime: the kernel drops it at death or reboot, and it makes two daemons on one mount impossible. The pid stays for display and for signalling. internal/autostart is darwin-only today; autostart_other.go returns ErrUnsupported and every caller already treats that as "nothing to do", so Linux (systemd user unit) and Windows are one file each. Tests: internal/daemon gets its first ones — a recycled pid must not read as running (the exact regression), the lock decides liveness, a second holder is refused. internal/autostart covers write/idempotency/stale-path-rewrite/ uninstall with HOME redirected, and lints the plist with plutil so launchd can actually parse it. The CLI e2e asserts init registers the agent, that it runs `resume`, that resume finds the live daemon instead of starting a second, and that --no-autostart is silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * feat(autostart): Linux support — a systemd user unit alongside the launchd agent Same three functions, same discipline. Linux writes $XDG_CONFIG_HOME/systemd/user/beardrive.service (Type=oneshot, no Restart= — `bdrive resume` exits by design) plus the default.target.wants symlink that `systemctl --user enable` would create, because systemd ignores a unit nothing wants. No `systemctl` shell-out, for the same reasons as launchctl: the file is the registration, it only matters at the next login, and a container or ssh session has no session bus to talk to. Install declines with ErrUnsupported unless systemd is actually the init system (/run/systemd/system, i.e. sd_booted) — on Alpine, WSL1 or a slim container a unit file is inert decoration, and reporting "registered" would be a lie. Installed() likewise requires the enable symlink, not just the unit: a unit nothing wants never starts. os.UserConfigDir honors XDG_CONFIG_HOME, so relocated config dirs work. Windows is now the only gap; autostart_other.go is !darwin && !linux, and the shared writeIfDifferent/selfPath moved into the tag-free autostart.go (darwin now uses them too). Tests run on Linux, not just compiled for it: cross-compiled test binaries executed in a container, both with /run/systemd/system present (unit written, enabled, idempotent, stale ExecStart rewritten, broken symlink repaired, XDG honored, uninstall removes both) and without it (Install declines and writes nothing). The daemon flock tests were run there too, since flock semantics are per-OS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * feat(autostart): Windows support — a per-user Run entry Third platform, same three functions. Windows has no user service manager in the launchd/systemd sense, so the registration is a HKCU\...\Run value via golang.org/x/sys/windows/registry (already in the module graph; go mod tidy just promotes it to direct). Chosen over the alternatives for the same reason the other two write files: no admin rights, no COM (a Startup-folder .lnk needs it), no schtasks shell-out. It is also honestly discoverable — the entry appears in Task Manager's Startup tab, where someone can disable it without knowing bdrive exists. The executable is quoted because Explorer parses the value as a command line and Program Files has a space in it. Two things a reader should not have to discover for themselves: - The tests here have NEVER RUN. They are written and compile-checked (GOOS=windows go test -c) from macOS; there is no Windows host or usable container on an arm64 mac. They execute the first time the suite runs on Windows. They also cannot use a temp HOME the way the macOS and Linux tests do — HKCU is real — so each one snapshots and restores the previous value. - `GOOS=windows go build ./...` still does not pass, and this package is not why: internal/store's Lock uses syscall.Flock and internal/daemon uses syscall.Kill and Setsid, all unix-only (true before this branch too). A Windows port means LockFileEx plus a stop story for a platform with no SIGTERM — a separate change, against the sync invariants, and untestable from here. So this code is correct and currently unreachable. autostart_other.go is now !darwin && !linux && !windows (the BSDs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
31f705e287
commit
fb6ce347c4
@@ -35,22 +35,72 @@ func LogPath(volDir string) string {
|
||||
return filepath.Join(volDir, "daemon.log")
|
||||
}
|
||||
|
||||
// Running reports the daemon pid for a mount if one is alive.
|
||||
// LockPath is the file a live daemon holds an exclusive flock on for its
|
||||
// whole lifetime. Liveness is the LOCK, not the pidfile: the kernel drops a
|
||||
// flock when the holder dies — including at reboot, and including a crash —
|
||||
// so a leftover daemon.pid can never be mistaken for a running daemon.
|
||||
//
|
||||
// The pid alone cannot answer this. `kill(pid, 0)` only asks "does some
|
||||
// process own this number", and daemon.pid outlives the process (it sits in
|
||||
// ~/.bdrive, which survives reboots). Any same-user process that later
|
||||
// recycles the pid used to read as a live daemon — which made `bdrive status`
|
||||
// lie and, worse, made Start() a silent no-op, so the one documented recovery
|
||||
// (`bdrive init`) left the folder unsynced.
|
||||
func LockPath(volDir string) string {
|
||||
return filepath.Join(volDir, "daemon.lock")
|
||||
}
|
||||
|
||||
// Running reports the daemon pid for a mount if one is alive. The pid is
|
||||
// informational (for display and for Stop's signal); aliveness comes from
|
||||
// LockPath — see the comment there.
|
||||
func Running(volDir string) (int, bool) {
|
||||
if !locked(LockPath(volDir)) {
|
||||
return 0, false
|
||||
}
|
||||
data, err := os.ReadFile(PidPath(volDir))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
return 0, true // held by a daemon whose pidfile we can't read
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if err := syscall.Kill(pid, 0); err != nil {
|
||||
return 0, false
|
||||
return 0, true
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// locked reports whether another process holds the lock file. Taking it
|
||||
// non-blocking and immediately releasing is the probe: success means nobody
|
||||
// held it (so: no daemon), failure means someone does.
|
||||
func locked(path string) bool {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return false // can't tell; treat as not running so Start can try
|
||||
}
|
||||
defer f.Close()
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
return true
|
||||
}
|
||||
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
return false
|
||||
}
|
||||
|
||||
// hold takes the daemon's lifetime lock. The returned closer releases it;
|
||||
// process death releases it too, which is the point.
|
||||
func hold(path string) (func(), error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("another daemon is already running for this mount: %w", err)
|
||||
}
|
||||
return func() {
|
||||
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start launches a detached daemon for the folder (no-op if already running).
|
||||
func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (int, error) {
|
||||
if pid, ok := Running(volDir); ok {
|
||||
@@ -81,19 +131,26 @@ func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (i
|
||||
return pid, cmd.Process.Release()
|
||||
}
|
||||
|
||||
// Stop terminates the daemon for a mount and waits for it to exit.
|
||||
// Stop terminates the daemon for a mount and waits for it to exit. Exit is
|
||||
// observed by the lock being released, not by the pid disappearing: the pid
|
||||
// could be recycled while we wait, and the lock cannot.
|
||||
func Stop(volDir string) (bool, error) {
|
||||
pid, ok := Running(volDir)
|
||||
if !ok {
|
||||
os.Remove(PidPath(volDir))
|
||||
return false, nil
|
||||
}
|
||||
if pid <= 0 {
|
||||
// Alive (lock held) but no readable pid — nothing to signal.
|
||||
return false, fmt.Errorf("a daemon holds %s but %s is unreadable; kill it by hand",
|
||||
LockPath(volDir), PidPath(volDir))
|
||||
}
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return false, err
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := syscall.Kill(pid, 0); err != nil {
|
||||
if !locked(LockPath(volDir)) {
|
||||
os.Remove(PidPath(volDir))
|
||||
return true, nil
|
||||
}
|
||||
@@ -129,6 +186,15 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Hold the lifetime lock before announcing the pid: it is what makes
|
||||
// "is a daemon running" answerable, and it also makes a double start
|
||||
// impossible (two daemons on one mount would write one journal twice).
|
||||
release, err := hold(LockPath(volDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A pidfile outlives the process that wrote it — it sits in ~/.bdrive, which
|
||||
// survives the reboot that killed the daemon. Liveness therefore cannot be
|
||||
// "some process owns this number": any same-user process that later recycles
|
||||
// the pid used to read as a live daemon, which made `bdrive status` lie and
|
||||
// made Start() a silent no-op, so `bdrive init` left the folder unsynced.
|
||||
//
|
||||
// os.Getpid() stands in for the recycler: it is alive, same-user, and
|
||||
// certainly not a bdrive daemon.
|
||||
func TestRecycledPidIsNotALiveDaemon(t *testing.T) {
|
||||
vdir := t.TempDir()
|
||||
writePid(t, vdir, os.Getpid())
|
||||
|
||||
if pid, ok := Running(vdir); ok {
|
||||
t.Fatalf("Running reports pid %d as a live daemon; only the lock may say that", pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Garbage and stale-but-plausible pidfiles are equally not daemons.
|
||||
func TestPidFileWithoutLockIsNeverRunning(t *testing.T) {
|
||||
for _, body := range []string{"", "\n", "not-a-number", "0", "-1", "999999999"} {
|
||||
vdir := t.TempDir()
|
||||
if err := os.WriteFile(PidPath(vdir), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := Running(vdir); ok {
|
||||
t.Errorf("pidfile %q read as running", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The lock is the answer: held → running, released → not. This is what makes
|
||||
// the reboot case correct without asking the OS about processes at all.
|
||||
func TestLockDecidesLiveness(t *testing.T) {
|
||||
vdir := t.TempDir()
|
||||
if _, ok := Running(vdir); ok {
|
||||
t.Fatal("a fresh volume dir cannot have a running daemon")
|
||||
}
|
||||
|
||||
release, err := hold(LockPath(vdir))
|
||||
if err != nil {
|
||||
t.Fatalf("hold: %v", err)
|
||||
}
|
||||
writePid(t, vdir, 4242)
|
||||
pid, ok := Running(vdir)
|
||||
if !ok {
|
||||
t.Fatal("a held lock means a running daemon")
|
||||
}
|
||||
if pid != 4242 {
|
||||
t.Fatalf("pid = %d, want the pidfile's 4242 (informational only)", pid)
|
||||
}
|
||||
|
||||
// A second holder must be refused: two daemons on one mount would both
|
||||
// write the same device journal.
|
||||
if _, err := hold(LockPath(vdir)); err == nil {
|
||||
t.Fatal("hold succeeded twice — a double daemon is possible")
|
||||
}
|
||||
|
||||
release()
|
||||
if _, ok := Running(vdir); ok {
|
||||
t.Fatal("releasing the lock must end the daemon's liveness")
|
||||
}
|
||||
}
|
||||
|
||||
// A held lock with an unreadable pid is still a running daemon — we just
|
||||
// cannot name it. Stop must say so rather than pretending it stopped one.
|
||||
func TestLockedWithoutPidFile(t *testing.T) {
|
||||
vdir := t.TempDir()
|
||||
release, err := hold(LockPath(vdir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
pid, ok := Running(vdir)
|
||||
if !ok || pid != 0 {
|
||||
t.Fatalf("Running = (%d, %v), want (0, true)", pid, ok)
|
||||
}
|
||||
if stopped, err := Stop(vdir); stopped || err == nil {
|
||||
t.Fatalf("Stop = (%v, %v), want (false, error) — nothing to signal", stopped, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stopping when nothing runs is success, and it cleans up the stale pidfile.
|
||||
func TestStopWithNoDaemon(t *testing.T) {
|
||||
vdir := t.TempDir()
|
||||
writePid(t, vdir, os.Getpid())
|
||||
|
||||
stopped, err := Stop(vdir)
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if stopped {
|
||||
t.Fatal("Stop reported killing a daemon that was never running")
|
||||
}
|
||||
if _, err := os.Stat(PidPath(vdir)); !os.IsNotExist(err) {
|
||||
t.Fatal("Stop left the stale pidfile behind")
|
||||
}
|
||||
}
|
||||
|
||||
func writePid(t *testing.T, vdir string, pid int) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(vdir, "daemon.pid"),
|
||||
[]byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user