fix(list): prevent nil pointer panic in watch mode with daemon (#1324)

When running `bd list -w` with the daemon active, watchIssues() was
called with nil store, causing a panic at store.SearchIssues().

In daemon mode, storage operations are handled via RPC, so the global
`store` variable is nil. The watch mode code path didn't account for
this and used `store` directly.

Fix: Call ensureDirectMode() before watchIssues() to initialize the
store. This follows the same pattern used for other commands that
need direct store access in daemon mode (e.g., graph, ship).

Fixes #1323

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Ryan
2026-01-25 20:59:35 -05:00
committed by GitHub
parent 5ed65ed6a8
commit 119774fa7d
2 changed files with 191 additions and 0 deletions

View File

@@ -1020,7 +1020,12 @@ var listCmd = &cobra.Command{
sortIssues(issues, sortBy, reverse)
// Handle watch mode (GH#654)
// Watch mode requires direct store access for file watching
if watchMode {
if err := ensureDirectMode("watch mode requires direct database access"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
watchIssues(ctx, store, filter, sortBy, reverse)
return
}

186
cmd/bd/list_watch_test.go Normal file
View File

@@ -0,0 +1,186 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
)
// TestWatchModeWithDaemonDoesNotPanic verifies that the list -w code path
// properly initializes the store when in daemon mode, preventing the nil
// pointer panic that occurred before the fix.
//
// Bug: In daemon mode, store=nil because RPC handles storage. But watchIssues()
// was called directly with nil store, causing panic at store.SearchIssues().
//
// Fix: Call ensureDirectMode() before watchIssues() to initialize the store.
func TestWatchModeWithDaemonDoesNotPanic(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode")
}
// Save original state
origDaemonClient := daemonClient
origStore := store
origStoreActive := storeActive
origDBPath := dbPath
origRootCtx := rootCtx
origAutoImport := autoImportEnabled
defer func() {
// Restore state
daemonClient = origDaemonClient
if store != nil && store != origStore {
_ = store.Close()
}
storeMutex.Lock()
store = origStore
storeActive = origStoreActive
storeMutex.Unlock()
dbPath = origDBPath
rootCtx = origRootCtx
autoImportEnabled = origAutoImport
}()
// Create temp directory with .beads structure and test database
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0o755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
testDBPath := filepath.Join(beadsDir, "beads.db")
// Change to temp dir so beads.FindBeadsDir() finds our test .beads
// t.Chdir auto-restores working directory after test
t.Chdir(tmpDir)
// Create and seed the test database
setupStore := newTestStore(t, testDBPath)
testIssue := &types.Issue{
Title: "Watch mode test issue",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
ctx := context.Background()
if err := setupStore.CreateIssue(ctx, testIssue, "test"); err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
if err := setupStore.Close(); err != nil {
t.Fatalf("failed to close setup store: %v", err)
}
// Simulate daemon mode: store is nil, daemonClient is non-nil
// This is the state that caused the panic
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rootCtx = ctx
dbPath = testDBPath
storeMutex.Lock()
store = nil
storeActive = false
storeMutex.Unlock()
daemonClient = &rpc.Client{} // Non-nil = daemon mode
autoImportEnabled = false
// This is what the fix does: call ensureDirectMode before watchIssues
// Without this, watchIssues would panic on nil store
if err := ensureDirectMode("watch mode requires direct database access"); err != nil {
t.Fatalf("ensureDirectMode failed: %v", err)
}
// Verify store is now initialized (fix worked)
storeMutex.Lock()
currentStore := store
isActive := storeActive
storeMutex.Unlock()
if currentStore == nil {
t.Fatal("store is still nil after ensureDirectMode - fix not working")
}
if !isActive {
t.Fatal("store not marked active after ensureDirectMode")
}
// Now watchIssues can be called safely (won't panic)
// Run it briefly in a goroutine to verify no panic
filter := types.IssueFilter{Limit: 10}
done := make(chan bool)
var panicValue interface{}
go func() {
defer func() {
panicValue = recover()
done <- true
}()
// Call watchIssues - should not panic now that store is initialized
// It will block watching for file changes, so we let it run briefly
watchIssues(ctx, currentStore, filter, "", false)
}()
// Wait briefly for watchIssues to start and potentially panic
// The panic would occur immediately at store.SearchIssues(), so 100ms is plenty
select {
case <-done:
if panicValue != nil {
t.Fatalf("watchIssues panicked even with initialized store: %v", panicValue)
}
// watchIssues returned (probably due to context cancellation or early exit)
case <-time.After(100 * time.Millisecond):
// watchIssues is running without panic - success!
cancel() // Cancel context to stop watchIssues
}
}
// TestWatchIssuesDirectlyWithNilStorePanics documents that calling watchIssues
// directly with nil store causes a panic. This is the underlying bug.
func TestWatchIssuesDirectlyWithNilStorePanics(t *testing.T) {
// Create temp directory with .beads structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0o755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Change to temp dir so watchIssues finds .beads
t.Chdir(tmpDir)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// This documents that watchIssues panics with nil store
var nilStore storage.Storage = nil
filter := types.IssueFilter{}
done := make(chan bool)
panicked := false
go func() {
defer func() {
if r := recover(); r != nil {
panicked = true
}
done <- true
}()
watchIssues(ctx, nilStore, filter, "", false)
}()
select {
case <-done:
// Goroutine finished
case <-ctx.Done():
t.Log("watchIssues didn't panic within timeout")
}
// This test documents the bug - watchIssues DOES panic with nil store
if !panicked {
t.Error("expected watchIssues to panic with nil store, but it didn't")
}
}