Skip to content

Commit b034356

Browse files
tintop2kTintopclaude
authored
fix(daemon): use file-based stop signal on Windows (#134) (#140)
`grepai watch --stop` fails on Windows because Go does not support os.Interrupt across consoles. Replace the shared StopProcess with platform-specific implementations: SIGINT on Unix (unchanged) and a sentinel stop file polled by the daemon on Windows. Co-authored-by: Tintop <john@csnapit.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6a07495 commit b034356

File tree

5 files changed

+274
-40
lines changed

5 files changed

+274
-40
lines changed

cli/watch.go

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ func runWatchLoop(ctx context.Context, st store.VectorStore, symbolStore *trace.
633633
// Handle signals
634634
sigChan := make(chan os.Signal, 1)
635635
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
636+
loopStopCh := daemon.StopChannel()
636637

637638
if !isBackgroundChild {
638639
fmt.Println("\nWatching for changes... (Press Ctrl+C to stop)")
@@ -647,6 +648,17 @@ func runWatchLoop(ctx context.Context, st store.VectorStore, symbolStore *trace.
647648
// Config write throttling - only write every 30 seconds at most
648649
var lastConfigWrite time.Time
649650

651+
// persistAndExit persists all stores before returning from the event loop.
652+
persistAndExit := func() error {
653+
if err := st.Persist(ctx); err != nil {
654+
log.Printf("Warning: failed to persist index on shutdown: %v", err)
655+
}
656+
if err := symbolStore.Persist(ctx); err != nil {
657+
log.Printf("Warning: failed to persist symbol index on shutdown: %v", err)
658+
}
659+
return nil
660+
}
661+
650662
// Event loop
651663
for {
652664
select {
@@ -656,13 +668,11 @@ func runWatchLoop(ctx context.Context, st store.VectorStore, symbolStore *trace.
656668
} else {
657669
log.Println("Shutting down...")
658670
}
659-
if err := st.Persist(ctx); err != nil {
660-
log.Printf("Warning: failed to persist index on shutdown: %v", err)
661-
}
662-
if err := symbolStore.Persist(ctx); err != nil {
663-
log.Printf("Warning: failed to persist symbol index on shutdown: %v", err)
664-
}
665-
return nil
671+
return persistAndExit()
672+
673+
case <-loopStopCh:
674+
log.Println("Stop file detected, shutting down...")
675+
return persistAndExit()
666676

667677
case <-persistTicker.C:
668678
if err := st.Persist(ctx); err != nil {
@@ -1174,6 +1184,7 @@ func runWatchForeground() error {
11741184
watchCtx, watchCancel := context.WithCancel(ctx)
11751185
defer watchCancel()
11761186

1187+
stopCh := daemon.StopChannel()
11771188
go func() {
11781189
select {
11791190
case <-sigChan:
@@ -1183,6 +1194,9 @@ func runWatchForeground() error {
11831194
log.Println("Shutting down...")
11841195
}
11851196
watchCancel()
1197+
case <-stopCh:
1198+
log.Println("Stop file detected, shutting down...")
1199+
watchCancel()
11861200
case <-watchCtx.Done():
11871201
}
11881202
}()
@@ -1725,6 +1739,7 @@ func runWorkspaceWatchForeground(logDir string, ws *config.Workspace) error {
17251739
// Handle signals
17261740
sigChan := make(chan os.Signal, 1)
17271741
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
1742+
wsStopCh := daemon.StopChannel()
17281743

17291744
if !isBackgroundChild {
17301745
fmt.Printf("\nWatching %d projects for changes... (Press Ctrl+C to stop)\n", len(runtimes))
@@ -1749,6 +1764,23 @@ func runWorkspaceWatchForeground(logDir string, ws *config.Workspace) error {
17491764
}()
17501765
}
17511766

1767+
// persistAndShutdown persists all stores before returning from the event loop.
1768+
persistAndShutdown := func() {
1769+
if err := st.Persist(ctx); err != nil {
1770+
log.Printf("Warning: failed to persist index on shutdown: %v", err)
1771+
}
1772+
for _, runtime := range runtimes {
1773+
if err := runtime.symbolStore.Persist(ctx); err != nil {
1774+
log.Printf("Warning: failed to persist symbol index on shutdown for %s: %v", runtime.project.Name, err)
1775+
}
1776+
if runtime.rpgStore != nil {
1777+
if err := runtime.rpgStore.Persist(ctx); err != nil {
1778+
log.Printf("Warning: failed to persist RPG graph on shutdown for %s: %v", runtime.project.Name, err)
1779+
}
1780+
}
1781+
}
1782+
}
1783+
17521784
// Event loop
17531785
persistTicker := time.NewTicker(30 * time.Second)
17541786
defer persistTicker.Stop()
@@ -1761,19 +1793,12 @@ func runWorkspaceWatchForeground(logDir string, ws *config.Workspace) error {
17611793
} else {
17621794
log.Println("Shutting down...")
17631795
}
1764-
if err := st.Persist(ctx); err != nil {
1765-
log.Printf("Warning: failed to persist index on shutdown: %v", err)
1766-
}
1767-
for _, runtime := range runtimes {
1768-
if err := runtime.symbolStore.Persist(ctx); err != nil {
1769-
log.Printf("Warning: failed to persist symbol index on shutdown for %s: %v", runtime.project.Name, err)
1770-
}
1771-
if runtime.rpgStore != nil {
1772-
if err := runtime.rpgStore.Persist(ctx); err != nil {
1773-
log.Printf("Warning: failed to persist RPG graph on shutdown for %s: %v", runtime.project.Name, err)
1774-
}
1775-
}
1776-
}
1796+
persistAndShutdown()
1797+
return nil
1798+
1799+
case <-wsStopCh:
1800+
log.Println("Stop file detected, shutting down...")
1801+
persistAndShutdown()
17771802
return nil
17781803

17791804
case <-persistTicker.C:

daemon/daemon.go

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -278,35 +278,30 @@ func SpawnBackground(logDir string, args []string) (int, <-chan struct{}, error)
278278
return spawnBackgroundWithLog(logDir, logPath, args)
279279
}
280280

281-
// StopProcess sends an interrupt signal to the process with the given PID.
281+
// StopProcess sends a stop signal to the process with the given PID.
282282
//
283-
// This sends SIGINT (Unix) or os.Interrupt (Windows) to request graceful shutdown.
284-
// The target process should have signal handlers installed to catch the interrupt
285-
// and clean up (persist state, close connections, etc.) before exiting.
283+
// On Unix, this sends SIGINT to request graceful shutdown.
284+
// On Windows, this writes a sentinel stop file that the daemon polls for.
286285
//
287286
// This function returns immediately after sending the signal. It does NOT wait
288287
// for the process to exit. Callers should poll IsProcessRunning() to verify
289288
// the process has stopped.
290289
//
291290
// Returns an error if the PID is invalid (<= 0) or if the signal cannot be sent
292291
// (process doesn't exist, insufficient permissions, etc.).
293-
func StopProcess(pid int) error {
294-
if pid <= 0 {
295-
return fmt.Errorf("invalid PID: %d", pid)
296-
}
297-
298-
process, err := os.FindProcess(pid)
299-
if err != nil {
300-
return fmt.Errorf("failed to find process: %w", err)
301-
}
302-
303-
// Send interrupt signal (SIGINT on Unix, CTRL_BREAK on Windows)
304-
if err := process.Signal(os.Interrupt); err != nil {
305-
return fmt.Errorf("failed to send interrupt signal: %w", err)
306-
}
292+
//
293+
// Platform-specific implementations are in daemon_unix.go and daemon_windows.go.
307294

308-
return nil
309-
}
295+
// StopChannel returns a channel that is closed when a stop signal is detected.
296+
//
297+
// On Unix, this returns a channel that never fires (signals are handled via
298+
// os/signal). On Windows, this polls for a sentinel stop file written by
299+
// StopProcess and closes the channel when detected.
300+
//
301+
// Callers should select on the returned channel alongside other shutdown
302+
// mechanisms (e.g., os/signal) to support graceful shutdown on all platforms.
303+
//
304+
// Platform-specific implementations are in daemon_unix.go and daemon_windows.go.
310305

311306
// GetWorktreePIDFile returns the path to the PID file for a worktree.
312307
func GetWorktreePIDFile(logDir, worktreeID string) string {

daemon/daemon_unix.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,28 @@ func (l *livenessCheck) cleanup() {
8989
l.pr.Close()
9090
l.pw.Close()
9191
}
92+
93+
// StopProcess sends SIGINT to the process with the given PID.
94+
func StopProcess(pid int) error {
95+
if pid <= 0 {
96+
return fmt.Errorf("invalid PID: %d", pid)
97+
}
98+
99+
process, err := os.FindProcess(pid)
100+
if err != nil {
101+
return fmt.Errorf("failed to find process: %w", err)
102+
}
103+
104+
if err := process.Signal(os.Interrupt); err != nil {
105+
return fmt.Errorf("failed to send interrupt signal: %w", err)
106+
}
107+
108+
return nil
109+
}
110+
111+
// StopChannel returns a channel that never fires on Unix.
112+
// Signal-based shutdown is handled via os/signal, so no additional
113+
// mechanism is needed.
114+
func StopChannel() <-chan struct{} {
115+
return make(chan struct{})
116+
}

daemon/daemon_windows.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"os"
99
"os/exec"
10+
"path/filepath"
1011
"syscall"
1112
"time"
1213
"unsafe"
@@ -111,3 +112,76 @@ func (l *livenessCheck) start(pid int) <-chan struct{} {
111112
func (l *livenessCheck) cleanup() {
112113
// no-op
113114
}
115+
116+
const (
117+
stopFilePrefix = "grepai-stop-"
118+
stopPollInterval = 500 * time.Millisecond
119+
stopStaleAfter = 60 * time.Second
120+
)
121+
122+
// stopFilePath returns the path to the sentinel stop file for the given PID.
123+
func stopFilePath(pid int) (string, error) {
124+
logDir, err := GetDefaultLogDir()
125+
if err != nil {
126+
return "", err
127+
}
128+
return filepath.Join(logDir, fmt.Sprintf("%s%d", stopFilePrefix, pid)), nil
129+
}
130+
131+
// StopProcess writes a sentinel stop file that the daemon polls for.
132+
// This avoids os.Interrupt which is not supported cross-console on Windows.
133+
func StopProcess(pid int) error {
134+
if pid <= 0 {
135+
return fmt.Errorf("invalid PID: %d", pid)
136+
}
137+
138+
if !IsProcessRunning(pid) {
139+
return fmt.Errorf("process %d is not running", pid)
140+
}
141+
142+
path, err := stopFilePath(pid)
143+
if err != nil {
144+
return fmt.Errorf("failed to determine stop file path: %w", err)
145+
}
146+
147+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
148+
return fmt.Errorf("failed to create log directory: %w", err)
149+
}
150+
151+
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0600); err != nil {
152+
return fmt.Errorf("failed to write stop file: %w", err)
153+
}
154+
155+
return nil
156+
}
157+
158+
// StopChannel returns a channel that is closed when a stop file is detected
159+
// for the current process. It also cleans up any stale stop files from
160+
// previous runs on startup.
161+
func StopChannel() <-chan struct{} {
162+
ch := make(chan struct{})
163+
pid := os.Getpid()
164+
165+
path, err := stopFilePath(pid)
166+
if err != nil {
167+
// Can't determine path; return inert channel.
168+
return ch
169+
}
170+
171+
// Clean up stale stop file from a previous run that reused this PID.
172+
_ = os.Remove(path)
173+
174+
go func() {
175+
for {
176+
time.Sleep(stopPollInterval)
177+
if _, err := os.Stat(path); err == nil {
178+
// Stop file detected — remove it and signal shutdown.
179+
_ = os.Remove(path)
180+
close(ch)
181+
return
182+
}
183+
}
184+
}()
185+
186+
return ch
187+
}

0 commit comments

Comments
 (0)