fix: make daemon and state cache per-mount, not per-volume

One volume can be mounted at several folders (e.g. ./shared in multiple
repos defaulting to the same volume name). Previously the daemon pidfile
and materialization cache were keyed per volume, so mounts fought over a
single daemon and shared one folder-state cache. Key both by a mount ID
(hash of the absolute folder path); blobs, journals, and the lamport
clock stay shared per volume. Content now propagates between co-mounted
folders even offline. Also default remote polling 30s -> 10s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
snow
2026-06-12 08:46:44 -07:00
co-authored by Claude Fable 5
parent 2caa09702f
commit 27b3adf86f
14 changed files with 237 additions and 32 deletions
+123
View File
@@ -0,0 +1,123 @@
{
"version": "1.0.0",
"lastScanned": 1781278954384,
"projectRoot": "/Users/ssowonny/Workspace/runbear/sfs",
"techStack": {
"languages": [
{
"name": "Go",
"version": null,
"confidence": "high",
"markers": [
"go.mod"
]
}
],
"frameworks": [],
"packageManager": "go",
"runtime": null
},
"build": {
"buildCommand": null,
"testCommand": null,
"lintCommand": null,
"devCommand": null,
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [],
"gitBranches": null
},
"customNotes": [],
"directoryMap": {
"cmd": {
"path": "cmd",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1781278954347,
"keyFiles": []
},
"internal": {
"path": "internal",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1781278954360,
"keyFiles": []
},
"packaging": {
"path": "packaging",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1781278954360,
"keyFiles": []
},
"internal/config": {
"path": "internal/config",
"purpose": "Configuration files",
"fileCount": 1,
"lastAccessed": 1781278954367,
"keyFiles": [
"config.go"
]
}
},
"hotPaths": [
{
"path": "internal/daemon/daemon.go",
"accessCount": 4,
"lastAccessed": 1781279115286,
"type": "file"
},
{
"path": "internal/syncer/syncer.go",
"accessCount": 3,
"lastAccessed": 1781279091569,
"type": "file"
},
{
"path": "internal/config/config.go",
"accessCount": 2,
"lastAccessed": 1781279069157,
"type": "file"
},
{
"path": "cmd/sfs/cmds.go",
"accessCount": 2,
"lastAccessed": 1781279129227,
"type": "file"
},
{
"path": "internal/store/store.go",
"accessCount": 1,
"lastAccessed": 1781279075626,
"type": "file"
},
{
"path": "cmd/sfs/mount.go",
"accessCount": 1,
"lastAccessed": 1781279125661,
"type": "file"
},
{
"path": "internal/store/store_test.go",
"accessCount": 1,
"lastAccessed": 1781279133287,
"type": "file"
},
{
"path": "internal/syncer/syncer_test.go",
"accessCount": 1,
"lastAccessed": 1781279139436,
"type": "file"
}
],
"userDirectives": []
}
@@ -0,0 +1 @@
{"t":0,"agent":"system","event":"keyword_detected","keyword":"wiki"}
+1
View File
@@ -0,0 +1 @@
{"session_id":"d0d876ca-992f-4ad4-98d3-105e3371e069","transcript_path":"/Users/ssowonny/.claude/projects/-Users-ssowonny-Workspace-runbear-sfs/d0d876ca-992f-4ad4-98d3-105e3371e069.jsonl","cwd":"/Users/ssowonny/Workspace/runbear/sfs","effort":{"level":"xhigh"},"model":{"id":"claude-opus-4-7[1m]","display_name":"Opus 4.7 (1M context)"},"workspace":{"current_dir":"/Users/ssowonny/Workspace/runbear/sfs","project_dir":"/Users/ssowonny/Workspace/runbear/sfs","added_dirs":[]},"version":"2.1.141","output_style":{"name":"default"},"cost":{"total_cost_usd":0,"total_duration_ms":294,"total_api_duration_ms":0,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":0,"total_output_tokens":0,"context_window_size":1000000,"current_usage":null,"used_percentage":null,"remaining_percentage":null},"exceeds_200k_tokens":false,"fast_mode":false,"thinking":{"enabled":true}}
+3
View File
@@ -0,0 +1,3 @@
{
"lastSentAt": "2026-06-12T15:37:47.380Z"
}
+7
View File
@@ -0,0 +1,7 @@
{
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"gcloud storage buckets create gs://runbear-wiki --project=runbear-local --location=us-west1 --uniform-bucket-level-access 2>&1 && gcloud config set project runbear-local 2>&1 | tail -1\",\"d...",
"error": "Exit code 1\nCreating gs://runbear-wiki/...\nERROR: (gcloud.storage.buckets.create) HTTPError 403: snow@runbear.io does not have storage.buckets.create access to the Google Cloud project. Permission 'storage.buckets.create' denied on resource '//storage.googleapis.com/projects/_/buckets/runbear-wiki' (or it may not exist). This command is authenticated as snow@runbear.io which is the active account specified by the [core/account] property.",
"timestamp": "2026-06-12T15:32:54.007Z",
"retry_count": 1
}
+3 -2
View File
@@ -129,8 +129,9 @@ working folder ←materialize/scan→ local volume store ←push/pull→ obj
Concurrent edits keep the last writer at the path; the loser is preserved
as a conflict-copy file by the device that detects the overlap.
- A per-mount **daemon** scans the folder every few seconds (cheap
size+mtime check) and exchanges with the remote every ~30s — or
immediately after local edits.
size+mtime check) and exchanges with the remote every ~10s — or
immediately after local edits. Tune with --scan-interval and
--remote-interval on `sfs mnt`.
### What sfs does not sync
+3 -3
View File
@@ -86,7 +86,7 @@ func statusCmd() *cobra.Command {
if err != nil {
return err
}
if pid, ok := daemon.Running(vdir); ok {
if pid, ok := daemon.Running(vdir, config.MountID(folder)); ok {
fmt.Printf(" daemon: running (pid %d)\n", pid)
} else {
fmt.Printf(" daemon: stopped\n")
@@ -95,7 +95,7 @@ func statusCmd() *cobra.Command {
if err != nil {
continue
}
cache, err := sess.Store.LoadCache()
cache, err := sess.Store.LoadCache(config.MountID(folder))
if err == nil {
var total int64
for _, c := range cache {
@@ -245,7 +245,7 @@ func daemonCmd() *cobra.Command {
},
}
run.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "local scan interval")
run.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "remote sync interval")
run.Flags().DurationVar(&remoteInterval, "remote-interval", 10*time.Second, "remote sync interval")
c.AddCommand(run)
return c
}
+2 -2
View File
@@ -113,7 +113,7 @@ daemon keeps the folder in sync until "sfs umnt".`,
c.Flags().StringVarP(&volume, "volume", "v", "", "volume name (default: folder basename)")
c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground")
c.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "how often to scan the folder for local changes")
c.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "how often to sync with the remote")
c.Flags().DurationVar(&remoteInterval, "remote-interval", 10*time.Second, "how often to sync with the remote")
return c
}
@@ -142,7 +142,7 @@ volume data under ~/.sfs/volumes is still kept).`,
if err != nil {
return err
}
stopped, err := daemon.Stop(vdir)
stopped, err := daemon.Stop(vdir, config.MountID(folder))
if err != nil {
return err
}
+10
View File
@@ -5,6 +5,7 @@ package config
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
@@ -136,6 +137,15 @@ func SaveMounts(m map[string]MountInfo) error {
return writeJSON(p, m)
}
// MountID derives a stable identifier for a mounted folder. One volume can
// be mounted at several folders (even on the same device); everything
// folder-specific — the sync daemon and the materialization cache — is keyed
// by this ID, while blobs and journals stay shared per volume.
func MountID(folder string) string {
sum := sha256.Sum256([]byte(folder))
return hex.EncodeToString(sum[:])[:12]
}
// VolumeDir returns (and creates parents for) the local store dir of a volume.
func VolumeDir(volume string) (string, error) {
home, err := Home()
+25 -16
View File
@@ -26,12 +26,19 @@ import (
"github.com/runbear-io/sfs/internal/syncer"
)
func PidPath(volDir string) string { return filepath.Join(volDir, "daemon.pid") }
func LogPath(volDir string) string { return filepath.Join(volDir, "daemon.log") }
// Daemons are per mount, not per volume: one volume may be mounted at
// several folders (each gets its own daemon), so pid/log files are keyed by
// the mount ID.
func PidPath(volDir, mountID string) string {
return filepath.Join(volDir, "daemon-"+mountID+".pid")
}
func LogPath(volDir, mountID string) string {
return filepath.Join(volDir, "daemon-"+mountID+".log")
}
// Running reports the daemon pid for a volume if one is alive.
func Running(volDir string) (int, bool) {
data, err := os.ReadFile(PidPath(volDir))
// Running reports the daemon pid for a mount if one is alive.
func Running(volDir, mountID string) (int, bool) {
data, err := os.ReadFile(PidPath(volDir, mountID))
if err != nil {
return 0, false
}
@@ -47,14 +54,15 @@ func Running(volDir string) (int, bool) {
// 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 {
mountID := config.MountID(folder)
if pid, ok := Running(volDir, mountID); ok {
return pid, nil
}
exe, err := os.Executable()
if err != nil {
return 0, err
}
logf, err := os.OpenFile(LogPath(volDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
logf, err := os.OpenFile(LogPath(volDir, mountID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return 0, err
}
@@ -69,17 +77,17 @@ func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (i
return 0, err
}
pid := cmd.Process.Pid
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
if err := os.WriteFile(PidPath(volDir, mountID), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
return pid, err
}
return pid, cmd.Process.Release()
}
// Stop terminates the daemon for a volume and waits for it to exit.
func Stop(volDir string) (bool, error) {
pid, ok := Running(volDir)
// Stop terminates the daemon for a mount and waits for it to exit.
func Stop(volDir, mountID string) (bool, error) {
pid, ok := Running(volDir, mountID)
if !ok {
os.Remove(PidPath(volDir))
os.Remove(PidPath(volDir, mountID))
return false, nil
}
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
@@ -88,13 +96,13 @@ func Stop(volDir string) (bool, error) {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := syscall.Kill(pid, 0); err != nil {
os.Remove(PidPath(volDir))
os.Remove(PidPath(volDir, mountID))
return true, nil
}
time.Sleep(100 * time.Millisecond)
}
syscall.Kill(pid, syscall.SIGKILL)
os.Remove(PidPath(volDir))
os.Remove(PidPath(volDir, mountID))
return true, nil
}
@@ -124,10 +132,11 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
if err != nil {
return err
}
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
mountID := config.MountID(folder)
if err := os.WriteFile(PidPath(volDir, mountID), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
return err
}
defer os.Remove(PidPath(volDir))
defer os.Remove(PidPath(volDir, mountID))
log.Printf("daemon started: folder=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
folder, mi.Volume, mi.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
+10 -5
View File
@@ -154,7 +154,8 @@ func (s *Store) AllOps() ([]journal.Op, error) {
// CachedFile records what sfs last wrote to / observed in the working folder
// for a path. Size+MTimeNS make change detection cheap; Blob ties it back to
// content.
// content. The cache is per mount (one volume can be materialized into
// several folders, each with its own stat fingerprints).
type CachedFile struct {
Blob string `json:"blob"`
Size int64 `json:"size"`
@@ -162,16 +163,20 @@ type CachedFile struct {
MTimeNS int64 `json:"mtime_ns"`
}
func (s *Store) LoadCache() (map[string]CachedFile, error) {
func (s *Store) cachePath(mountID string) string {
return filepath.Join(s.dir, "state-"+mountID+".json")
}
func (s *Store) LoadCache(mountID string) (map[string]CachedFile, error) {
out := map[string]CachedFile{}
if err := readJSON(filepath.Join(s.dir, "state.json"), &out); err != nil {
if err := readJSON(s.cachePath(mountID), &out); err != nil {
return nil, err
}
return out, nil
}
func (s *Store) SaveCache(c map[string]CachedFile) error {
return WriteJSONAtomic(filepath.Join(s.dir, "state.json"), c)
func (s *Store) SaveCache(mountID string, c map[string]CachedFile) error {
return WriteJSONAtomic(s.cachePath(mountID), c)
}
// ---- sync state (sync.json) ----
+7 -2
View File
@@ -61,13 +61,18 @@ func TestJournalAndState(t *testing.T) {
}
cache := map[string]CachedFile{"f": {Blob: "b", Size: 1, MTimeNS: 42}}
if err := s.SaveCache(cache); err != nil {
if err := s.SaveCache("m1", cache); err != nil {
t.Fatal(err)
}
got, err := s.LoadCache()
got, err := s.LoadCache("m1")
if err != nil || got["f"].MTimeNS != 42 {
t.Fatalf("cache roundtrip: %v %v", got, err)
}
// caches are isolated per mount
other, err := s.LoadCache("m2")
if err != nil || len(other) != 0 {
t.Fatalf("mount caches must be isolated: %v %v", other, err)
}
st := SyncState{Lamport: 7, PushedOps: 3}
if err := s.SaveSync(st); err != nil {
+10 -2
View File
@@ -32,11 +32,19 @@ import (
// Session ties a working folder to its volume store and (optionally) remote.
type Session struct {
Folder string
MountID string // distinguishes folders sharing one volume; see config.MountID
Store *store.Store
Device config.Device
Backend remote.Backend // nil = work offline
}
func (s *Session) mountID() string {
if s.MountID != "" {
return s.MountID
}
return config.MountID(s.Folder)
}
// Result summarizes one sync cycle.
type Result struct {
LocalOps int // local changes committed to the journal
@@ -68,7 +76,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
defer unlock()
res := &Result{}
cache, err := s.Store.LoadCache()
cache, err := s.Store.LoadCache(s.mountID())
if err != nil {
return nil, fmt.Errorf("load state: %w", err)
}
@@ -147,7 +155,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
}
}
if err := s.Store.SaveCache(cache); err != nil {
if err := s.Store.SaveCache(s.mountID(), cache); err != nil {
return nil, err
}
if err := s.Store.SaveSync(st); err != nil {
+32
View File
@@ -245,6 +245,38 @@ func TestOfflineThenReconnect(t *testing.T) {
}
}
func TestSameVolumeMountedAtTwoFolders(t *testing.T) {
// One device mounts the same volume at two folders (e.g. ./shared in two
// repos). They share the store (blobs+journals) but have separate mount
// caches, and content propagates between them even with no remote.
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
if err != nil {
t.Fatal(err)
}
dev := config.Device{ID: "dev1", Name: "dev1", Author: "dev1@test"}
m1 := &Session{Folder: t.TempDir(), MountID: "mount1", Store: st, Device: dev}
m2 := &Session{Folder: t.TempDir(), MountID: "mount2", Store: st, Device: dev}
write(t, m1.Folder, "shared.md", "from folder one")
cycle(t, m1)
res := cycle(t, m2)
if res.Materialized != 1 {
t.Fatalf("folder two should materialize the file: %+v", res)
}
if read(t, m2.Folder, "shared.md") != "from folder one" {
t.Fatal("content did not propagate between mounts")
}
// edit in folder two propagates back
time.Sleep(10 * time.Millisecond)
write(t, m2.Folder, "shared.md", "edited in folder two")
cycle(t, m2)
cycle(t, m1)
if read(t, m1.Folder, "shared.md") != "edited in folder two" {
t.Fatal("edit did not propagate back to folder one")
}
}
func TestExecutableBitPreserved(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)