mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A device whose journal push the hub 403s reported healthy sync between every pair of remote passes, and never showed the hub's reason for the refusal — so a user whose device was not registered to their account re-ran `bdrive login` (which the message told them to), re-checked their project permissions (write), and had nowhere left to look. Two causes, both local to the client: - Cycle recomputed st.Access from scratch at the end of every pass, including the daemon's cheap local-only ticks that never reach the hub. Three of those run between remote passes, so the daemon alternated "read-only on this project" / "access restored; syncing normally" every few seconds and `bdrive status` reported OK moments after a refused push. Now each leg records its own verdict — pull clears no-access, push records read-only or clears it — and a cycle that asked nothing leaves the last answer standing. - The hub's own sentence was summarized into "read-only (pull only)", which describes the STATUS CODE. It is the only thing that tells a device-registration refusal from a project the user really is a reader on. It now rides in SyncState.AccessReason and Result.Reason(), printed by `bdrive sync`, `bdrive status` and the daemon log, and dropped unless it passes journal.SafeText — hub text reaching a terminal. The hub's refusal also now names the upgrade: the binding is made by the login request naming its device, which a CLI older than the gate does not do, so "run `bdrive login`" alone sent that user in a circle. Hub and CLI deploy separately, so the skew is the expected state right after the gate ships. Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
497 lines
17 KiB
Go
497 lines
17 KiB
Go
// Package daemon runs the per-mount background sync loop and manages its
|
|
// lifecycle (detached start, pidfile, graceful stop).
|
|
//
|
|
// The loop scans the working folder every scan-interval (cheap: size+mtime
|
|
// against the state cache) and talks to the remote every remote-interval —
|
|
// or immediately after local changes, so edits propagate quickly without
|
|
// hammering the object store.
|
|
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
"github.com/runbear-io/beardrive/internal/syncer"
|
|
)
|
|
|
|
// The volume store is keyed by the mount id, so exactly one daemon runs per
|
|
// mount and its pid/log live in the store dir.
|
|
func PidPath(volDir string) string {
|
|
return filepath.Join(volDir, "daemon.pid")
|
|
}
|
|
func LogPath(volDir string) string {
|
|
return filepath.Join(volDir, "daemon.log")
|
|
}
|
|
|
|
// 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. Aliveness is the
|
|
// flock on LockPath. The pid comes out of that same locked file — written by
|
|
// the holder itself (see announce) — and falls back to daemon.pid only for
|
|
// display: nothing binds THAT file's contents to the process holding the lock,
|
|
// and a daemon killed with -9 leaves it behind for the next process to inherit
|
|
// the number. Stop signals the announced pid only (see lockPid).
|
|
func Running(volDir string) (int, bool) {
|
|
pid, ok := lockPid(volDir)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
if pid > 0 {
|
|
return pid, true
|
|
}
|
|
data, err := os.ReadFile(PidPath(volDir))
|
|
if err != nil {
|
|
return 0, true
|
|
}
|
|
if p, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && p > 0 {
|
|
return p, true
|
|
}
|
|
return 0, true
|
|
}
|
|
|
|
// lockPid reports whether a daemon holds the mount's lock and, if it announced
|
|
// one, its pid. This is the only pid anything ever signals: it is written
|
|
// inside the lock and truncated when the lock is released, so it cannot name a
|
|
// process that has exited.
|
|
func lockPid(volDir string) (int, bool) {
|
|
f, err := openLock(LockPath(volDir))
|
|
if err != nil {
|
|
return 0, unopenableLockIsRunning(LockPath(volDir))
|
|
}
|
|
defer f.Close()
|
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil {
|
|
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
return 0, false // nobody holds it: no daemon
|
|
}
|
|
buf := make([]byte, 32)
|
|
n, _ := f.ReadAt(buf, 0)
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(buf[:n])))
|
|
if err != nil || pid <= 0 {
|
|
return 0, true // alive, but it announced no pid we can signal
|
|
}
|
|
return pid, true
|
|
}
|
|
|
|
// openLock opens the lock file without following a symlink. Following one put
|
|
// the flock on whatever the link named, so a single symlink inside
|
|
// $BDRIVE_HOME made an unrelated long-lived process's lock read as this
|
|
// mount's daemon — Start() a no-op and sync silently never restarting, the
|
|
// exact failure the flock design exists to eliminate.
|
|
func openLock(path string) (*os.File, error) {
|
|
return os.OpenFile(path, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0o600)
|
|
}
|
|
|
|
// held is the set of lock paths THIS process holds. It is the only evidence
|
|
// of a daemon available when the lock file cannot be opened.
|
|
var held struct {
|
|
sync.Mutex
|
|
paths map[string]bool
|
|
}
|
|
|
|
func markHeld(path string, on bool) {
|
|
held.Lock()
|
|
defer held.Unlock()
|
|
if held.paths == nil {
|
|
held.paths = map[string]bool{}
|
|
}
|
|
if on {
|
|
held.paths[path] = true
|
|
} else {
|
|
delete(held.paths, path)
|
|
}
|
|
}
|
|
|
|
// unopenableLockIsRunning answers the liveness question when the lock file
|
|
// cannot be opened at all.
|
|
//
|
|
// Round 5 made this fail CLOSED with one carve-out for a symlink, and the
|
|
// carve-out was on the wrong axis: a directory at the lock path, a lock file
|
|
// nobody can open, and a volume directory that does not exist at all all fail
|
|
// to open too, and every one of them then read as a live daemon forever —
|
|
// Start a permanent no-op, Stop refusing, `bdrive status` reporting a healthy
|
|
// mount that has not synced since. The ENOENT case needs no attacker.
|
|
//
|
|
// The answer is the reason, not the shape: none of those states is a daemon,
|
|
// and reporting one wedges the mount. What makes reporting "no daemon" safe
|
|
// here is that holdLock opens the SAME path the same way — a second daemon
|
|
// cannot start on a lock it cannot open either, so the "two writers of one
|
|
// journal" this guard exists for cannot materialize. The one real daemon that
|
|
// can exist while the file is unopenable is this process, and it knows.
|
|
func unopenableLockIsRunning(path string) bool {
|
|
held.Lock()
|
|
defer held.Unlock()
|
|
return held.paths[path]
|
|
}
|
|
|
|
// locked reports whether another process holds the lock file.
|
|
func locked(path string) bool {
|
|
f, err := openLock(path)
|
|
if err != nil {
|
|
return unopenableLockIsRunning(path)
|
|
}
|
|
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
|
|
}
|
|
|
|
// holdLock takes the daemon's lifetime lock, returning the open file so the
|
|
// holder can announce its pid INSIDE it. Process death releases the flock,
|
|
// which is the point.
|
|
func holdLock(path string) (*os.File, error) {
|
|
f, err := openLock(path)
|
|
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)
|
|
}
|
|
markHeld(path, true)
|
|
return f, nil
|
|
}
|
|
|
|
// release drops the lock and clears the announced pid with it, so no number
|
|
// outlives the process that owned it.
|
|
func release(f *os.File) {
|
|
markHeld(f.Name(), false)
|
|
f.Truncate(0)
|
|
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
f.Close()
|
|
}
|
|
|
|
// hold takes the lock and returns its releaser.
|
|
func hold(path string) (func(), error) {
|
|
f, err := holdLock(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func() { release(f) }, nil
|
|
}
|
|
|
|
// announce writes the holder's pid into the locked file — the only pid Stop
|
|
// ever signals. It lives and dies with the lock, so it cannot name a process
|
|
// that has exited (and been recycled), which daemon.pid routinely did: a
|
|
// daemon killed with -9 leaves that file behind for the next process to
|
|
// inherit the number.
|
|
func announce(f *os.File) error {
|
|
if err := f.Truncate(0); err != nil {
|
|
return err
|
|
}
|
|
_, err := f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0)
|
|
return err
|
|
}
|
|
|
|
// 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 {
|
|
return pid, nil
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// 0600: daemon.log names the mount id, the folder's absolute path, the
|
|
// remote URL and the device name+id.
|
|
logf, err := os.OpenFile(LogPath(volDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer logf.Close()
|
|
cmd := exec.Command(exe, "daemon", "run", folder,
|
|
"--scan-interval", scanInterval.String(),
|
|
"--remote-interval", remoteInterval.String())
|
|
cmd.Stdout = logf
|
|
cmd.Stderr = logf
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := cmd.Process.Release(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// The child announces its own pid, and only once it holds the lifetime
|
|
// lock (see Run). The parent must NOT write PidPath: a child that loses
|
|
// the lock race exits without ever being the daemon, and its pid written
|
|
// here would outlive it — leaving Stop signalling a corpse (ESRCH) while
|
|
// the daemon that won keeps syncing, and status printing a phantom pid.
|
|
//
|
|
// So wait for the lock instead of assuming the spawn worked. A caller
|
|
// that gets a pid back can trust that a daemon owns it. In a race the pid
|
|
// is the winner's rather than the child just spawned, which is the honest
|
|
// answer to "which pid is the daemon" — callers that need to distinguish
|
|
// starting from adopting check Running first (see `bdrive resume`).
|
|
deadline := time.Now().Add(startTimeout)
|
|
for {
|
|
if pid, ok := Running(volDir); ok && pid > 0 {
|
|
return pid, nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return 0, fmt.Errorf("daemon did not come up within %s; see %s",
|
|
startTimeout, LogPath(volDir))
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// startTimeout bounds how long Start waits for the child to take the lock and
|
|
// write its pid. Generous on purpose: it covers a cold binary on a loaded
|
|
// machine, and the only cost of waiting is a slower `bdrive init`.
|
|
const startTimeout = 10 * time.Second
|
|
|
|
// 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) {
|
|
// The pid to signal comes from inside the lock, never from daemon.pid: a
|
|
// number in a file the daemon does not own is a SIGKILL primitive against
|
|
// any same-user process, and a -9'd daemon leaves exactly such a number
|
|
// behind with no attacker involved.
|
|
pid, ok := lockPid(volDir)
|
|
if !ok {
|
|
os.Remove(PidPath(volDir))
|
|
return false, nil
|
|
}
|
|
if pid <= 0 {
|
|
return false, fmt.Errorf("a daemon holds %s but it announced no pid; kill it by hand",
|
|
LockPath(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 !locked(LockPath(volDir)) {
|
|
os.Remove(PidPath(volDir))
|
|
return true, nil
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
syscall.Kill(pid, syscall.SIGKILL)
|
|
os.Remove(PidPath(volDir))
|
|
return true, nil
|
|
}
|
|
|
|
// Run is the daemon main loop, executed in the foreground of the (usually
|
|
// detached) `bdrive daemon run` process.
|
|
func Run(folder string, scanInterval, remoteInterval time.Duration) error {
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
|
defer stop()
|
|
|
|
proj, ok, err := config.ResolveMount(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%s is not a beardrive project (run `bdrive init`)", folder)
|
|
}
|
|
volDir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st, err := store.Open(volDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dev, err := config.LoadDevice()
|
|
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).
|
|
lockf, err := holdLock(LockPath(volDir))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release(lockf)
|
|
if err := announce(lockf); err != nil {
|
|
return err
|
|
}
|
|
|
|
// daemon.pid is the human-readable copy (status output, `kill` by hand).
|
|
// 0600: this directory is 0755 and the file decides what gets signalled.
|
|
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(PidPath(volDir))
|
|
|
|
log.Printf("daemon started: folder=%s mount=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
|
|
folder, proj.ID, proj.Volume, proj.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
|
|
|
|
var be remote.Backend
|
|
defer func() {
|
|
if be != nil {
|
|
be.Close()
|
|
}
|
|
}()
|
|
var lastRemote time.Time
|
|
var lastToken string
|
|
// Which access state we last logged, so a degraded daemon says it once
|
|
// instead of on every tick.
|
|
lastAccess := store.AccessOK
|
|
|
|
for {
|
|
// Re-read the project config each tick: picks up `bdrive remote set`
|
|
// and hand-edits. A vanished config means the folder was moved,
|
|
// renamed, or deleted — exit cleanly (propagating nothing); the next
|
|
// bdrive command in the folder's new location resumes the daemon.
|
|
cur, ok, err := config.LoadProject(folder)
|
|
if err != nil || !ok {
|
|
log.Printf("project config gone (folder moved or deleted); exiting")
|
|
return nil
|
|
}
|
|
if cur.ID != proj.ID {
|
|
log.Printf("mount identity changed; exiting")
|
|
return nil
|
|
}
|
|
// If the registry says this mount now lives elsewhere, a new
|
|
// location has taken over — stand down.
|
|
if m, err := config.LoadMounts(); err == nil {
|
|
if mi, ok := m[proj.ID]; ok && mi.Path != folder {
|
|
log.Printf("mount re-registered at %s; exiting", mi.Path)
|
|
return nil
|
|
}
|
|
}
|
|
if cur.Remote != proj.Remote {
|
|
// A running daemon never follows a folder config to a new remote.
|
|
// .bdrive/config.json is untrusted input (anything with write
|
|
// access inside the mount writes it: an agent session, a
|
|
// dependency's install script), and following it moved the whole
|
|
// project — every path, device name and signed-in email in the
|
|
// journal — to a host the user never chose, on the next 3s tick,
|
|
// with no credential needed for a file:// target. The daemon then
|
|
// PULLED from there too, which is an arbitrary write into the
|
|
// mount. Standing down is the same self-heal as a moved folder:
|
|
// the next bdrive command in this folder starts a daemon for
|
|
// whatever it then says, which is a user action.
|
|
log.Printf("remote changed in .bdrive/config.json (%q -> %q); exiting — run bdrive in this folder to resume",
|
|
proj.Remote, cur.Remote)
|
|
return nil
|
|
}
|
|
proj = cur
|
|
|
|
// Re-read settings each tick too, so a login/logout/account switch
|
|
// after the daemon started is reflected in op authorship — otherwise
|
|
// a long-lived daemon stamps every change with a stale identity. The
|
|
// http backend captures its credential at open, so drop it when the
|
|
// token changes and reconnect with the new one.
|
|
settings, _ := config.LoadSettings()
|
|
if settings.Token != lastToken {
|
|
if be != nil {
|
|
be.Close()
|
|
be = nil
|
|
}
|
|
lastToken = settings.Token
|
|
lastRemote = time.Time{}
|
|
}
|
|
|
|
doRemote := proj.Remote != "" && time.Since(lastRemote) >= remoteInterval
|
|
if doRemote && be == nil {
|
|
b, err := remote.Open(ctx, proj.Remote)
|
|
if err != nil {
|
|
log.Printf("remote unavailable: %v", err)
|
|
doRemote = false
|
|
lastRemote = time.Now()
|
|
} else {
|
|
be = b
|
|
}
|
|
}
|
|
|
|
sess := &syncer.Session{Folder: folder, MountID: proj.ID, Store: st, Device: dev, Account: settings}
|
|
if doRemote {
|
|
sess.Backend = be
|
|
}
|
|
res, err := sess.Cycle(ctx)
|
|
switch {
|
|
case ctx.Err() != nil:
|
|
log.Printf("daemon stopping")
|
|
return nil
|
|
case err != nil:
|
|
log.Printf("cycle error: %v", err)
|
|
case res.NoAccess:
|
|
// The connection is fine, the answer isn't: keep the backend and
|
|
// keep ticking cheaply so a re-grant self-heals. Log the
|
|
// transition only — a paused daemon must stay quiet.
|
|
if lastAccess != store.AccessNone {
|
|
log.Printf("access revoked for this project; sync paused (%v)", res.AccessErr)
|
|
lastAccess = store.AccessNone
|
|
}
|
|
lastRemote = time.Now()
|
|
case res.ReadOnly:
|
|
if lastAccess != store.AccessReadOnly {
|
|
if reason := res.Reason(); reason != "" {
|
|
log.Printf("read-only on this project, pulling only; local changes stay on this device (%s)", reason)
|
|
} else {
|
|
log.Printf("read-only on this project, pulling only; local changes stay on this device")
|
|
}
|
|
lastAccess = store.AccessReadOnly
|
|
}
|
|
lastRemote = time.Now()
|
|
case res.Offline:
|
|
log.Printf("offline, will retry: %v", res.OfflineErr)
|
|
if be != nil {
|
|
be.Close()
|
|
be = nil
|
|
}
|
|
lastRemote = time.Now()
|
|
default:
|
|
// "access restored" is a claim about the hub, and a local-only tick
|
|
// never asked it one. Announcing it there is what made a refused
|
|
// device alternate between this line and the read-only one every few
|
|
// seconds: three cheap scan ticks run between remote passes, each
|
|
// cleared the flag, and the next remote pass set it again.
|
|
if doRemote && lastAccess != store.AccessOK {
|
|
log.Printf("access restored; syncing normally")
|
|
lastAccess = store.AccessOK
|
|
}
|
|
if res.Activity() {
|
|
log.Printf("local+%d pulled+%d conflicts=%d files~%d pushed=%v",
|
|
res.LocalOps, res.PulledOps, res.Conflicts, res.Materialized, res.Pushed)
|
|
}
|
|
if doRemote {
|
|
lastRemote = time.Now()
|
|
}
|
|
if res.LocalOps > 0 && !doRemote {
|
|
lastRemote = time.Time{} // push local edits on the next tick
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("daemon stopping")
|
|
return nil
|
|
case <-time.After(scanInterval):
|
|
}
|
|
}
|
|
}
|