fix: relocate daemon socket for deep paths (GH#1001)
On Unix systems, socket paths are limited to 104 chars (macOS) or 108 chars
(Linux). Deep workspace paths like /Volumes/External Drive/Dropbox/...
would exceed this limit and cause daemon startup failures.
This fix:
- Adds ShortSocketPath() which computes /tmp/beads-{hash}/bd.sock for
paths that would exceed the limit
- Keeps backward compatibility: short paths still use .beads/bd.sock
- Updates daemon discovery to check both locations
- Uses SHA256 hash of canonical workspace path for unique directories
Closes GH#1001
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -457,13 +457,17 @@ func recordDaemonStartFailure() {
|
|||||||
|
|
||||||
// getSocketPath returns the daemon socket path based on the database location.
|
// getSocketPath returns the daemon socket path based on the database location.
|
||||||
// If BD_SOCKET env var is set, uses that value instead (enables test isolation).
|
// If BD_SOCKET env var is set, uses that value instead (enables test isolation).
|
||||||
// Returns local socket path (.beads/bd.sock relative to database)
|
// On Unix systems, uses rpc.ShortSocketPath to avoid exceeding socket path limits
|
||||||
|
// (macOS: 104 chars) by relocating long paths to /tmp/beads-{hash}/ (GH#1001).
|
||||||
func getSocketPath() string {
|
func getSocketPath() string {
|
||||||
// Check environment variable first (enables test isolation)
|
// Check environment variable first (enables test isolation)
|
||||||
if socketPath := os.Getenv("BD_SOCKET"); socketPath != "" {
|
if socketPath := os.Getenv("BD_SOCKET"); socketPath != "" {
|
||||||
return socketPath
|
return socketPath
|
||||||
}
|
}
|
||||||
return filepath.Join(filepath.Dir(dbPath), "bd.sock")
|
// Get workspace path (parent of .beads directory)
|
||||||
|
beadsDir := filepath.Dir(dbPath)
|
||||||
|
workspacePath := filepath.Dir(beadsDir)
|
||||||
|
return rpc.ShortSocketPath(workspacePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// emitVerboseWarning prints a one-line warning when falling back to direct mode
|
// emitVerboseWarning prints a one-line warning when falling back to direct mode
|
||||||
|
|||||||
@@ -340,6 +340,11 @@ func TestDaemonAutostart_RestartDaemonForVersionMismatch_Stubbed(t *testing.T) {
|
|||||||
t.Fatalf("getPIDFilePath: %v", err)
|
t.Fatalf("getPIDFilePath: %v", err)
|
||||||
}
|
}
|
||||||
sock := getSocketPath()
|
sock := getSocketPath()
|
||||||
|
// Create socket directory if needed (GH#1001 - socket may be in /tmp/beads-{hash}/)
|
||||||
|
sockDir := filepath.Dir(sock)
|
||||||
|
if err := os.MkdirAll(sockDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("MkdirAll sockDir: %v", err)
|
||||||
|
}
|
||||||
if err := os.WriteFile(pidFile, []byte("999999\n"), 0o600); err != nil {
|
if err := os.WriteFile(pidFile, []byte("999999\n"), 0o600); err != nil {
|
||||||
t.Fatalf("WriteFile pid: %v", err)
|
t.Fatalf("WriteFile pid: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,15 +214,28 @@ func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
|
|||||||
// For worktrees, .beads is in the main repository root, not the worktree
|
// For worktrees, .beads is in the main repository root, not the worktree
|
||||||
beadsDir := findBeadsDirForWorkspace(workspacePath)
|
beadsDir := findBeadsDirForWorkspace(workspacePath)
|
||||||
|
|
||||||
// First try the socket in the determined .beads directory
|
// Try short socket path first (GH#1001 - avoids macOS 104-char limit)
|
||||||
socketPath := filepath.Join(beadsDir, "bd.sock")
|
// This is computed from the workspace path, not the beads dir
|
||||||
if _, err := os.Stat(socketPath); err == nil {
|
mainWorkspace := filepath.Dir(beadsDir) // Get workspace from .beads dir
|
||||||
daemon := discoverDaemon(socketPath)
|
shortSocketPath := rpc.ShortSocketPath(mainWorkspace)
|
||||||
|
if _, err := os.Stat(shortSocketPath); err == nil {
|
||||||
|
daemon := discoverDaemon(shortSocketPath)
|
||||||
if daemon.Alive {
|
if daemon.Alive {
|
||||||
return &daemon, nil
|
return &daemon, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try legacy socket path in .beads directory (backwards compatibility)
|
||||||
|
legacySocketPath := filepath.Join(beadsDir, "bd.sock")
|
||||||
|
if legacySocketPath != shortSocketPath {
|
||||||
|
if _, err := os.Stat(legacySocketPath); err == nil {
|
||||||
|
daemon := discoverDaemon(legacySocketPath)
|
||||||
|
if daemon.Alive {
|
||||||
|
return &daemon, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to discovering all daemons
|
// Fall back to discovering all daemons
|
||||||
daemons, err := DiscoverDaemons([]string{workspacePath})
|
daemons, err := DiscoverDaemons([]string{workspacePath})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
112
internal/rpc/socket_path.go
Normal file
112
internal/rpc/socket_path.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxUnixSocketPath is the maximum length for Unix socket paths.
|
||||||
|
// macOS has a 104-byte limit (including null terminator), Linux has 108.
|
||||||
|
// We use 103 to be safe across platforms.
|
||||||
|
const MaxUnixSocketPath = 103
|
||||||
|
|
||||||
|
// ShortSocketPath returns a short socket path suitable for Unix sockets.
|
||||||
|
// On Unix systems with socket path length limits (macOS: 104 chars, Linux: 108),
|
||||||
|
// this function returns a path in /tmp/beads-{hash}/ to avoid exceeding limits.
|
||||||
|
//
|
||||||
|
// The hash is derived from the canonicalized workspace path, ensuring:
|
||||||
|
// - Different workspaces get different socket directories
|
||||||
|
// - The same workspace always gets the same hash (deterministic)
|
||||||
|
// - Symlinks and case differences resolve to the same hash
|
||||||
|
//
|
||||||
|
// If the computed .beads/bd.sock path is short enough, it returns that directly.
|
||||||
|
// This preserves backwards compatibility for workspaces with short paths.
|
||||||
|
func ShortSocketPath(workspacePath string) string {
|
||||||
|
// Canonicalize path for consistent hashing across symlinks and case
|
||||||
|
canonical := utils.NormalizePathForComparison(workspacePath)
|
||||||
|
if canonical == "" {
|
||||||
|
canonical = workspacePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the "natural" socket path in .beads/
|
||||||
|
naturalPath := filepath.Join(workspacePath, ".beads", "bd.sock")
|
||||||
|
|
||||||
|
// If natural path is short enough, use it (backwards compatible)
|
||||||
|
if len(naturalPath) <= MaxUnixSocketPath {
|
||||||
|
return naturalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path too long - use /tmp with hash
|
||||||
|
return shortSocketDir(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortSocketDir returns a socket path in /tmp/beads-{hash}/.
|
||||||
|
// The hash is 8 hex characters derived from SHA256 of the workspace path.
|
||||||
|
func shortSocketDir(canonicalPath string) string {
|
||||||
|
hash := sha256.Sum256([]byte(canonicalPath))
|
||||||
|
hashStr := hex.EncodeToString(hash[:4]) // 8 hex chars from 4 bytes
|
||||||
|
|
||||||
|
dir := filepath.Join(tmpDir(), "beads-"+hashStr)
|
||||||
|
return filepath.Join(dir, "bd.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmpDir returns the appropriate temp directory for sockets.
|
||||||
|
// On macOS, /tmp is a symlink to /private/tmp, but /tmp is shorter.
|
||||||
|
func tmpDir() string {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
// On macOS, prefer /tmp over $TMPDIR which can be long
|
||||||
|
// (/var/folders/xx/xxxxxxxxxxxx/T/)
|
||||||
|
return "/tmp"
|
||||||
|
}
|
||||||
|
// On Linux and other Unix, use /tmp
|
||||||
|
return "/tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSocketDir creates the socket directory if it doesn't exist.
|
||||||
|
// Returns the socket path (unchanged) and any error.
|
||||||
|
// This should be called by the daemon before listening.
|
||||||
|
func EnsureSocketDir(socketPath string) (string, error) {
|
||||||
|
dir := filepath.Dir(socketPath)
|
||||||
|
|
||||||
|
// Only create if it's a /tmp/beads-* directory
|
||||||
|
// Don't create .beads directories - those should exist
|
||||||
|
if strings.HasPrefix(dir, filepath.Join(tmpDir(), "beads-")) {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return socketPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupSocketDir removes the socket directory if it's in /tmp/beads-*.
|
||||||
|
// This should be called when the daemon shuts down.
|
||||||
|
func CleanupSocketDir(socketPath string) error {
|
||||||
|
dir := filepath.Dir(socketPath)
|
||||||
|
|
||||||
|
// Only remove if it's a /tmp/beads-* directory we created
|
||||||
|
if strings.HasPrefix(dir, filepath.Join(tmpDir(), "beads-")) {
|
||||||
|
// Remove socket file first
|
||||||
|
_ = os.Remove(socketPath)
|
||||||
|
// Remove directory (will fail if not empty, which is fine)
|
||||||
|
return os.Remove(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For .beads/ directories, just remove the socket file
|
||||||
|
return os.Remove(socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsShortPath returns true if the workspace path would result in a socket
|
||||||
|
// path exceeding Unix limits.
|
||||||
|
func NeedsShortPath(workspacePath string) bool {
|
||||||
|
naturalPath := filepath.Join(workspacePath, ".beads", "bd.sock")
|
||||||
|
return len(naturalPath) > MaxUnixSocketPath
|
||||||
|
}
|
||||||
159
internal/rpc/socket_path_test.go
Normal file
159
internal/rpc/socket_path_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShortSocketPath_ShortPath(t *testing.T) {
|
||||||
|
// Short paths should use the natural .beads/bd.sock location
|
||||||
|
workspacePath := "/tmp/myrepo"
|
||||||
|
socketPath := ShortSocketPath(workspacePath)
|
||||||
|
|
||||||
|
expected := filepath.Join(workspacePath, ".beads", "bd.sock")
|
||||||
|
if socketPath != expected {
|
||||||
|
t.Errorf("ShortSocketPath(%q) = %q, want %q", workspacePath, socketPath, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortSocketPath_LongPath(t *testing.T) {
|
||||||
|
// Long paths should use /tmp/beads-{hash}/bd.sock
|
||||||
|
// Create a path that's definitely over 103 chars when .beads/bd.sock is added
|
||||||
|
longPath := "/Volumes/External Drive/Dropbox/Projects/Clients/Company/product-name-with-extra-long-name"
|
||||||
|
socketPath := ShortSocketPath(longPath)
|
||||||
|
|
||||||
|
// Should be relocated to /tmp
|
||||||
|
if !strings.HasPrefix(socketPath, "/tmp/beads-") {
|
||||||
|
t.Errorf("ShortSocketPath(%q) = %q, want path starting with /tmp/beads-", longPath, socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should end with bd.sock
|
||||||
|
if !strings.HasSuffix(socketPath, "/bd.sock") {
|
||||||
|
t.Errorf("ShortSocketPath(%q) = %q, want path ending with /bd.sock", longPath, socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path should be short enough
|
||||||
|
if len(socketPath) > MaxUnixSocketPath {
|
||||||
|
t.Errorf("ShortSocketPath(%q) = %q (len=%d), want len <= %d", longPath, socketPath, len(socketPath), MaxUnixSocketPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortSocketPath_Deterministic(t *testing.T) {
|
||||||
|
// Same workspace should always produce same socket path
|
||||||
|
workspacePath := "/Volumes/External Drive/Some/Long/Path/To/A/Repository"
|
||||||
|
path1 := ShortSocketPath(workspacePath)
|
||||||
|
path2 := ShortSocketPath(workspacePath)
|
||||||
|
|
||||||
|
if path1 != path2 {
|
||||||
|
t.Errorf("ShortSocketPath is not deterministic: %q != %q", path1, path2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortSocketPath_DifferentWorkspaces(t *testing.T) {
|
||||||
|
// Different workspaces should produce different socket paths
|
||||||
|
workspace1 := "/Volumes/External/Project1/With/Long/Path/Here"
|
||||||
|
workspace2 := "/Volumes/External/Project2/With/Long/Path/Here"
|
||||||
|
|
||||||
|
path1 := ShortSocketPath(workspace1)
|
||||||
|
path2 := ShortSocketPath(workspace2)
|
||||||
|
|
||||||
|
if path1 == path2 {
|
||||||
|
t.Errorf("Different workspaces should produce different socket paths: both got %q", path1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsShortPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
workspace string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "short path",
|
||||||
|
workspace: "/tmp/myrepo",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "medium path",
|
||||||
|
workspace: "/Users/john/projects/myrepo",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long path exceeding limit",
|
||||||
|
workspace: "/Volumes/External Drive/Dropbox/Projects/Clients/Company/product-name-with-extra-characters-to-exceed-limit",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := NeedsShortPath(tt.workspace)
|
||||||
|
if got != tt.want {
|
||||||
|
naturalPath := filepath.Join(tt.workspace, ".beads", "bd.sock")
|
||||||
|
t.Errorf("NeedsShortPath(%q) = %v, want %v (natural path len=%d, limit=%d)",
|
||||||
|
tt.workspace, got, tt.want, len(naturalPath), MaxUnixSocketPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSocketDir(t *testing.T) {
|
||||||
|
// Test creating a /tmp/beads-* directory
|
||||||
|
// Manually simulate the condition where we need to create the directory
|
||||||
|
// by using a path format that matches our pattern
|
||||||
|
testSocketPath := filepath.Join("/tmp", "beads-testxyz", "bd.sock")
|
||||||
|
|
||||||
|
result, err := EnsureSocketDir(testSocketPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureSocketDir failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != testSocketPath {
|
||||||
|
t.Errorf("EnsureSocketDir returned %q, want %q", result, testSocketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
_ = os.RemoveAll(filepath.Dir(testSocketPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupSocketDir(t *testing.T) {
|
||||||
|
// Create a test directory in /tmp
|
||||||
|
testDir := filepath.Join("/tmp", "beads-cleanup-test")
|
||||||
|
if err := os.MkdirAll(testDir, 0700); err != nil {
|
||||||
|
t.Fatalf("Failed to create test dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
socketPath := filepath.Join(testDir, "bd.sock")
|
||||||
|
if err := os.WriteFile(socketPath, []byte("test"), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create test socket file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
if err := CleanupSocketDir(socketPath); err != nil {
|
||||||
|
t.Errorf("CleanupSocketDir failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory should be removed
|
||||||
|
if _, err := os.Stat(testDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("Directory %s should have been removed", testDir)
|
||||||
|
_ = os.RemoveAll(testDir) // Clean up for next run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortSocketPath_EdgeCase_ExactLimit(t *testing.T) {
|
||||||
|
// Test a path that's exactly at the limit
|
||||||
|
// .beads/bd.sock adds 15 characters
|
||||||
|
// So a workspace path of 88 chars + 15 = 103 (exactly at limit)
|
||||||
|
workspace := strings.Repeat("x", 88)
|
||||||
|
socketPath := ShortSocketPath(workspace)
|
||||||
|
|
||||||
|
// Should use natural path since it's exactly at the limit
|
||||||
|
expected := filepath.Join(workspace, ".beads", "bd.sock")
|
||||||
|
if socketPath != expected {
|
||||||
|
t.Errorf("Path at exact limit should use natural path.\nGot: %q\nWant: %q\nLen: %d", socketPath, expected, len(expected))
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/rpc/socket_path_windows.go
Normal file
35
internal/rpc/socket_path_windows.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxUnixSocketPath is not applicable on Windows (uses TCP).
|
||||||
|
// Kept for API compatibility.
|
||||||
|
const MaxUnixSocketPath = 103
|
||||||
|
|
||||||
|
// ShortSocketPath returns the socket path for Windows.
|
||||||
|
// Windows uses TCP instead of Unix sockets, so path length is not a concern.
|
||||||
|
// The "socket path" is actually a file containing the TCP endpoint info.
|
||||||
|
func ShortSocketPath(workspacePath string) string {
|
||||||
|
return filepath.Join(workspacePath, ".beads", "bd.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSocketDir is a no-op on Windows since the .beads directory
|
||||||
|
// should already exist.
|
||||||
|
func EnsureSocketDir(socketPath string) (string, error) {
|
||||||
|
return socketPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupSocketDir removes the socket file on Windows.
|
||||||
|
func CleanupSocketDir(socketPath string) error {
|
||||||
|
return os.Remove(socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsShortPath always returns false on Windows since TCP is used.
|
||||||
|
func NeedsShortPath(workspacePath string) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user