Files
beads/cmd/bd/main.go
Steve Yegge a17e4af725 Fix context propagation lifecycle bugs
Critical fixes to context propagation implementation (bd-rtp, bd-yb8, bd-2o2):

1. Fix rootCtx lifecycle in main.go:
   - Removed premature defer rootCancel() from PersistentPreRun (line 132)
   - Added proper cleanup in PersistentPostRun (lines 544-547)
   - Context now properly spans from setup through command execution to cleanup

2. Fix test context contamination in cli_fast_test.go:
   - Reset rootCtx and rootCancel to nil in test cleanup (lines 139-140)
   - Prevents cancelled contexts from affecting subsequent tests

3. Fix export tests missing context in export_test.go:
   - Added rootCtx initialization in 5 export test subtests
   - Tests now properly set up context before calling exportCmd.Run()

These fixes ensure:
- Signal-aware contexts work correctly for graceful cancellation
- Ctrl+C properly cancels import/export operations
- Database integrity is maintained after cancellation
- All cancellation tests pass (TestImportCancellation, TestExportCommand)

Tested:
- go build ./cmd/bd ✓
- go test ./cmd/bd -run TestImportCancellation ✓
- go test ./cmd/bd -run TestExportCommand ✓

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:54:11 -05:00

558 lines
19 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime/pprof"
"runtime/trace"
"slices"
"sync"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/memory"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/utils"
)
// DaemonStatus captures daemon connection state for the current command
type DaemonStatus struct {
Mode string `json:"mode"` // "daemon" or "direct"
Connected bool `json:"connected"`
Degraded bool `json:"degraded"`
SocketPath string `json:"socket_path,omitempty"`
AutoStartEnabled bool `json:"auto_start_enabled"`
AutoStartAttempted bool `json:"auto_start_attempted"`
AutoStartSucceeded bool `json:"auto_start_succeeded"`
FallbackReason string `json:"fallback_reason,omitempty"` // "none","flag_no_daemon","connect_failed","health_failed","auto_start_disabled","auto_start_failed"
Detail string `json:"detail,omitempty"` // short diagnostic
Health string `json:"health,omitempty"` // "healthy","degraded","unhealthy"
}
// Fallback reason constants
const (
FallbackNone = "none"
FallbackFlagNoDaemon = "flag_no_daemon"
FallbackConnectFailed = "connect_failed"
FallbackHealthFailed = "health_failed"
cmdDaemon = "daemon"
cmdImport = "import"
statusHealthy = "healthy"
FallbackAutoStartDisabled = "auto_start_disabled"
FallbackAutoStartFailed = "auto_start_failed"
FallbackDaemonUnsupported = "daemon_unsupported"
)
var (
dbPath string
actor string
store storage.Storage
jsonOutput bool
daemonStatus DaemonStatus // Tracks daemon connection state for current command
// Daemon mode
daemonClient *rpc.Client // RPC client when daemon is running
noDaemon bool // Force direct mode (no daemon)
// Signal-aware context for graceful cancellation
rootCtx context.Context
rootCancel context.CancelFunc
// Auto-flush state
autoFlushEnabled = true // Can be disabled with --no-auto-flush
isDirty = false // Tracks if DB has changes needing export (used by legacy code)
needsFullExport = false // Set to true when IDs change (used by legacy code)
flushMutex sync.Mutex
flushTimer *time.Timer // DEPRECATED: Use flushManager instead
storeMutex sync.Mutex // Protects store access from background goroutine
storeActive = false // Tracks if store is available
flushFailureCount = 0 // Consecutive flush failures
lastFlushError error // Last flush error for debugging
// Auto-flush manager (replaces timer-based approach to fix bd-52)
flushManager *FlushManager
// Auto-import state
autoImportEnabled = true // Can be disabled with --no-auto-import
)
var (
noAutoFlush bool
noAutoImport bool
sandboxMode bool
noDb bool // Use --no-db mode: load from JSONL, write back after each command
profileEnabled bool
profileFile *os.File
traceFile *os.File
)
func init() {
// Initialize viper configuration
if err := config.Initialize(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
}
// Register persistent flags
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db)")
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR or $USER)")
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
rootCmd.PersistentFlags().BoolVar(&noDaemon, "no-daemon", false, "Force direct storage mode, bypass daemon if running")
rootCmd.PersistentFlags().BoolVar(&noAutoFlush, "no-auto-flush", false, "Disable automatic JSONL sync after CRUD operations")
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
rootCmd.PersistentFlags().BoolVar(&sandboxMode, "sandbox", false, "Sandbox mode: disables daemon and auto-sync")
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
// Add --version flag to root command (same behavior as version subcommand)
rootCmd.Flags().BoolP("version", "v", false, "Print version information")
}
var rootCmd = &cobra.Command{
Use: "bd",
Short: "bd - Dependency-aware issue tracker",
Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`,
Run: func(cmd *cobra.Command, args []string) {
// Handle --version flag on root command
if v, _ := cmd.Flags().GetBool("version"); v {
fmt.Printf("bd version %s (%s)\n", Version, Build)
return
}
// No subcommand - show help
_ = cmd.Help()
},
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Set up signal-aware context for graceful cancellation
rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// Apply viper configuration if flags weren't explicitly set
// Priority: flags > viper (config file + env vars) > defaults
// Do this BEFORE early-return so init/version/help respect config
// If flag wasn't explicitly set, use viper value
if !cmd.Flags().Changed("json") {
jsonOutput = config.GetBool("json")
}
if !cmd.Flags().Changed("no-daemon") {
noDaemon = config.GetBool("no-daemon")
}
if !cmd.Flags().Changed("no-auto-flush") {
noAutoFlush = config.GetBool("no-auto-flush")
}
if !cmd.Flags().Changed("no-auto-import") {
noAutoImport = config.GetBool("no-auto-import")
}
if !cmd.Flags().Changed("no-db") {
noDb = config.GetBool("no-db")
}
if !cmd.Flags().Changed("db") && dbPath == "" {
dbPath = config.GetString("db")
}
if !cmd.Flags().Changed("actor") && actor == "" {
actor = config.GetString("actor")
}
// Performance profiling setup
// When --profile is enabled, force direct mode to capture actual database operations
// rather than just RPC serialization/network overhead. This gives accurate profiles
// of the storage layer, query performance, and business logic.
if profileEnabled {
noDaemon = true
timestamp := time.Now().Format("20060102-150405")
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
profileFile = f
_ = pprof.StartCPUProfile(f)
}
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
traceFile = f
_ = trace.Start(f)
}
}
// Skip database initialization for commands that don't need a database
noDbCommands := []string{
cmdDaemon,
"bash",
"completion",
"doctor",
"fish",
"help",
"init",
"merge",
"powershell",
"prime",
"quickstart",
"setup",
"version",
"zsh",
}
if slices.Contains(noDbCommands, cmd.Name()) {
return
}
// If sandbox mode is set, enable all sandbox flags
if sandboxMode {
noDaemon = true
noAutoFlush = true
noAutoImport = true
}
// Force direct mode for human-only interactive commands
// edit: can take minutes in $EDITOR, daemon connection times out (GH #227)
if cmd.Name() == "edit" {
noDaemon = true
}
// Set auto-flush based on flag (invert no-auto-flush)
autoFlushEnabled = !noAutoFlush
// Set auto-import based on flag (invert no-auto-import)
autoImportEnabled = !noAutoImport
// Handle --no-db mode: load from JSONL, use in-memory storage
if noDb {
if err := initializeNoDbMode(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing --no-db mode: %v\n", err)
os.Exit(1)
}
// Set actor for audit trail
if actor == "" {
if bdActor := os.Getenv("BD_ACTOR"); bdActor != "" {
actor = bdActor
} else if user := os.Getenv("USER"); user != "" {
actor = user
} else {
actor = "unknown"
}
}
// Skip daemon and SQLite initialization - we're in memory mode
return
}
// Initialize database path
if dbPath == "" {
// Use public API to find database (same logic as extensions)
if foundDB := beads.FindDatabasePath(); foundDB != "" {
dbPath = foundDB
} else {
// Allow import command to auto-initialize database if missing
if cmd.Name() != "import" {
// No database found - error out instead of falling back to ~/.beads
fmt.Fprintf(os.Stderr, "Error: no beads database found\n")
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to create a database in the current directory\n")
fmt.Fprintf(os.Stderr, " or set BEADS_DIR to point to your .beads directory\n")
fmt.Fprintf(os.Stderr, " or set BEADS_DB to point to your database file (deprecated)\n")
os.Exit(1)
}
// For import command, set default database path
dbPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
}
}
// Set actor from flag, viper (env), or default
// Priority: --actor flag > viper (config + BD_ACTOR env) > USER env > "unknown"
// Note: Viper handles BD_ACTOR automatically via AutomaticEnv()
if actor == "" {
// Viper already populated from config file or BD_ACTOR env
// Fall back to USER env if still empty
if user := os.Getenv("USER"); user != "" {
actor = user
} else {
actor = "unknown"
}
}
// Initialize daemon status
socketPath := getSocketPath()
daemonStatus = DaemonStatus{
Mode: "direct",
Connected: false,
Degraded: true,
SocketPath: socketPath,
AutoStartEnabled: shouldAutoStartDaemon(),
FallbackReason: FallbackNone,
}
// Try to connect to daemon first (unless --no-daemon flag is set)
if noDaemon {
daemonStatus.FallbackReason = FallbackFlagNoDaemon
debug.Logf("--no-daemon flag set, using direct mode")
} else {
// Attempt daemon connection
client, err := rpc.TryConnect(socketPath)
if err == nil && client != nil {
// Set expected database path for validation
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
// Perform health check
health, healthErr := client.Health()
if healthErr == nil && health.Status == statusHealthy {
// Check version compatibility
if !health.Compatible {
debug.Logf("daemon version mismatch (daemon: %s, client: %s), restarting daemon",
health.Version, Version)
_ = client.Close()
// Kill old daemon and restart with new version
if restartDaemonForVersionMismatch() {
// Retry connection after restart
client, err = rpc.TryConnect(socketPath)
if err == nil && client != nil {
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
health, healthErr = client.Health()
if healthErr == nil && health.Status == statusHealthy {
daemonClient = client
daemonStatus.Mode = cmdDaemon
daemonStatus.Connected = true
daemonStatus.Degraded = false
daemonStatus.Health = health.Status
debug.Logf("connected to restarted daemon (version: %s)", health.Version)
warnWorktreeDaemon(dbPath)
return
}
}
}
// If restart failed, fall through to direct mode
daemonStatus.FallbackReason = FallbackHealthFailed
daemonStatus.Detail = fmt.Sprintf("version mismatch (daemon: %s, client: %s) and restart failed",
health.Version, Version)
} else {
// Daemon is healthy and compatible - use it
daemonClient = client
daemonStatus.Mode = cmdDaemon
daemonStatus.Connected = true
daemonStatus.Degraded = false
daemonStatus.Health = health.Status
debug.Logf("connected to daemon at %s (health: %s)", socketPath, health.Status)
// Warn if using daemon with git worktrees
warnWorktreeDaemon(dbPath)
return // Skip direct storage initialization
}
} else {
// Health check failed or daemon unhealthy
_ = client.Close()
daemonStatus.FallbackReason = FallbackHealthFailed
if healthErr != nil {
daemonStatus.Detail = healthErr.Error()
debug.Logf("daemon health check failed: %v", healthErr)
} else {
daemonStatus.Health = health.Status
daemonStatus.Detail = health.Error
debug.Logf("daemon unhealthy (status=%s): %s", health.Status, health.Error)
}
}
} else {
// Connection failed
daemonStatus.FallbackReason = FallbackConnectFailed
if err != nil {
daemonStatus.Detail = err.Error()
debug.Logf("daemon connect failed at %s: %v", socketPath, err)
}
}
// Daemon not running or unhealthy - try auto-start if enabled
if daemonStatus.AutoStartEnabled {
daemonStatus.AutoStartAttempted = true
debug.Logf("attempting to auto-start daemon")
startTime := time.Now()
if tryAutoStartDaemon(socketPath) {
// Retry connection after auto-start
client, err := rpc.TryConnect(socketPath)
if err == nil && client != nil {
// Set expected database path for validation
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
// Check health of auto-started daemon
health, healthErr := client.Health()
if healthErr == nil && health.Status == statusHealthy {
daemonClient = client
daemonStatus.Mode = cmdDaemon
daemonStatus.Connected = true
daemonStatus.Degraded = false
daemonStatus.AutoStartSucceeded = true
daemonStatus.Health = health.Status
daemonStatus.FallbackReason = FallbackNone
elapsed := time.Since(startTime).Milliseconds()
debug.Logf("auto-start succeeded; connected at %s in %dms", socketPath, elapsed)
// Warn if using daemon with git worktrees
warnWorktreeDaemon(dbPath)
return // Skip direct storage initialization
} else {
// Auto-started daemon is unhealthy
_ = client.Close()
daemonStatus.FallbackReason = FallbackHealthFailed
if healthErr != nil {
daemonStatus.Detail = healthErr.Error()
} else {
daemonStatus.Health = health.Status
daemonStatus.Detail = health.Error
}
debug.Logf("auto-started daemon is unhealthy; falling back to direct mode")
}
} else {
// Auto-start completed but connection still failed
daemonStatus.FallbackReason = FallbackAutoStartFailed
if err != nil {
daemonStatus.Detail = err.Error()
}
// Check for daemon-error file to provide better error message
if beadsDir := filepath.Dir(socketPath); beadsDir != "" {
errFile := filepath.Join(beadsDir, "daemon-error")
// nolint:gosec // G304: errFile is derived from secure beads directory
if errMsg, readErr := os.ReadFile(errFile); readErr == nil && len(errMsg) > 0 {
fmt.Fprintf(os.Stderr, "\n%s\n", string(errMsg))
daemonStatus.Detail = string(errMsg)
}
}
debug.Logf("auto-start did not yield a running daemon; falling back to direct mode")
}
} else {
// Auto-start itself failed
daemonStatus.FallbackReason = FallbackAutoStartFailed
debug.Logf("auto-start failed; falling back to direct mode")
}
} else {
// Auto-start disabled - preserve the actual failure reason
// Don't override connect_failed or health_failed with auto_start_disabled
// This preserves important diagnostic info (daemon crashed vs not running)
debug.Logf("auto-start disabled by BEADS_AUTO_START_DAEMON")
}
// Emit BD_VERBOSE warning if falling back to direct mode
if os.Getenv("BD_VERBOSE") != "" {
emitVerboseWarning()
}
debug.Logf("using direct mode (reason: %s)", daemonStatus.FallbackReason)
}
// Fall back to direct storage access
var err error
store, err = sqlite.New(rootCtx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
// Mark store as active for flush goroutine safety
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
// Initialize flush manager (fixes bd-52: race condition in auto-flush)
// For in-process test scenarios where commands run multiple times,
// we create a new manager each time. Shutdown() is idempotent so
// PostRun can safely shutdown whichever manager is active.
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
// Warn if multiple databases detected in directory hierarchy
warnMultipleDatabases(dbPath)
// Auto-import if JSONL is newer than DB (e.g., after git pull)
// Skip for import command itself to avoid recursion
// Skip for delete command to prevent resurrection of deleted issues (bd-8kde)
// Skip if sync --dry-run to avoid modifying DB in dry-run mode (bd-191)
if cmd.Name() != "import" && cmd.Name() != "delete" && autoImportEnabled {
// Check if this is sync command with --dry-run flag
if cmd.Name() == "sync" {
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
// Skip auto-import in dry-run mode
debug.Logf("auto-import skipped for sync --dry-run")
} else {
autoImportIfNewer()
}
} else {
autoImportIfNewer()
}
}
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
// Handle --no-db mode: write memory storage back to JSONL
if noDb {
if store != nil {
// Determine beads directory (respect BEADS_DIR)
var beadsDir string
if envDir := os.Getenv("BEADS_DIR"); envDir != "" {
// Canonicalize the path
beadsDir = utils.CanonicalizePath(envDir)
} else {
// Fall back to current directory
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
beadsDir = filepath.Join(cwd, ".beads")
}
if memStore, ok := store.(*memory.MemoryStorage); ok {
if err := writeIssuesToJSONL(memStore, beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write JSONL: %v\n", err)
os.Exit(1)
}
}
}
return
}
// Close daemon client if we're using it
if daemonClient != nil {
_ = daemonClient.Close()
return
}
// Otherwise, handle direct mode cleanup
// Shutdown flush manager (performs final flush if needed)
if flushManager != nil {
if err := flushManager.Shutdown(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: flush manager shutdown error: %v\n", err)
}
}
// Signal that store is closing (prevents background flush from accessing closed store)
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
if store != nil {
_ = store.Close()
}
if profileFile != nil { pprof.StopCPUProfile(); _ = profileFile.Close() }
if traceFile != nil { trace.Stop(); _ = traceFile.Close() }
// Cancel the signal context to clean up resources
if rootCancel != nil {
rootCancel()
}
},
}
// getDebounceDuration returns the auto-flush debounce duration
// Configurable via config file or BEADS_FLUSH_DEBOUNCE env var (e.g., "500ms", "10s")
// Defaults to 5 seconds if not set or invalid
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}