Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,10 @@ func runWatchSubcommand(subCmd, root string) {
return
}
if err := stopWatchDaemon(absRoot); err != nil {
if errors.Is(err, watch.ErrForeignDaemonPID) {
fmt.Println("Watch daemon not running (cleared stale PID file)")
return
}
fmt.Fprintf(os.Stderr, "Error stopping daemon: %v\n", err)
os.Exit(1)
}
Expand Down
21 changes: 21 additions & 0 deletions main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -758,3 +758,24 @@ func TestSubcommandDispatchViaBinary(t *testing.T) {
}
})
}

func TestRunWatchSubcommandStopForeignPID(t *testing.T) {
root := t.TempDir()

origRunning := watchIsRunning
origStop := stopWatchDaemon
defer func() {
watchIsRunning = origRunning
stopWatchDaemon = origStop
}()
watchIsRunning = func(string) bool { return true }
stopWatchDaemon = func(string) error { return watch.ErrForeignDaemonPID }

stdout, _ := captureMainStreams(t, func() { runWatchSubcommand("stop", root) })
if !strings.Contains(stdout, "cleared stale PID file") {
t.Fatalf("expected stale-PID message on foreign PID, got:\n%s", stdout)
}
if strings.Contains(stdout, "Watch daemon stopped") {
t.Fatalf("should not claim the daemon was stopped for a foreign PID:\n%s", stdout)
}
}
8 changes: 8 additions & 0 deletions watch/process_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ func processAlive(pid int) bool {
}
return proc.Signal(syscall.Signal(0)) == nil
}

// terminateDaemon stops the daemon on Unix with SIGTERM so it can shut down
// gracefully. This matches long-standing behavior and does not gate on
// ownership (unlike Windows, where the kill is destructive): the root argument
// is accepted only to share a signature with the Windows implementation.
func terminateDaemon(_ string, proc *os.Process) error {
return proc.Signal(syscall.SIGTERM)
}
31 changes: 28 additions & 3 deletions watch/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@
package watch

import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)

// processCommandLine returns the full command line of the given PID via CIM
// (the Windows analog of `ps`; wmic is deprecated/removed on newer Windows).
// IsOwnedDaemon uses this to confirm a PID belongs to this repo's watch daemon.
func processCommandLine(pid int) (string, error) {
_ = pid
return "", errors.New("process command line lookup not supported on windows")
if pid <= 0 {
return "", fmt.Errorf("invalid pid %d", pid)
}
psCmd := fmt.Sprintf(`(Get-CimInstance Win32_Process -Filter "ProcessId=%d").CommandLine`, pid)
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}

// processAlive reports whether a process with the given PID is currently
Expand All @@ -33,3 +46,15 @@ func processAlive(pid int) bool {
}
return code == stillActive
}

// terminateDaemon stops the daemon on Windows. Windows has no SIGTERM, so it
// terminates with Kill — but only after confirming the PID actually belongs to
// this repo's watch daemon. A stale watch.pid may point to a PID the OS reused
// for an unrelated process, and killing that would be destructive; when
// ownership cannot be confirmed we refuse (ErrForeignDaemonPID) instead.
func terminateDaemon(root string, proc *os.Process) error {
if !IsOwnedDaemon(root) {
return ErrForeignDaemonPID
}
return proc.Kill()
}
Comment on lines +56 to +65
24 changes: 17 additions & 7 deletions watch/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ package watch

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)

// ErrForeignDaemonPID is returned by Stop when the PID in watch.pid is alive but
// cannot be confirmed to be this repo's watch daemon (e.g. a stale PID reused by
// an unrelated process). Callers should treat it as "nothing of ours to stop"
// rather than a hard failure.
var ErrForeignDaemonPID = errors.New("watch.pid does not belong to a codemap watch daemon for this repo (stale or reused PID)")

// ReadState reads the daemon state from disk (for hooks to use).
// Returns nil if state doesn't exist or if it's stale and daemon is not running.
func ReadState(root string) *State {
Expand Down Expand Up @@ -108,12 +114,16 @@ func Stop(root string) error {
if err != nil {
return err
}
// NOTE: Windows has no SIGTERM, so this returns an error there (as it always
// has). `watch stop` on Windows is intentionally left non-destructive: safely
// killing would require verifying the PID still belongs to this repo's daemon
// (guarding against PID reuse), which needs a Windows command-line lookup that
// processCommandLine does not yet implement. Tracked as follow-up.
if err := proc.Signal(syscall.SIGTERM); err != nil {
// terminateDaemon is platform-specific: SIGTERM on Unix; on Windows it
// verifies the PID belongs to this repo's daemon (guarding against a reused
// stale PID) before killing, returning ErrForeignDaemonPID otherwise.
if err := terminateDaemon(root, proc); err != nil {
if errors.Is(err, ErrForeignDaemonPID) {
// The recorded PID isn't our daemon (stale or reused). Clear the
// bogus pid file so status stops reporting it, but never kill a
// process we can't confirm is ours.
RemovePID(root)
}
return err
}
// Clean up PID file
Expand Down
Loading