feat(cli): add --lock-timeout flag for SQLite busy_timeout control (#536)
Implements single-shot mode improvements for Windows and Docker scenarios: - Add --lock-timeout global flag (default 30s, 0 = fail immediately) - Add config file support: lock-timeout: 100ms - Parameterize SQLite busy_timeout via NewWithTimeout() function - In --sandbox mode: default lock-timeout to 100ms - In --sandbox mode: skip FlushManager creation (no background goroutines) This addresses bd.exe hanging on Windows and locking conflicts when using beads across host + Docker containers. Closes: bd-59er, bd-r4od, bd-dh8a 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1605
.beads/issues.jsonl
1605
.beads/issues.jsonl
File diff suppressed because one or more lines are too long
@@ -96,12 +96,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
noAutoFlush bool
|
noAutoFlush bool
|
||||||
noAutoImport bool
|
noAutoImport bool
|
||||||
sandboxMode bool
|
sandboxMode bool
|
||||||
allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch)
|
allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch)
|
||||||
noDb bool // Use --no-db mode: load from JSONL, write back after each command
|
noDb bool // Use --no-db mode: load from JSONL, write back after each command
|
||||||
readonlyMode bool // Read-only mode: block write operations (for worker sandboxes)
|
readonlyMode bool // Read-only mode: block write operations (for worker sandboxes)
|
||||||
|
lockTimeout time.Duration // SQLite busy_timeout (default 30s, 0 = fail immediately)
|
||||||
profileEnabled bool
|
profileEnabled bool
|
||||||
profileFile *os.File
|
profileFile *os.File
|
||||||
traceFile *os.File
|
traceFile *os.File
|
||||||
@@ -126,6 +127,7 @@ func init() {
|
|||||||
rootCmd.PersistentFlags().BoolVar(&allowStale, "allow-stale", false, "Allow operations on potentially stale data (skip staleness check)")
|
rootCmd.PersistentFlags().BoolVar(&allowStale, "allow-stale", false, "Allow operations on potentially stale data (skip staleness check)")
|
||||||
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
|
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
|
||||||
rootCmd.PersistentFlags().BoolVar(&readonlyMode, "readonly", false, "Read-only mode: block write operations (for worker sandboxes)")
|
rootCmd.PersistentFlags().BoolVar(&readonlyMode, "readonly", false, "Read-only mode: block write operations (for worker sandboxes)")
|
||||||
|
rootCmd.PersistentFlags().DurationVar(&lockTimeout, "lock-timeout", 30*time.Second, "SQLite busy timeout (0 = fail immediately if locked)")
|
||||||
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
|
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Enable verbose/debug output")
|
rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Enable verbose/debug output")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress non-essential output (errors only)")
|
rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress non-essential output (errors only)")
|
||||||
@@ -178,6 +180,9 @@ var rootCmd = &cobra.Command{
|
|||||||
if !cmd.Flags().Changed("readonly") {
|
if !cmd.Flags().Changed("readonly") {
|
||||||
readonlyMode = config.GetBool("readonly")
|
readonlyMode = config.GetBool("readonly")
|
||||||
}
|
}
|
||||||
|
if !cmd.Flags().Changed("lock-timeout") {
|
||||||
|
lockTimeout = config.GetDuration("lock-timeout")
|
||||||
|
}
|
||||||
if !cmd.Flags().Changed("db") && dbPath == "" {
|
if !cmd.Flags().Changed("db") && dbPath == "" {
|
||||||
dbPath = config.GetString("db")
|
dbPath = config.GetString("db")
|
||||||
}
|
}
|
||||||
@@ -250,6 +255,10 @@ var rootCmd = &cobra.Command{
|
|||||||
noDaemon = true
|
noDaemon = true
|
||||||
noAutoFlush = true
|
noAutoFlush = true
|
||||||
noAutoImport = true
|
noAutoImport = true
|
||||||
|
// Use shorter lock timeout in sandbox mode unless explicitly set
|
||||||
|
if !cmd.Flags().Changed("lock-timeout") {
|
||||||
|
lockTimeout = 100 * time.Millisecond
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force direct mode for human-only interactive commands
|
// Force direct mode for human-only interactive commands
|
||||||
@@ -555,7 +564,7 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
// Fall back to direct storage access
|
// Fall back to direct storage access
|
||||||
var err error
|
var err error
|
||||||
store, err = sqlite.New(rootCtx, dbPath)
|
store, err = sqlite.NewWithTimeout(rootCtx, dbPath, lockTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for fresh clone scenario (bd-dmb)
|
// Check for fresh clone scenario (bd-dmb)
|
||||||
beadsDir := filepath.Dir(dbPath)
|
beadsDir := filepath.Dir(dbPath)
|
||||||
@@ -572,10 +581,14 @@ var rootCmd = &cobra.Command{
|
|||||||
storeMutex.Unlock()
|
storeMutex.Unlock()
|
||||||
|
|
||||||
// Initialize flush manager (fixes bd-52: race condition in auto-flush)
|
// Initialize flush manager (fixes bd-52: race condition in auto-flush)
|
||||||
|
// Skip FlushManager creation in sandbox mode - no background goroutines needed
|
||||||
|
// (bd-dh8a: improves Windows exit behavior and container scenarios)
|
||||||
// For in-process test scenarios where commands run multiple times,
|
// For in-process test scenarios where commands run multiple times,
|
||||||
// we create a new manager each time. Shutdown() is idempotent so
|
// we create a new manager each time. Shutdown() is idempotent so
|
||||||
// PostRun can safely shutdown whichever manager is active.
|
// PostRun can safely shutdown whichever manager is active.
|
||||||
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
|
if !sandboxMode {
|
||||||
|
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
|
||||||
|
}
|
||||||
|
|
||||||
// Warn if multiple databases detected in directory hierarchy
|
// Warn if multiple databases detected in directory hierarchy
|
||||||
warnMultipleDatabases(dbPath)
|
warnMultipleDatabases(dbPath)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func Initialize() error {
|
|||||||
v.SetDefault("db", "")
|
v.SetDefault("db", "")
|
||||||
v.SetDefault("actor", "")
|
v.SetDefault("actor", "")
|
||||||
v.SetDefault("issue-prefix", "")
|
v.SetDefault("issue-prefix", "")
|
||||||
|
v.SetDefault("lock-timeout", "30s")
|
||||||
|
|
||||||
// Additional environment variables (not prefixed with BD_)
|
// Additional environment variables (not prefixed with BD_)
|
||||||
// These are bound explicitly for backward compatibility
|
// These are bound explicitly for backward compatibility
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
// Import SQLite driver
|
// Import SQLite driver
|
||||||
sqlite3 "github.com/ncruces/go-sqlite3"
|
sqlite3 "github.com/ncruces/go-sqlite3"
|
||||||
@@ -72,8 +73,17 @@ func init() {
|
|||||||
_ = setupWASMCache()
|
_ = setupWASMCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new SQLite storage backend
|
// New creates a new SQLite storage backend with default 30s busy timeout
|
||||||
func New(ctx context.Context, path string) (*SQLiteStorage, error) {
|
func New(ctx context.Context, path string) (*SQLiteStorage, error) {
|
||||||
|
return NewWithTimeout(ctx, path, 30*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithTimeout creates a new SQLite storage backend with configurable busy timeout.
|
||||||
|
// A timeout of 0 means fail immediately if the database is locked.
|
||||||
|
func NewWithTimeout(ctx context.Context, path string, busyTimeout time.Duration) (*SQLiteStorage, error) {
|
||||||
|
// Convert timeout to milliseconds for SQLite pragma
|
||||||
|
timeoutMs := int64(busyTimeout / time.Millisecond)
|
||||||
|
|
||||||
// Build connection string with proper URI syntax
|
// Build connection string with proper URI syntax
|
||||||
// For :memory: databases, use shared cache so multiple connections see the same data
|
// For :memory: databases, use shared cache so multiple connections see the same data
|
||||||
var connStr string
|
var connStr string
|
||||||
@@ -81,12 +91,12 @@ func New(ctx context.Context, path string) (*SQLiteStorage, error) {
|
|||||||
// Use shared in-memory database with a named identifier
|
// Use shared in-memory database with a named identifier
|
||||||
// Note: WAL mode doesn't work with shared in-memory databases, so use DELETE mode
|
// Note: WAL mode doesn't work with shared in-memory databases, so use DELETE mode
|
||||||
// The name "memdb" is required for cache=shared to work properly across connections
|
// The name "memdb" is required for cache=shared to work properly across connections
|
||||||
connStr = "file:memdb?mode=memory&cache=shared&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
|
connStr = fmt.Sprintf("file:memdb?mode=memory&cache=shared&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", timeoutMs)
|
||||||
} else if strings.HasPrefix(path, "file:") {
|
} else if strings.HasPrefix(path, "file:") {
|
||||||
// Already a URI - append our pragmas if not present
|
// Already a URI - append our pragmas if not present
|
||||||
connStr = path
|
connStr = path
|
||||||
if !strings.Contains(path, "_pragma=foreign_keys") {
|
if !strings.Contains(path, "_pragma=foreign_keys") {
|
||||||
connStr += "&_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
|
connStr += fmt.Sprintf("&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", timeoutMs)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Ensure directory exists for file-based databases
|
// Ensure directory exists for file-based databases
|
||||||
@@ -95,7 +105,7 @@ func New(ctx context.Context, path string) (*SQLiteStorage, error) {
|
|||||||
return nil, fmt.Errorf("failed to create directory: %w", err)
|
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
// Use file URI with pragmas
|
// Use file URI with pragmas
|
||||||
connStr = "file:" + path + "?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
|
connStr = fmt.Sprintf("file:%s?_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", path, timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", connStr)
|
db, err := sql.Open("sqlite3", connStr)
|
||||||
|
|||||||
Reference in New Issue
Block a user