Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3834,6 +3834,8 @@ func runMount(args []string) error {
remotePath := fs.String("remote-path", envOrDefault("RELAYFILE_REMOTE_PATH", "/"), "remote root path")
eventProvider := fs.String("provider", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_PROVIDER")), "event provider filter")
stateFile := fs.String("state-file", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_STATE_FILE")), "state file path")
stateDir := fs.String("state-dir", envOrDefault("RELAYFILE_MOUNT_STATE_DIR", mountsync.DefaultMountStateDir()), "directory for private mount state")
mountKind := fs.String("mount-kind", envOrDefault("RELAYFILE_MOUNT_KIND", mountsync.MountKindDaemon), "private state identity kind: daemon, flush, or initial-sync")
localDirFlag := fs.String("local-dir", "", "local mirror directory")
mode := fs.String("mode", envOrDefault("RELAYFILE_MOUNT_MODE", defaultMountMode), "mount mode: poll (recommended) or fuse")
interval := fs.Duration("interval", durationEnv("RELAYFILE_MOUNT_INTERVAL", defaultMountInterval), "sync interval")
Expand All @@ -3859,6 +3861,8 @@ func runMount(args []string) error {
"remote-path": true,
"provider": true,
"state-file": true,
"state-dir": true,
"mount-kind": true,
"mode": true,
"interval": true,
"interval-jitter": true,
Expand Down Expand Up @@ -4061,6 +4065,9 @@ func runMount(args []string) error {
EventProvider: strings.TrimSpace(*eventProvider),
LocalRoot: absLocalDir,
StateFile: strings.TrimSpace(*stateFile),
StateDir: strings.TrimSpace(*stateDir),
MountKind: strings.TrimSpace(*mountKind),
ValidateState: true,
WebSocket: boolPtr(*websocketEnabled),
LowMemory: boolPtr(*lowMemory),
RootCtx: rootCtx,
Expand Down Expand Up @@ -4149,6 +4156,8 @@ Common flags:
hard cap for initial/full-tree bootstrap (0 = progress-based)
--cursor-timeout 20s timeout for events-cursor resolution
--full-reconcile force one full reconcile regardless of bootstrap state
--state-dir DIR private mount state directory (default $HOME/.relayfile-mount-state)
--state-file FILE exact private state file override; wins over --state-dir
--rehome allow moving an already-registered mirror to a new LOCAL_DIR
--no-websocket disable websocket event streaming
--low-memory skip detailed per-file public state and defer content reads
Expand Down
2 changes: 2 additions & 0 deletions cmd/relayfile-cli/setup_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ func TestA13MountHelpListsSyncedMirrorLimitations(t *testing.T) {
"Directory listings can briefly omit",
"inotify/fsevents",
"--mode poll|fuse",
"--state-dir DIR",
"--state-file FILE",
} {
if !strings.Contains(got, fragment) {
t.Fatalf("expected mount --help to contain %q, got:\n%s", fragment, got)
Expand Down
31 changes: 13 additions & 18 deletions cmd/relayfile-mount/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type mountConfig struct {
eventProvider string
localDir string
stateFile string
stateDir string
mountKind string
interval time.Duration
intervalJitter float64
timeout time.Duration
Expand Down Expand Up @@ -73,6 +75,8 @@ func main() {
eventProvider := flag.String("provider", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_PROVIDER")), "event provider filter")
localDir := flag.String("local-dir", strings.TrimSpace(os.Getenv("RELAYFILE_LOCAL_DIR")), "local mirror directory")
stateFile := flag.String("state-file", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_STATE_FILE")), "state file path")
stateDir := flag.String("state-dir", envOrDefault("RELAYFILE_MOUNT_STATE_DIR", mountsync.DefaultMountStateDir()), "directory for private mount state")
mountKind := flag.String("mount-kind", envOrDefault("RELAYFILE_MOUNT_KIND", mountsync.MountKindDaemon), "private state identity kind: daemon, flush, or initial-sync")
interval := flag.Duration("interval", durationEnv("RELAYFILE_MOUNT_INTERVAL", 30*time.Second), "sync interval")
intervalJitter := flag.Float64("interval-jitter", floatEnv("RELAYFILE_MOUNT_INTERVAL_JITTER", 0.2), "sync interval jitter ratio (0.0-1.0)")
timeout := flag.Duration("timeout", durationEnv("RELAYFILE_MOUNT_TIMEOUT", 15*time.Second), "per-sync timeout")
Expand Down Expand Up @@ -128,6 +132,8 @@ func main() {
eventProvider: strings.TrimSpace(*eventProvider),
localDir: *localDir,
stateFile: *stateFile,
stateDir: *stateDir,
mountKind: *mountKind,
interval: *interval,
intervalJitter: *intervalJitter,
timeout: *timeout,
Expand Down Expand Up @@ -205,6 +211,9 @@ func runScopedPollingMountsWithRunner(
}
scopedMounts := make([]scopedMount, 0, len(remotePaths))
seen := map[string]struct{}{}
if len(remotePaths) > 1 && strings.TrimSpace(cfg.stateFile) != "" {
return fmt.Errorf("--state-file cannot be shared across multiple scoped mounts; use --state-dir instead")
}
for _, remotePath := range remotePaths {
remotePath := normalizeMountRemotePath(remotePath)
if _, ok := seen[remotePath]; ok {
Expand All @@ -215,7 +224,7 @@ func runScopedPollingMountsWithRunner(
scoped.remotePath = remotePath
scoped.remotePaths = nil
scoped.localDir = scopedLocalDir(cfg.localDir, remotePath)
scoped.stateFile = scopedStateFile(cfg.stateFile, remotePath)
scoped.stateFile = cfg.stateFile
Comment thread
khaliqgant marked this conversation as resolved.
if err := os.MkdirAll(scoped.localDir, 0o755); err != nil {
return fmt.Errorf("create scoped local dir for %s: %w", remotePath, err)
}
Expand Down Expand Up @@ -294,6 +303,9 @@ func runSinglePollingMount(rootCtx context.Context, cfg mountConfig) error {
EventProvider: cfg.eventProvider,
LocalRoot: cfg.localDir,
StateFile: cfg.stateFile,
StateDir: cfg.stateDir,
MountKind: cfg.mountKind,
ValidateState: true,
Scopes: cfg.scopes,
WebSocket: boolPtr(cfg.websocketEnabled),
RootCtx: rootCtx,
Expand Down Expand Up @@ -457,23 +469,6 @@ func scopedLocalDir(localRoot, remotePath string) string {
return filepath.Join(localRoot, filepath.FromSlash(strings.TrimPrefix(remotePath, "/")))
}

func scopedStateFile(stateFile, remotePath string) string {
if strings.TrimSpace(stateFile) == "" {
return ""
}
remotePath = normalizeMountRemotePath(remotePath)
if remotePath == "/" {
return stateFile
}
ext := filepath.Ext(stateFile)
base := strings.TrimSuffix(stateFile, ext)
suffix := strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(strings.Trim(remotePath, "/"))
if suffix == "" {
suffix = "root"
}
return base + "-" + suffix + ext
}

// readBootstrapProgress reads the in-progress bootstrap block from the
// mountsync public state file. ok is false when there is no bootstrap in
// progress (or the file is missing/unparseable).
Expand Down
54 changes: 53 additions & 1 deletion cmd/relayfile-mount/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,58 @@ func TestScopedLocalDirKeepsProviderPrefixUnderMountRoot(t *testing.T) {
}
}

func TestRunScopedPollingMountsKeepsSharedStateDirForHashResolver(t *testing.T) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
stateDir := t.TempDir()
var gotMu sync.Mutex
var got []mountConfig

err := runScopedPollingMountsWithRunner(
context.Background(),
mountConfig{localDir: t.TempDir(), stateDir: stateDir},
[]string{"/github", "/slack"},
func(_ context.Context, cfg mountConfig) error {
gotMu.Lock()
defer gotMu.Unlock()
got = append(got, cfg)
return nil
},
)
if err != nil {
t.Fatalf("runScopedPollingMountsWithRunner returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 scoped mounts, got %d", len(got))
}
for _, cfg := range got {
if cfg.stateDir != stateDir {
t.Fatalf("expected state dir %q, got %q", stateDir, cfg.stateDir)
}
if cfg.stateFile != "" {
t.Fatalf("expected state-file to stay empty so mountsync derives hashed path, got %q", cfg.stateFile)
}
}
}

func TestRunScopedPollingMountsRejectsSharedExactStateFileOverride(t *testing.T) {
stateFile := filepath.Join(t.TempDir(), "state.json")

err := runScopedPollingMountsWithRunner(
context.Background(),
mountConfig{localDir: t.TempDir(), stateDir: t.TempDir(), stateFile: stateFile},
[]string{"/github", "/slack"},
func(_ context.Context, cfg mountConfig) error {
t.Fatalf("runner should not start with shared state-file override: %+v", cfg)
return nil
},
)
if err == nil {
t.Fatal("expected shared state-file override to be rejected")
}
if !strings.Contains(err.Error(), "use --state-dir") {
t.Fatalf("expected state-dir guidance, got %v", err)
}
}

func TestRunScopedPollingMountsCancelsSiblingsOnFirstError(t *testing.T) {
wantErr := errors.New("boom")
var canceled atomic.Bool
Expand All @@ -257,7 +309,7 @@ func TestRunScopedPollingMountsCancelsSiblingsOnFirstError(t *testing.T) {

err := runScopedPollingMountsWithRunner(
context.Background(),
mountConfig{localDir: t.TempDir(), stateFile: filepath.Join(t.TempDir(), "state.json")},
mountConfig{localDir: t.TempDir(), stateDir: t.TempDir()},
[]string{"/github", "/slack"},
func(ctx context.Context, cfg mountConfig) error {
started <- cfg.remotePath
Expand Down
Loading
Loading