From f1d7a95a0bd400907be2cac2a9b00d8c669965d4 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:23:06 +0200 Subject: [PATCH 1/6] fix(runtime): Isolate project runtime state Namespace mutable files per canonical project and use one ownership-checked bridge for legacy daemon transitions. Co-Authored-By: GPT-5.6 Sol --- cmd/hooks_more_test.go | 5 +- cmd/hooks_test.go | 40 ++++- handoff/handoff_test.go | 4 + handoff/storage.go | 17 +- internal/projectpath/path.go | 9 +- internal/projectpath/path_test.go | 117 ++++++++++++- internal/projectpath/runtime.go | 109 ++++++++++++ internal/runtimefile/append_unix.go | 24 +++ internal/runtimefile/append_windows.go | 48 ++++++ internal/runtimefile/replace_unix.go | 9 + internal/runtimefile/replace_windows.go | 57 +++++++ internal/runtimefile/runtimefile.go | 46 ++++++ main.go | 81 +++++++-- main_more_test.go | 24 ++- watch/events.go | 8 +- watch/process_unix.go | 5 +- watch/state.go | 209 ++++++++++++++++++++---- watch/state_more_test.go | 10 +- watch/state_test.go | 194 +++++++++++++++++++++- watch/transition.go | 125 ++++++++++++++ 20 files changed, 1054 insertions(+), 87 deletions(-) create mode 100644 internal/projectpath/runtime.go create mode 100644 internal/runtimefile/append_unix.go create mode 100644 internal/runtimefile/append_windows.go create mode 100644 internal/runtimefile/replace_unix.go create mode 100644 internal/runtimefile/replace_windows.go create mode 100644 internal/runtimefile/runtimefile.go create mode 100644 watch/transition.go diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 3bfd428..822bc98 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -611,10 +611,7 @@ func TestFindChildReposAndSessionStartVariants(t *testing.T) { "pkg/types.go": {"a.go", "b.go", "c.go"}, }, }) - if err := watch.WritePID(root); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { watch.RemovePID(root) }) + writeOwnedWatchPID(t, root) if err := handoff.WriteLatest(root, &handoff.Artifact{ SchemaVersion: handoff.SchemaVersion, diff --git a/cmd/hooks_test.go b/cmd/hooks_test.go index 7d273f3..56895b4 100644 --- a/cmd/hooks_test.go +++ b/cmd/hooks_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -27,6 +28,35 @@ func withOwnedDaemonProcess(t *testing.T, fn func(string) bool) { }) } +func TestOwnedWatchDaemonHelperProcess(t *testing.T) { + if os.Getenv("CODEMAP_CMD_WATCH_HELPER") != "1" { + return + } + time.Sleep(time.Minute) +} + +func writeOwnedWatchPID(t *testing.T, root string) { + t.Helper() + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + process := exec.Command(os.Args[0], "-test.run=TestOwnedWatchDaemonHelperProcess", "--", "watch", "daemon", canonical) + process.Env = append(os.Environ(), "CODEMAP_CMD_WATCH_HELPER=1") + if err := process.Start(); err != nil { + t.Fatal(err) + } + if err := watch.WriteProcessPID(root, process.Process.Pid); err != nil { + _ = process.Process.Kill() + t.Fatal(err) + } + t.Cleanup(func() { + _ = process.Process.Kill() + _, _ = process.Process.Wait() + watch.RemovePID(root) + }) +} + // TestHubInfoIsHub tests the hub detection threshold (3+ importers) func TestHubInfoIsHub(t *testing.T) { tests := []struct { @@ -248,10 +278,7 @@ func TestShouldRestartDaemon(t *testing.T) { if err := os.MkdirAll(codemapDir, 0755); err != nil { t.Fatal(err) } - if err := watch.WritePID(root); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { watch.RemovePID(root) }) + writeOwnedWatchPID(t, root) if !shouldRestartDaemon(root, time.Now()) { t.Fatal("expected true when daemon pid exists but state is missing") @@ -814,10 +841,7 @@ func writeWatchState(t *testing.T, root string, state watch.State) { if err := os.WriteFile(filepath.Join(codemapDir, "state.json"), data, 0644); err != nil { t.Fatal(err) } - if err := watch.WritePID(root); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { watch.RemovePID(root) }) + writeOwnedWatchPID(t, root) } // TestGetLastSessionEvents verifies that the 20-line budget is enforced when diff --git a/handoff/handoff_test.go b/handoff/handoff_test.go index 0e08e9d..b58c509 100644 --- a/handoff/handoff_test.go +++ b/handoff/handoff_test.go @@ -335,6 +335,7 @@ func TestMetricsLogCapped(t *testing.T) { func TestStoragePathsUseSetupRoot(t *testing.T) { projectRoot := t.TempDir() + otherProject := t.TempDir() setupRoot := t.TempDir() projectpath.SetSetupRoot(setupRoot) t.Cleanup(projectpath.ResetSetupRoot) @@ -343,6 +344,9 @@ func TestStoragePathsUseSetupRoot(t *testing.T) { if got := LatestPath(projectRoot); got != want { t.Fatalf("LatestPath() = %q, want %q", got, want) } + if LatestPath(projectRoot) == LatestPath(otherProject) { + t.Fatal("explicit setup-root projects share handoff storage") + } } func TestAutomaticLinkedWorktreesUseDistinctHandoffStorage(t *testing.T) { diff --git a/handoff/storage.go b/handoff/storage.go index 789c41c..ddc9a10 100644 --- a/handoff/storage.go +++ b/handoff/storage.go @@ -7,6 +7,7 @@ import ( "path/filepath" "codemap/internal/projectpath" + "codemap/internal/runtimefile" ) const ( @@ -104,11 +105,7 @@ func writeJSONAtomic(path string, value any) error { if err != nil { return err } - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - return err - } - return os.Rename(tmpPath, path) + return runtimefile.WriteAtomic(path, data, 0o644) } func appendMetrics(root string, artifact *Artifact) error { @@ -135,7 +132,7 @@ func appendMetrics(root string, artifact *Artifact) error { return err } - f, err := os.OpenFile(MetricsPath(root), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := runtimefile.OpenAppend(MetricsPath(root), 0o644) if err != nil { return err } @@ -231,7 +228,7 @@ func capMetricsLog(root string, maxLines int) error { } path := MetricsPath(root) - data, err := os.ReadFile(path) + data, err := runtimefile.Read(path) if err != nil { if os.IsNotExist(err) { return nil @@ -250,9 +247,5 @@ func capMetricsLog(root string, maxLines int) error { trimmed := bytes.Join(lines[len(lines)-maxLines:], []byte("\n")) trimmed = append(trimmed, '\n') - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, trimmed, 0644); err != nil { - return err - } - return os.Rename(tmpPath, path) + return runtimefile.WriteAtomic(path, trimmed, 0o644) } diff --git a/internal/projectpath/path.go b/internal/projectpath/path.go index 9faa613..dd3604a 100644 --- a/internal/projectpath/path.go +++ b/internal/projectpath/path.go @@ -125,9 +125,6 @@ func CodemapDir(projectRoot string) string { // RuntimeRoot returns the root for mutable state associated with a project. func RuntimeRoot(projectRoot string) string { - if explicit := ConfiguredSetupRoot(); explicit != "" { - return filepath.Clean(explicit) - } selection, err := Select(projectRoot) if err == nil { return selection.RuntimeRoot @@ -178,7 +175,11 @@ func ProjectRuntimeDir(projectRoot string) string { // RuntimeCodemapDir returns the .codemap directory for mutable project state. func RuntimeCodemapDir(projectRoot string) string { - return filepath.Join(RuntimeRoot(projectRoot), ".codemap") + selection, err := SelectRuntime(projectRoot) + if err == nil { + return selection.RuntimeDir + } + return filepath.Join(filepath.Clean(projectRoot), ".codemap") } func canonicalProjectRoot(root string) (string, error) { diff --git a/internal/projectpath/path_test.go b/internal/projectpath/path_test.go index 5d57c66..ef2c601 100644 --- a/internal/projectpath/path_test.go +++ b/internal/projectpath/path_test.go @@ -1,6 +1,9 @@ package projectpath import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "os" "path/filepath" "runtime" @@ -201,11 +204,121 @@ func TestRuntimeCodemapDirSeparatesAutomaticAndExplicitStorage(t *testing.T) { explicit := t.TempDir() SetSetupRoot(explicit) t.Cleanup(ResetSetupRoot) - if got, want := RuntimeCodemapDir(linked), filepath.Join(explicit, ".codemap"); got != want { - t.Fatalf("explicit RuntimeCodemapDir() = %q, want shared runtime dir %q", got, want) + digest := sha256.Sum256([]byte(linked)) + want := filepath.Join(explicit, ".codemap", "runtime", hex.EncodeToString(digest[:])) + if got := RuntimeCodemapDir(linked); got != want { + t.Fatalf("explicit RuntimeCodemapDir() = %q, want project namespace %q", got, want) } } +func TestExplicitSetupRuntimeNamespacesAreDeterministicAndDistinct(t *testing.T) { + setup := t.TempDir() + projectA := makeProjectFixture(t) + projectB := makeProjectFixture(t) + SetSetupRoot(setup) + t.Cleanup(ResetSetupRoot) + + a, err := SelectRuntime(projectA) + if err != nil { + t.Fatal(err) + } + b, err := SelectRuntime(projectB) + if err != nil { + t.Fatal(err) + } + if a.PolicyDir != b.PolicyDir || a.PolicyDir != filepath.Join(setup, ".codemap") { + t.Fatalf("policy dirs = %q, %q; want shared setup policy", a.PolicyDir, b.PolicyDir) + } + if a.RuntimeDir == b.RuntimeDir { + t.Fatalf("runtime dirs both %q; want project isolation", a.RuntimeDir) + } + if a.LegacyDir != a.PolicyDir || b.LegacyDir != b.PolicyDir { + t.Fatalf("legacy dirs = %q, %q; want shared old location", a.LegacyDir, b.LegacyDir) + } + assertProjectMarker(t, a.RuntimeDir, a.ProjectRoot) + assertProjectMarker(t, b.RuntimeDir, b.ProjectRoot) + + again, err := SelectRuntime(projectA) + if err != nil || again != a { + t.Fatalf("SelectRuntime() repeat = %#v, %v; want %#v", again, err, a) + } +} + +func TestExplicitSetupRuntimeNamespaceCanonicalizesSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + setup := t.TempDir() + project := makeProjectFixture(t) + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(project, alias); err != nil { + t.Fatal(err) + } + SetSetupRoot(setup) + t.Cleanup(ResetSetupRoot) + + direct, err := SelectRuntime(project) + if err != nil { + t.Fatal(err) + } + viaAlias, err := SelectRuntime(alias) + if err != nil { + t.Fatal(err) + } + if direct != viaAlias { + t.Fatalf("direct = %#v, alias = %#v", direct, viaAlias) + } +} + +func TestExplicitSetupRuntimeRejectsMarkerMismatch(t *testing.T) { + setup := t.TempDir() + project := makeProjectFixture(t) + SetSetupRoot(setup) + t.Cleanup(ResetSetupRoot) + + selection, err := SelectRuntime(project) + if err != nil { + t.Fatal(err) + } + marker := filepath.Join(selection.RuntimeDir, "project.json") + if err := os.WriteFile(marker, []byte(`{"canonical_root":"/different/project"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := SelectRuntime(project); err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("SelectRuntime() error = %v, want identity mismatch", err) + } +} + +func assertProjectMarker(t *testing.T, runtimeDir, wantRoot string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(runtimeDir, "project.json")) + if err != nil { + t.Fatal(err) + } + var marker struct { + CanonicalRoot string `json:"canonical_root"` + } + if err := json.Unmarshal(data, &marker); err != nil { + t.Fatal(err) + } + if marker.CanonicalRoot != wantRoot { + t.Fatalf("marker root = %q, want %q", marker.CanonicalRoot, wantRoot) + } +} + +func makeProjectFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + return root +} + func TestSelectRejectsNonstandardWorktreeMetadataDirectory(t *testing.T) { ResetSetupRoot() t.Cleanup(ResetSetupRoot) diff --git a/internal/projectpath/runtime.go b/internal/projectpath/runtime.go new file mode 100644 index 0000000..39ea585 --- /dev/null +++ b/internal/projectpath/runtime.go @@ -0,0 +1,109 @@ +package projectpath + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// RuntimeSelection separates reusable setup policy from mutable project state. +type RuntimeSelection struct { + ProjectRoot string + PolicyDir string + RuntimeDir string + LegacyDir string + Source Source +} + +type projectMarker struct { + CanonicalRoot string `json:"canonical_root"` +} + +// SelectRuntime resolves and validates mutable runtime storage for one project. +func SelectRuntime(projectRoot string) (RuntimeSelection, error) { + selection, err := Select(projectRoot) + if err != nil { + return RuntimeSelection{}, err + } + policyDir := filepath.Join(selection.SetupRoot, ".codemap") + result := RuntimeSelection{ + ProjectRoot: selection.ProjectRoot, + PolicyDir: policyDir, + RuntimeDir: filepath.Join(selection.RuntimeRoot, ".codemap"), + LegacyDir: policyDir, + Source: selection.Source, + } + if selection.Source != SourceExplicit { + return result, nil + } + + digest := sha256.Sum256([]byte(selection.ProjectRoot)) + runtimeRoot := filepath.Join(policyDir, "runtime") + result.RuntimeDir = filepath.Join(runtimeRoot, hex.EncodeToString(digest[:])) + for _, dir := range []string{policyDir, runtimeRoot, result.RuntimeDir} { + if err := ensureRealDirectory(dir); err != nil { + return RuntimeSelection{}, err + } + } + if err := ensureProjectMarker(result.RuntimeDir, selection.ProjectRoot); err != nil { + return RuntimeSelection{}, err + } + return result, nil +} + +func ensureRealDirectory(path string) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + if err := os.Mkdir(path, 0o755); err != nil && !os.IsExist(err) { + return fmt.Errorf("create runtime directory %q: %w", path, err) + } + info, err = os.Lstat(path) + } + if err != nil { + return fmt.Errorf("inspect runtime directory %q: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("unsafe runtime directory %q: expected a real directory", path) + } + return nil +} + +func ensureProjectMarker(runtimeDir, canonicalRoot string) error { + path := filepath.Join(runtimeDir, "project.json") + payload, err := json.Marshal(projectMarker{CanonicalRoot: canonicalRoot}) + if err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + if _, writeErr := f.Write(payload); writeErr != nil { + _ = f.Close() + _ = os.Remove(path) + return writeErr + } + if closeErr := f.Close(); closeErr != nil { + _ = os.Remove(path) + return closeErr + } + return nil + } + if !os.IsExist(err) { + return fmt.Errorf("create runtime identity %q: %w", path, err) + } + info, statErr := os.Lstat(path) + if statErr != nil || !info.Mode().IsRegular() { + return fmt.Errorf("unsafe runtime identity %q", path) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return fmt.Errorf("read runtime identity %q: %w", path, readErr) + } + var marker projectMarker + if json.Unmarshal(data, &marker) != nil || marker.CanonicalRoot != canonicalRoot { + return fmt.Errorf("runtime identity mismatch in %q", path) + } + return nil +} diff --git a/internal/runtimefile/append_unix.go b/internal/runtimefile/append_unix.go new file mode 100644 index 0000000..62f6f74 --- /dev/null +++ b/internal/runtimefile/append_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package runtimefile + +import ( + "fmt" + "os" + "syscall" +) + +// OpenAppend opens without following a final symlink. +func OpenAppend(path string, mode os.FileMode) (*os.File, error) { + fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_APPEND|syscall.O_CREAT|syscall.O_NOFOLLOW, uint32(mode.Perm())) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + _ = file.Close() + return nil, fmt.Errorf("unsafe runtime file %q", path) + } + return file, nil +} diff --git a/internal/runtimefile/append_windows.go b/internal/runtimefile/append_windows.go new file mode 100644 index 0000000..ee8d45a --- /dev/null +++ b/internal/runtimefile/append_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package runtimefile + +import ( + "fmt" + "os" + "syscall" +) + +func OpenAppend(path string, mode os.FileMode) (*os.File, error) { + _ = mode + pathPtr, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := syscall.CreateFile( + pathPtr, + syscall.GENERIC_WRITE, + syscall.FILE_SHARE_READ, + nil, + syscall.OPEN_ALWAYS, + syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + var info syscall.ByHandleFileInformation + if err := syscall.GetFileInformationByHandle(handle, &info); err != nil { + _ = syscall.CloseHandle(handle) + return nil, err + } + if info.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 || info.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 { + _ = syscall.CloseHandle(handle) + return nil, fmt.Errorf("unsafe runtime file %q", path) + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + _ = syscall.CloseHandle(handle) + return nil, fmt.Errorf("open runtime file %q", path) + } + if _, err := file.Seek(0, 2); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} diff --git a/internal/runtimefile/replace_unix.go b/internal/runtimefile/replace_unix.go new file mode 100644 index 0000000..fbb0d75 --- /dev/null +++ b/internal/runtimefile/replace_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package runtimefile + +import "os" + +func replaceFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/internal/runtimefile/replace_windows.go b/internal/runtimefile/replace_windows.go new file mode 100644 index 0000000..9386ae6 --- /dev/null +++ b/internal/runtimefile/replace_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package runtimefile + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +const ( + moveFileWriteThrough = 0x8 + replaceFileWriteThrough = 0x2 +) + +var ( + kernel32DLL = syscall.NewLazyDLL("kernel32.dll") + moveFileExW = kernel32DLL.NewProc("MoveFileExW") + replaceFileW = kernel32DLL.NewProc("ReplaceFileW") +) + +func replaceFile(source, destination string) error { + sourcePtr, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := syscall.UTF16PtrFromString(destination) + if err != nil { + return err + } + if _, err := os.Lstat(destination); err == nil { + result, _, callErr := replaceFileW.Call( + uintptr(unsafe.Pointer(destinationPtr)), + uintptr(unsafe.Pointer(sourcePtr)), + 0, + replaceFileWriteThrough, + 0, + 0, + ) + if result == 0 { + return fmt.Errorf("replace runtime file: %w", callErr) + } + return nil + } else if !os.IsNotExist(err) { + return err + } + result, _, callErr := moveFileExW.Call( + uintptr(unsafe.Pointer(sourcePtr)), + uintptr(unsafe.Pointer(destinationPtr)), + moveFileWriteThrough, + ) + if result == 0 { + return fmt.Errorf("replace runtime file: %w", callErr) + } + return nil +} diff --git a/internal/runtimefile/runtimefile.go b/internal/runtimefile/runtimefile.go new file mode 100644 index 0000000..af5f58f --- /dev/null +++ b/internal/runtimefile/runtimefile.go @@ -0,0 +1,46 @@ +package runtimefile + +import ( + "fmt" + "os" + "path/filepath" +) + +// WriteAtomic replaces a regular runtime file without following its endpoint. +func WriteAtomic(path string, data []byte, mode os.FileMode) error { + if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() { + return fmt.Errorf("unsafe runtime file %q", path) + } else if err != nil && !os.IsNotExist(err) { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".codemap-write-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return replaceFile(tmpPath, path) +} + +// Read rejects symlink and non-regular endpoints. +func Read(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("unsafe runtime file %q", path) + } + return os.ReadFile(path) +} diff --git a/main.go b/main.go index 0563f78..e976928 100644 --- a/main.go +++ b/main.go @@ -36,14 +36,15 @@ var ( newWatchProcess = func(root string, verbose bool) (watchProcess, error) { return watch.NewDaemon(root, verbose) } - watchIsRunning = watch.IsRunning - stopWatchDaemon = watch.Stop - writeWatchPID = watch.WritePID - removeWatchPID = watch.RemovePID - executablePath = os.Executable - execCommand = exec.Command - notifySignals = signal.Notify - terminalChecker = isTerminal + watchIsRunning = watch.IsRunning + stopWatchDaemon = watch.Stop + writeWatchPID = watch.WritePID + executablePath = os.Executable + execCommand = exec.Command + notifySignals = signal.Notify + terminalChecker = isTerminal + acquireWatchTransition = watch.AcquireTransition + writeWatchProcessPID = watch.WriteProcessPID ) func main() { @@ -736,10 +737,25 @@ func runWatchSubcommand(subCmd, root string) { switch subCmd { case "start": - if watchIsRunning(absRoot) { + transition, err := acquireWatchTransition(absRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) + return + } + defer transition.Release() + active, err := watch.ResolveActiveRuntime(absRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) + return + } + if active.PID > 0 { fmt.Println("Watch daemon already running") return } + if err := watch.PreserveStalePIDEvidence(active); err != nil { + fmt.Fprintf(os.Stderr, "Error preserving stale daemon PID: %v\n", err) + return + } // Fork a background daemon exe, err := executablePath() if err != nil { @@ -757,6 +773,11 @@ func runWatchSubcommand(subCmd, root string) { fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) os.Exit(1) } + if err := writeWatchProcessPID(absRoot, cmd.Process.Pid); err != nil { + _ = cmd.Process.Kill() + fmt.Fprintf(os.Stderr, "Error publishing daemon PID: %v\n", err) + return + } fmt.Printf("Watch daemon started (pid %d)\n", cmd.Process.Pid) case "daemon": @@ -764,7 +785,12 @@ func runWatchSubcommand(subCmd, root string) { runDaemon(absRoot) case "stop": - if !watchIsRunning(absRoot) { + active, resolveErr := watch.ResolveActiveRuntime(absRoot) + if resolveErr != nil { + fmt.Fprintf(os.Stderr, "Error stopping daemon: %v\n", resolveErr) + return + } + if active.PID <= 0 && !watchIsRunning(absRoot) { fmt.Println("Watch daemon not running") return } @@ -779,7 +805,12 @@ func runWatchSubcommand(subCmd, root string) { fmt.Println("Watch daemon stopped") case "status": - if watchIsRunning(absRoot) { + active, err := watch.ResolveActiveRuntime(absRoot) + if err != nil { + fmt.Printf("Watch daemon status unavailable: %v\n", err) + return + } + if active.PID > 0 { state := watch.ReadState(absRoot) if state != nil { fmt.Printf("Watch daemon running\n") @@ -936,6 +967,30 @@ func runHandoffSubcommand(args []string) { } func runDaemon(root string) { + var transition *watch.Transition + deadline := time.Now().Add(2 * time.Second) + for { + var err error + transition, err = acquireWatchTransition(root) + if err == nil { + break + } + if !errors.Is(err, watch.ErrTransitionLocked) || time.Now().After(deadline) { + fmt.Fprintf(os.Stderr, "Error claiming daemon transition: %v\n", err) + return + } + time.Sleep(10 * time.Millisecond) + } + defer transition.Release() + active, err := watch.ResolveActiveRuntime(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving daemon runtime: %v\n", err) + return + } + if active.PID > 0 && active.PID != os.Getpid() { + fmt.Fprintln(os.Stderr, "Error: another watch daemon is already running") + return + } daemon, err := newWatchProcess(root, false) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -949,6 +1004,8 @@ func runDaemon(root string) { // Write PID file writeWatchPID(root) + _ = transition.Release() + transition = nil // Wait for stop signal (SIGTERM or state file removal) sigChan := make(chan os.Signal, 1) @@ -956,7 +1013,7 @@ func runDaemon(root string) { <-sigChan daemon.Stop() - removeWatchPID(root) + _ = watch.RemoveProcessPID(root, os.Getpid()) } // isGitHubURL checks if the input looks like a GitHub repo URL diff --git a/main_more_test.go b/main_more_test.go index 8f689a2..4772f77 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -286,6 +286,13 @@ func runGitMainTestCmd(t *testing.T, dir string, args ...string) { } } +func TestMainWatchHelperProcess(t *testing.T) { + if os.Getenv("CODEMAP_MAIN_WATCH_HELPER") != "1" { + return + } + time.Sleep(time.Minute) +} + func writeMainWatchState(t *testing.T, root string, state watch.State, running bool) { t.Helper() @@ -300,10 +307,23 @@ func writeMainWatchState(t *testing.T, root string, state watch.State, running b t.Fatal(err) } if running { - if err := watch.WritePID(root); err != nil { + canonical, err := filepath.EvalSymlinks(root) + if err != nil { t.Fatal(err) } - t.Cleanup(func() { watch.RemovePID(root) }) + process := exec.Command(os.Args[0], "-test.run=TestMainWatchHelperProcess", "--", "watch", "daemon", canonical) + process.Env = append(os.Environ(), "CODEMAP_MAIN_WATCH_HELPER=1") + if err := process.Start(); err != nil { + t.Fatal(err) + } + if err := watch.WriteProcessPID(root, process.Process.Pid); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = process.Process.Kill() + _, _ = process.Process.Wait() + watch.RemovePID(root) + }) } } diff --git a/watch/events.go b/watch/events.go index 5469aa4..4449996 100644 --- a/watch/events.go +++ b/watch/events.go @@ -13,6 +13,7 @@ import ( "time" "codemap/internal/projectpath" + "codemap/internal/runtimefile" "codemap/limits" "codemap/scanner" @@ -592,7 +593,10 @@ func (d *Daemon) findRelatedHot(path string, window time.Duration) []string { // logEvent appends an event to the log file func (d *Daemon) logEvent(e Event) { - f, err := os.OpenFile(d.eventLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err := requireRegularRuntimeFile(d.eventLog); err != nil && !os.IsNotExist(err) { + return + } + f, err := runtimefile.OpenAppend(d.eventLog, 0o644) if err != nil { return } @@ -675,7 +679,7 @@ func (d *Daemon) writeState() { } stateFile := filepath.Join(projectpath.ProjectRuntimeDir(d.root), "state.json") - os.WriteFile(stateFile, data, 0644) + _ = runtimefile.WriteAtomic(stateFile, data, 0o644) } func appendBoundedEvents(events []Event, event Event) []Event { diff --git a/watch/process_unix.go b/watch/process_unix.go index 193ff3b..e061549 100644 --- a/watch/process_unix.go +++ b/watch/process_unix.go @@ -36,6 +36,9 @@ func processAlive(pid int) bool { // 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 { +func terminateDaemon(root string, proc *os.Process) error { + if daemonOwnershipForPID(root, proc.Pid) != ownershipOwned { + return ErrDaemonOwnershipUnknown + } return proc.Signal(syscall.SIGTERM) } diff --git a/watch/state.go b/watch/state.go index 6025443..30a88b6 100644 --- a/watch/state.go +++ b/watch/state.go @@ -10,6 +10,7 @@ import ( "time" "codemap/internal/projectpath" + "codemap/internal/runtimefile" ) // ErrForeignDaemonPID: the PID in watch.pid is alive but belongs to another @@ -20,8 +21,8 @@ var ErrForeignDaemonPID = errors.New("watch.pid points to a live process that is // refuse to kill and keep the pid file. var ErrDaemonOwnershipUnknown = errors.New("could not verify that watch.pid belongs to this repo's codemap watch daemon; refusing to stop it") -// 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. +var ErrDaemonExitTimeout = errors.New("watch daemon did not exit before transition deadline") + // canonicalRoot returns root as an absolute, symlink-resolved path; on error // it returns the absolute path unchanged. func canonicalRoot(root string) string { @@ -35,9 +36,18 @@ func canonicalRoot(root string) string { return abs } +// ReadState reads daemon state for hooks and returns nil when it is unavailable +// or stale without a running daemon. func ReadState(root string) *State { - stateFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json") - data, err := os.ReadFile(stateFile) + active, err := ResolveActiveRuntime(root) + if err != nil { + return nil + } + stateFile := filepath.Join(active.Directory, "state.json") + if err := requireRegularRuntimeFile(stateFile); err != nil { + return nil + } + data, err := runtimefile.Read(stateFile) if err != nil { return nil } @@ -68,28 +78,34 @@ func ReadState(root string) *State { return &state } -// WritePID writes the daemon PID to .codemap/watch.pid +// WritePID writes the daemon PID to the project's runtime namespace. func WritePID(root string) error { - if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { + return WriteProcessPID(root, os.Getpid()) +} + +// WriteProcessPID publishes the already-started daemon PID in its namespace. +func WriteProcessPID(root string, pid int) error { + if pid <= 0 { + return fmt.Errorf("invalid daemon PID %d", pid) + } + runtimeDir := projectpath.ProjectRuntimeDir(root) + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { return err } - pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") - return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) + pidFile := filepath.Join(runtimeDir, "watch.pid") + return runtimefile.WriteAtomic(pidFile, []byte(fmt.Sprintf("%d", pid)), 0o644) } -// ReadPID reads the daemon PID from .codemap/watch.pid +// ReadPID reads the daemon PID from the project's runtime namespace. func ReadPID(root string) (int, error) { - pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") - data, err := os.ReadFile(pidFile) + selection, err := projectpath.SelectRuntime(root) if err != nil { return 0, err } - var pid int - _, err = fmt.Sscanf(string(data), "%d", &pid) - return pid, err + return readPIDAt(projectpath.ProjectRuntimeDir(selection.ProjectRoot)) } -// RemovePID removes the PID file +// RemovePID removes the project's PID file. func RemovePID(root string) { pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") os.Remove(pidFile) @@ -106,6 +122,100 @@ const ( ownershipForeign // command line retrieved and does NOT match ) +// ActiveRuntime is the single ownership-checked location used by runtime consumers. +type ActiveRuntime struct { + Directory string + CanonicalRoot string + PID int + Legacy bool + StalePIDPath string +} + +// ResolveActiveRuntime selects the project namespace, or a positively owned +// live legacy daemon during forward migration. +func ResolveActiveRuntime(root string) (ActiveRuntime, error) { + selection, err := projectpath.SelectRuntime(root) + if err != nil { + return ActiveRuntime{}, err + } + base := ActiveRuntime{Directory: projectpath.ProjectRuntimeDir(selection.ProjectRoot), CanonicalRoot: selection.ProjectRoot} + var stalePath string + for _, candidate := range []ActiveRuntime{ + base, + {Directory: selection.LegacyDir, CanonicalRoot: selection.ProjectRoot, Legacy: true}, + } { + if candidate.Legacy && candidate.Directory == base.Directory { + continue + } + pid, readErr := readPIDAt(candidate.Directory) + if os.IsNotExist(readErr) { + continue + } + if readErr != nil || pid <= 0 { + return ActiveRuntime{}, ErrDaemonOwnershipUnknown + } + if !processAlive(pid) { + if candidate.Legacy { + continue + } + stalePath = filepath.Join(candidate.Directory, "watch.pid") + continue + } + switch daemonOwnershipForPID(selection.ProjectRoot, pid) { + case ownershipOwned: + candidate.PID = pid + return candidate, nil + case ownershipForeign: + return ActiveRuntime{}, ErrForeignDaemonPID + default: + return ActiveRuntime{}, ErrDaemonOwnershipUnknown + } + } + base.StalePIDPath = stalePath + return base, nil +} + +// PreserveStalePIDEvidence moves dead or malformed PID evidence aside under a +// transition lock before a replacement daemon publishes its PID. +func PreserveStalePIDEvidence(active ActiveRuntime) error { + if active.StalePIDPath == "" { + return nil + } + target := active.StalePIDPath + ".stale" + for suffix := 2; ; suffix++ { + if _, err := os.Lstat(target); os.IsNotExist(err) { + break + } + target = fmt.Sprintf("%s.stale-%d", active.StalePIDPath, suffix) + } + return os.Rename(active.StalePIDPath, target) +} + +func readPIDAt(dir string) (int, error) { + path := filepath.Join(dir, "watch.pid") + if err := requireRegularRuntimeFile(path); err != nil { + return 0, err + } + data, err := runtimefile.Read(path) + if err != nil { + return 0, err + } + var pid int + _, err = fmt.Sscanf(string(data), "%d", &pid) + return pid, err +} + +func requireRegularRuntimeFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsafe runtime file %q", path) + } + return nil +} + // daemonOwnershipForPID classifies whether pid is this repo's watch daemon. // It takes the PID explicitly (rather than re-reading watch.pid) so callers can // validate the exact process they are about to act on, avoiding a TOCTOU race @@ -127,11 +237,14 @@ func daemonOwnershipForPID(root string, pid int) daemonOwnership { if err != nil { absRoot = root } - if absRoot != "" && - strings.Contains(cmdline, "watch") && - strings.Contains(cmdline, "daemon") && - strings.Contains(cmdline, absRoot) { - return ownershipOwned + const daemonMarker = " watch daemon " + marker := strings.LastIndex(cmdline, daemonMarker) + if marker >= 0 { + candidate := strings.Trim(strings.TrimSpace(cmdline[marker+len(daemonMarker):]), `"`) + candidate, canonicalErr := filepath.EvalSymlinks(candidate) + if canonicalErr == nil && filepath.Clean(candidate) == filepath.Clean(absRoot) { + return ownershipOwned + } } return ownershipForeign } @@ -149,34 +262,70 @@ func IsOwnedDaemon(root string) bool { // IsRunning checks if the daemon is running func IsRunning(root string) bool { - pid, err := ReadPID(root) - if err != nil { + active, err := ResolveActiveRuntime(root) + if err != nil || active.PID <= 0 { return false } // Liveness is checked in a platform-specific way: Signal(0) on Unix is // unsupported on Windows, so processAlive queries the OS directly there. - return processAlive(pid) + return processAlive(active.PID) } -// Stop sends SIGTERM to the daemon process +// Stop requests shutdown of the daemon process and removes its PID file. func Stop(root string) error { - pid, err := ReadPID(root) + transition, err := acquireTransitionWithin(root, 2*time.Second) + if err != nil { + return err + } + defer transition.Release() + active, err := ResolveActiveRuntime(root) if err != nil { - return fmt.Errorf("no daemon running: %w", err) + return err } - proc, err := os.FindProcess(pid) + if active.PID <= 0 { + return fmt.Errorf("no daemon running: %w", os.ErrNotExist) + } + proc, err := os.FindProcess(active.PID) if err != nil { return err } // 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 err := terminateDaemon(active.CanonicalRoot, proc); err != nil { // Never remove the pid file on ErrForeignDaemonPID: the PID is alive // and unverified, so clearing it could orphan a real daemon. return err } - // Clean up PID file - RemovePID(root) + deadline := time.Now().Add(2 * time.Second) + for processAlive(active.PID) && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if processAlive(active.PID) { + return ErrDaemonExitTimeout + } + if err := removePIDIfMatches(active.Directory, active.PID); err != nil { + return err + } return nil } + +func removePIDIfMatches(dir string, pid int) error { + current, err := readPIDAt(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil || current != pid { + return fmt.Errorf("watch PID changed during transition") + } + return os.Remove(filepath.Join(dir, "watch.pid")) +} + +// RemoveProcessPID removes only the caller's still-current PID publication. +func RemoveProcessPID(root string, pid int) error { + selection, err := projectpath.SelectRuntime(root) + if err != nil { + return err + } + return removePIDIfMatches(projectpath.ProjectRuntimeDir(selection.ProjectRoot), pid) +} diff --git a/watch/state_more_test.go b/watch/state_more_test.go index 7ce63c1..382dba5 100644 --- a/watch/state_more_test.go +++ b/watch/state_more_test.go @@ -217,7 +217,7 @@ func TestStopWithoutPIDFileReturnsNoDaemonError(t *testing.T) { } } -func TestStopTerminatesProcessAndRemovesPID(t *testing.T) { +func TestStopRejectsForeignProcessAndRetainsPIDEvidence(t *testing.T) { root := t.TempDir() codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { @@ -238,10 +238,10 @@ func TestStopTerminatesProcessAndRemovesPID(t *testing.T) { t.Fatal(err) } - if err := Stop(root); err != nil { - t.Fatalf("Stop error: %v", err) + if err := Stop(root); !errors.Is(err, ErrForeignDaemonPID) { + t.Fatalf("Stop error = %v, want ErrForeignDaemonPID", err) } - if _, err := os.Stat(pidPath); !os.IsNotExist(err) { - t.Fatalf("expected pid file to be removed, stat err=%v", err) + if _, err := os.Stat(pidPath); err != nil { + t.Fatalf("expected foreign pid evidence to remain, stat err=%v", err) } } diff --git a/watch/state_test.go b/watch/state_test.go index c87f8ed..248c5f0 100644 --- a/watch/state_test.go +++ b/watch/state_test.go @@ -2,9 +2,12 @@ package watch import ( "encoding/json" + "errors" + "fmt" "os" "os/exec" "path/filepath" + "runtime" "testing" "time" @@ -12,6 +15,13 @@ import ( "codemap/scanner" ) +func TestHelperWatchDaemonProcess(t *testing.T) { + if os.Getenv("CODEMAP_TEST_WATCH_HELPER") != "1" { + return + } + time.Sleep(time.Minute) +} + func TestReadStateStaleButRunning(t *testing.T) { tmpDir, err := os.MkdirTemp("", "codemap-state-test") if err != nil { @@ -36,11 +46,20 @@ func TestReadStateStaleButRunning(t *testing.T) { t.Fatalf("Failed to write state file: %v", err) } - // Simulate running daemon by pointing pid file to current process. - if err := WritePID(tmpDir); err != nil { - t.Fatalf("Failed to write pid file: %v", err) + process := exec.Command(os.Args[0], "-test.run=TestHelperWatchDaemonProcess", "--", "watch", "daemon", tmpDir) + process.Env = append(os.Environ(), "CODEMAP_TEST_WATCH_HELPER=1") + if err := process.Start(); err != nil { + t.Fatal(err) + } + waited := make(chan struct{}) + go func() { + _ = process.Wait() + close(waited) + }() + t.Cleanup(func() { _ = process.Process.Kill(); <-waited }) + if err := os.WriteFile(filepath.Join(codemapDir, "watch.pid"), []byte(fmt.Sprint(process.Process.Pid)), 0o644); err != nil { + t.Fatal(err) } - defer RemovePID(tmpDir) got := ReadState(tmpDir) if got == nil { @@ -175,6 +194,171 @@ func TestWatchStorageUsesSetupRoot(t *testing.T) { } } +func TestExplicitSetupSeparatesMutableWatchFiles(t *testing.T) { + setup := t.TempDir() + projectA := t.TempDir() + projectB := t.TempDir() + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + + if err := WritePID(projectA); err != nil { + t.Fatal(err) + } + if err := WritePID(projectB); err != nil { + t.Fatal(err) + } + a, err := projectpath.SelectRuntime(projectA) + if err != nil { + t.Fatal(err) + } + b, err := projectpath.SelectRuntime(projectB) + if err != nil { + t.Fatal(err) + } + aDir := projectpath.ProjectRuntimeDir(a.ProjectRoot) + bDir := projectpath.ProjectRuntimeDir(b.ProjectRoot) + if aDir == bDir { + t.Fatalf("projects share runtime %q", aDir) + } + for _, dir := range []string{aDir, bDir} { + if _, err := os.Stat(filepath.Join(dir, "watch.pid")); err != nil { + t.Fatalf("namespaced PID missing in %q: %v", dir, err) + } + } + if _, err := os.Stat(filepath.Join(setup, ".codemap", "watch.pid")); !os.IsNotExist(err) { + t.Fatalf("legacy shared PID unexpectedly written: %v", err) + } +} + +func TestResolveActiveRuntimeUsesOnlyExactlyOwnedLiveLegacyDaemon(t *testing.T) { + setup := t.TempDir() + project := filepath.Join(t.TempDir(), "project with space") + if err := os.Mkdir(project, 0o755); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + selection, err := projectpath.SelectRuntime(project) + if err != nil { + t.Fatal(err) + } + + process := exec.Command(os.Args[0], "-test.run=TestHelperWatchDaemonProcess", "--", "watch", "daemon", selection.ProjectRoot) + process.Env = append(os.Environ(), "CODEMAP_TEST_WATCH_HELPER=1") + if err := process.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = process.Process.Kill(); _, _ = process.Process.Wait() }) + if err := os.MkdirAll(selection.LegacyDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(selection.LegacyDir, "watch.pid"), []byte(fmt.Sprint(process.Process.Pid)), 0o644); err != nil { + t.Fatal(err) + } + + active, err := ResolveActiveRuntime(project) + if err != nil { + t.Fatal(err) + } + if active.Directory != selection.LegacyDir || !active.Legacy || active.PID != process.Process.Pid { + t.Fatalf("ResolveActiveRuntime() = %#v, want owned legacy daemon", active) + } + + foreign := t.TempDir() + foreignProcess := exec.Command(os.Args[0], "-test.run=TestHelperWatchDaemonProcess", "--", "watch", "daemon", foreign) + foreignProcess.Env = append(os.Environ(), "CODEMAP_TEST_WATCH_HELPER=1") + if err := foreignProcess.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = foreignProcess.Process.Kill(); _, _ = foreignProcess.Process.Wait() }) + if err := os.WriteFile(filepath.Join(selection.LegacyDir, "watch.pid"), []byte(fmt.Sprint(foreignProcess.Process.Pid)), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ResolveActiveRuntime(project); !errors.Is(err, ErrForeignDaemonPID) { + t.Fatalf("foreign ResolveActiveRuntime() error = %v, want ErrForeignDaemonPID", err) + } +} + +func TestTransitionLockSerializesStartsAndReleases(t *testing.T) { + root := t.TempDir() + first, err := AcquireTransition(root) + if err != nil { + t.Fatal(err) + } + if _, err := AcquireTransition(root); !errors.Is(err, ErrTransitionLocked) { + t.Fatalf("second AcquireTransition() error = %v, want ErrTransitionLocked", err) + } + if err := first.Release(); err != nil { + t.Fatal(err) + } + third, err := AcquireTransition(root) + if err != nil { + t.Fatalf("AcquireTransition() after release: %v", err) + } + if err := third.Release(); err != nil { + t.Fatal(err) + } +} + +func TestWriteProcessPIDRejectsSymlinkEndpoint(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + root := t.TempDir() + runtimeDir := projectpath.ProjectRuntimeDir(root) + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(target, []byte("preserve"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(runtimeDir, "watch.pid")); err != nil { + t.Fatal(err) + } + if err := WriteProcessPID(root, 123); err == nil { + t.Fatal("WriteProcessPID followed a symlink") + } + data, err := os.ReadFile(target) + if err != nil || string(data) != "preserve" { + t.Fatalf("symlink target changed: %q, %v", data, err) + } +} + +func TestStopOwnedDaemonWaitsForVerifiedExitBeforeRemovingPID(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + process := exec.Command(os.Args[0], "-test.run=TestHelperWatchDaemonProcess", "--", "watch", "daemon", root) + process.Env = append(os.Environ(), "CODEMAP_TEST_WATCH_HELPER=1") + if err := process.Start(); err != nil { + t.Fatal(err) + } + waited := make(chan struct{}) + go func() { + _ = process.Wait() + close(waited) + }() + t.Cleanup(func() { _ = process.Process.Kill(); <-waited }) + if err := WriteProcessPID(root, process.Process.Pid); err != nil { + t.Fatal(err) + } + if err := Stop(root); err != nil { + t.Fatal(err) + } + if processAlive(process.Process.Pid) { + t.Fatal("Stop returned before daemon exit was observable") + } + selection, err := projectpath.SelectRuntime(root) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(selection.ProjectRoot), "watch.pid")); !os.IsNotExist(err) { + t.Fatalf("PID evidence remains after verified exit: %v", err) + } +} + func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) { projectpath.ResetSetupRoot() t.Cleanup(projectpath.ResetSetupRoot) @@ -220,7 +404,7 @@ func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) { if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(linked), "state.json")); err != nil { t.Fatalf("linked-worktree state missing: %v", err) } - if _, err := os.Stat(filepath.Join(primary, ".codemap", "state.json")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(primary), "state.json")); !os.IsNotExist(err) { t.Fatalf("primary state unexpectedly created: %v", err) } } diff --git a/watch/transition.go b/watch/transition.go new file mode 100644 index 0000000..75c48ff --- /dev/null +++ b/watch/transition.go @@ -0,0 +1,125 @@ +package watch + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "codemap/internal/projectpath" +) + +var ErrTransitionLocked = errors.New("watch daemon transition already in progress") + +type transitionRecord struct { + PID int `json:"pid"` + Token string `json:"token"` +} + +func acquireTransitionWithin(root string, timeout time.Duration) (*Transition, error) { + deadline := time.Now().Add(timeout) + for { + transition, err := AcquireTransition(root) + if err == nil { + return transition, nil + } + if !errors.Is(err, ErrTransitionLocked) || time.Now().After(deadline) { + return nil, err + } + time.Sleep(10 * time.Millisecond) + } +} + +type Transition struct { + path string + token string +} + +func AcquireTransition(root string) (*Transition, error) { + selection, err := projectpath.SelectRuntime(root) + if err != nil { + return nil, err + } + if err := os.MkdirAll(selection.RuntimeDir, 0o755); err != nil { + return nil, err + } + if info, err := os.Lstat(selection.RuntimeDir); err != nil || !info.IsDir() { + return nil, fmt.Errorf("unsafe runtime directory %q", selection.RuntimeDir) + } + path := filepath.Join(selection.RuntimeDir, "watch.transition") + for attempt := 0; attempt < 2; attempt++ { + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, err + } + record := transitionRecord{PID: os.Getpid(), Token: hex.EncodeToString(tokenBytes)} + payload, err := json.Marshal(record) + if err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + if _, err := file.Write(payload); err != nil { + _ = file.Close() + _ = os.Remove(path) + return nil, err + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return nil, err + } + return &Transition{path: path, token: record.Token}, nil + } + if !os.IsExist(err) { + return nil, err + } + existing, readErr := readTransition(path) + if readErr != nil || processAlive(existing.PID) { + return nil, ErrTransitionLocked + } + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return nil, ErrTransitionLocked + } + } + return nil, ErrTransitionLocked +} + +func (t *Transition) Release() error { + if t == nil || t.path == "" { + return nil + } + record, err := readTransition(t.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if record.Token != t.token { + return fmt.Errorf("transition lock ownership changed") + } + return os.Remove(t.path) +} + +func readTransition(path string) (transitionRecord, error) { + info, err := os.Lstat(path) + if err != nil { + return transitionRecord{}, err + } + if !info.Mode().IsRegular() { + return transitionRecord{}, fmt.Errorf("unsafe transition lock %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + return transitionRecord{}, err + } + var record transitionRecord + if err := json.Unmarshal(data, &record); err != nil || record.PID <= 0 || record.Token == "" { + return transitionRecord{}, fmt.Errorf("invalid transition lock %q", path) + } + return record, nil +} From 77f21983562a36bbebf84a900b90c8a2f7ab95f2 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:32:55 +0200 Subject: [PATCH 2/6] fix(runtime): Canonicalize daemon ownership roots Co-Authored-By: GPT-5.6 Sol --- watch/state.go | 2 ++ watch/state_more_test.go | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/watch/state.go b/watch/state.go index 30a88b6..1f270e0 100644 --- a/watch/state.go +++ b/watch/state.go @@ -236,6 +236,8 @@ func daemonOwnershipForPID(root string, pid int) daemonOwnership { absRoot, err := filepath.Abs(root) if err != nil { absRoot = root + } else if canonicalRoot, canonicalErr := filepath.EvalSymlinks(absRoot); canonicalErr == nil { + absRoot = canonicalRoot } const daemonMarker = " watch daemon " marker := strings.LastIndex(cmdline, daemonMarker) diff --git a/watch/state_more_test.go b/watch/state_more_test.go index 382dba5..3ab0c95 100644 --- a/watch/state_more_test.go +++ b/watch/state_more_test.go @@ -103,12 +103,16 @@ func TestIsOwnedDaemonMatchesCommandLine(t *testing.T) { } root := t.TempDir() + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } - cmd := exec.Command(os.Args[0], "-test.run=TestOwnedDaemonHelperProcess", "watch", "daemon", root) + cmd := exec.Command(os.Args[0], "-test.run=TestOwnedDaemonHelperProcess", "watch", "daemon", canonicalRoot) cmd.Env = append(os.Environ(), "CODEMAP_WATCH_HELPER=1") if err := cmd.Start(); err != nil { t.Fatalf("start helper daemon: %v", err) @@ -118,8 +122,7 @@ func TestIsOwnedDaemonMatchesCommandLine(t *testing.T) { _, _ = cmd.Process.Wait() }() - pidPath := filepath.Join(codemapDir, "watch.pid") - if err := os.WriteFile(pidPath, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { + if err := WriteProcessPID(root, cmd.Process.Pid); err != nil { t.Fatal(err) } From 44f7fe0a45b2d96c29bd91508074a15168c13682 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:24:26 +0200 Subject: [PATCH 3/6] fix(runtime): Fail closed on invalid state roots Route MCP watcher control through the CLI owner and reject poisoned runtime markers without fallback writes. Co-Authored-By: GPT-5.6 Sol --- cmd/hooks.go | 21 ++++++++--- cmd/hooks_more_test.go | 24 +++++++++++++ handoff/handoff_test.go | 22 ++++++++++++ handoff/storage.go | 40 ++++++++++++++++----- internal/projectpath/path.go | 10 ++++++ main.go | 51 +++++++++++--------------- main_more_test.go | 17 +++++++++ mcp/main.go | 69 +++++++++++++++++++++++++----------- mcp/main_more_test.go | 59 ++++++++++++++++++++++++++++++ mcp/surface_hygiene_test.go | 4 +-- watch/daemon.go | 47 ++++++++++++++++-------- watch/events.go | 10 ++++-- watch/state.go | 11 ++++-- watch/state_test.go | 29 +++++++++++++++ 14 files changed, 329 insertions(+), 85 deletions(-) diff --git a/cmd/hooks.go b/cmd/hooks.go index b18da23..8b39cad 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -539,7 +539,11 @@ func showLightweightDiffVsMain(root string) { // getLastSessionEvents reads events.log for previous session context func getLastSessionEvents(root string) []string { - eventsFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "events.log") + codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return nil + } + eventsFile := filepath.Join(codemapDir, "events.log") f, err := os.Open(eventsFile) if err != nil { return nil @@ -887,7 +891,10 @@ func hookPromptSubmit(root string) error { // writeStatuslineState writes a tiny file for the statusline to read. func writeStatuslineState(root string, intent TaskIntent) { - codemapDir := projectpath.ProjectRuntimeDir(root) + codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return + } status := intent.Category if intent.RiskLevel != "low" { status += " " + intent.RiskLevel @@ -1379,7 +1386,10 @@ func showSessionProgress(root, sessionID string) { // hookPreCompact saves hub state before context compaction func hookPreCompact(root string) error { - codemapDir := projectpath.ProjectRuntimeDir(root) + codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return err + } if err := os.MkdirAll(codemapDir, 0755); err != nil { return err } @@ -1694,7 +1704,10 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti } return nil } - codemapDir := projectpath.ProjectRuntimeDir(root) + codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return err + } if err := os.MkdirAll(codemapDir, 0o755); err != nil { return err } diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 822bc98..7e80c86 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -54,6 +54,30 @@ func withHookRuntimeStubs( }) } +func TestHookMutableStateFailsClosedOnRuntimeIdentityMismatch(t *testing.T) { + root, setup := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + selection, err := projectpath.SelectRuntime(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(selection.RuntimeDir, "project.json"), []byte(`{"canonical_root":"/other"}`), 0o600); err != nil { + t.Fatal(err) + } + + if err := updateSessionLease(root, "session-a", true, time.Now(), nil); err == nil { + t.Fatal("session lease accepted mismatched runtime identity") + } + writeStatuslineState(root, TaskIntent{Category: "test", RiskLevel: "low"}) + if _, err := os.Stat(filepath.Join(root, ".codemap")); !os.IsNotExist(err) { + t.Fatalf("unsafe project-local state exists: %v", err) + } +} + func captureOutputAndError(t *testing.T, fn func()) (string, string) { t.Helper() diff --git a/handoff/handoff_test.go b/handoff/handoff_test.go index b58c509..1467a1b 100644 --- a/handoff/handoff_test.go +++ b/handoff/handoff_test.go @@ -31,6 +31,28 @@ func contains(items []string, value string) bool { return false } +func TestWriteLatestFailsClosedOnRuntimeIdentityMismatch(t *testing.T) { + root, setup := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + selection, err := projectpath.SelectRuntime(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(selection.RuntimeDir, "project.json"), []byte(`{"canonical_root":"/other"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := WriteLatest(root, &Artifact{SchemaVersion: SchemaVersion}); err == nil { + t.Fatal("WriteLatest accepted mismatched runtime identity") + } + if _, err := os.Stat(filepath.Join(root, ".codemap", latestFilename)); !os.IsNotExist(err) { + t.Fatalf("unsafe fallback artifact exists: %v", err) + } +} + func TestBuildWriteRead(t *testing.T) { root := t.TempDir() diff --git a/handoff/storage.go b/handoff/storage.go index ddc9a10..55e03c5 100644 --- a/handoff/storage.go +++ b/handoff/storage.go @@ -61,7 +61,10 @@ func MetricsPath(root string) string { // ReadLatest reads the latest handoff artifact if it exists. // Returns (nil, nil) when no artifact is present. func ReadLatest(root string) (*Artifact, error) { - path := LatestPath(root) + path, err := runtimePath(root, latestFilename) + if err != nil { + return nil, err + } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -83,7 +86,11 @@ func ReadLatest(root string) (*Artifact, error) { func WriteLatest(root string, artifact *Artifact) error { normalizeArtifact(artifact) - path := LatestPath(root) + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return err + } + path := filepath.Join(runtimeDir, latestFilename) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } @@ -91,13 +98,21 @@ func WriteLatest(root string, artifact *Artifact) error { if err := writeJSONAtomic(path, artifact); err != nil { return err } - if err := writeJSONAtomic(PrefixPath(root), artifact.Prefix); err != nil { + if err := writeJSONAtomic(filepath.Join(runtimeDir, prefixFilename), artifact.Prefix); err != nil { return err } - if err := writeJSONAtomic(DeltaPath(root), artifact.Delta); err != nil { + if err := writeJSONAtomic(filepath.Join(runtimeDir, deltaFilename), artifact.Delta); err != nil { return err } - return appendMetrics(root, artifact) + return appendMetricsAt(filepath.Join(runtimeDir, metricsFilename), artifact) +} + +func runtimePath(root, name string) (string, error) { + dir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return "", err + } + return filepath.Join(dir, name), nil } func writeJSONAtomic(path string, value any) error { @@ -109,6 +124,14 @@ func writeJSONAtomic(path string, value any) error { } func appendMetrics(root string, artifact *Artifact) error { + path, err := runtimePath(root, metricsFilename) + if err != nil { + return err + } + return appendMetricsAt(path, artifact) +} + +func appendMetricsAt(path string, artifact *Artifact) error { entry := struct { GeneratedAt string `json:"generated_at"` Branch string `json:"branch"` @@ -132,7 +155,7 @@ func appendMetrics(root string, artifact *Artifact) error { return err } - f, err := runtimefile.OpenAppend(MetricsPath(root), 0o644) + f, err := runtimefile.OpenAppend(path, 0o644) if err != nil { return err } @@ -141,7 +164,7 @@ func appendMetrics(root string, artifact *Artifact) error { if _, err := f.Write(append(data, '\n')); err != nil { return err } - return capMetricsLog(root, maxMetricsLines) + return capMetricsLogAt(path, maxMetricsLines) } func normalizeArtifact(artifact *Artifact) { @@ -222,12 +245,11 @@ func backfillHashes(artifact *Artifact) { } } -func capMetricsLog(root string, maxLines int) error { +func capMetricsLogAt(path string, maxLines int) error { if maxLines <= 0 { return nil } - path := MetricsPath(root) data, err := runtimefile.Read(path) if err != nil { if os.IsNotExist(err) { diff --git a/internal/projectpath/path.go b/internal/projectpath/path.go index dd3604a..abfbcca 100644 --- a/internal/projectpath/path.go +++ b/internal/projectpath/path.go @@ -182,6 +182,16 @@ func RuntimeCodemapDir(projectRoot string) string { return filepath.Join(filepath.Clean(projectRoot), ".codemap") } +// CheckedRuntimeCodemapDir returns the validated mutable-state directory. +// Stateful callers must use this form so selection failures cannot fall back. +func CheckedRuntimeCodemapDir(projectRoot string) (string, error) { + selection, err := SelectRuntime(projectRoot) + if err != nil { + return "", err + } + return filepath.Join(selection.RuntimeDir, "projects", ProjectKey(selection.ProjectRoot)), nil +} + func canonicalProjectRoot(root string) (string, error) { absRoot, err := filepath.Abs(root) if err != nil { diff --git a/main.go b/main.go index e976928..6e2bc52 100644 --- a/main.go +++ b/main.go @@ -70,7 +70,10 @@ func main() { if len(os.Args) >= 4 { root = os.Args[3] } - runWatchSubcommand(subCmd, root) + if err := runWatchSubcommand(subCmd, root); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } return } @@ -718,16 +721,14 @@ func runImportersMode(root, file string, jsonMode bool, filters scanner.Filters) renderImportersReportCLI(os.Stdout, report) } -func runWatchSubcommand(subCmd, root string) { +func runWatchSubcommand(subCmd, root string) error { absRoot, _, err := cmd.ResolveNearestGitRoot(root) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } absRoot, err = cmd.ValidateProjectPath(absRoot) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } // Canonicalize so the daemon identity and path comparisons agree (e.g. // macOS /var -> /private/var). @@ -739,28 +740,24 @@ func runWatchSubcommand(subCmd, root string) { case "start": transition, err := acquireWatchTransition(absRoot) if err != nil { - fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) - return + return fmt.Errorf("starting daemon: %w", err) } defer transition.Release() active, err := watch.ResolveActiveRuntime(absRoot) if err != nil { - fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) - return + return fmt.Errorf("starting daemon: %w", err) } if active.PID > 0 { fmt.Println("Watch daemon already running") - return + return nil } if err := watch.PreserveStalePIDEvidence(active); err != nil { - fmt.Fprintf(os.Stderr, "Error preserving stale daemon PID: %v\n", err) - return + return fmt.Errorf("preserving stale daemon PID: %w", err) } // Fork a background daemon exe, err := executablePath() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } args := projectpath.PrependSetupRootArgs("watch", "daemon", absRoot) cmd := execCommand(exe, args...) @@ -770,13 +767,11 @@ func runWatchSubcommand(subCmd, root string) { // Detach from parent process group (Unix only) setSysProcAttr(cmd) if err := cmd.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) - os.Exit(1) + return fmt.Errorf("starting daemon: %w", err) } if err := writeWatchProcessPID(absRoot, cmd.Process.Pid); err != nil { _ = cmd.Process.Kill() - fmt.Fprintf(os.Stderr, "Error publishing daemon PID: %v\n", err) - return + return fmt.Errorf("publishing daemon PID: %w", err) } fmt.Printf("Watch daemon started (pid %d)\n", cmd.Process.Pid) @@ -787,28 +782,25 @@ func runWatchSubcommand(subCmd, root string) { case "stop": active, resolveErr := watch.ResolveActiveRuntime(absRoot) if resolveErr != nil { - fmt.Fprintf(os.Stderr, "Error stopping daemon: %v\n", resolveErr) - return + return fmt.Errorf("stopping daemon: %w", resolveErr) } if active.PID <= 0 && !watchIsRunning(absRoot) { fmt.Println("Watch daemon not running") - return + return nil } if err := stopWatchDaemon(absRoot); err != nil { if errors.Is(err, watch.ErrForeignDaemonPID) { fmt.Println("Watch daemon not running (cleared stale PID file)") - return + return nil } - fmt.Fprintf(os.Stderr, "Error stopping daemon: %v\n", err) - os.Exit(1) + return fmt.Errorf("stopping daemon: %w", err) } fmt.Println("Watch daemon stopped") case "status": active, err := watch.ResolveActiveRuntime(absRoot) if err != nil { - fmt.Printf("Watch daemon status unavailable: %v\n", err) - return + return fmt.Errorf("watch daemon status unavailable: %w", err) } if active.PID > 0 { state := watch.ReadState(absRoot) @@ -825,10 +817,9 @@ func runWatchSubcommand(subCmd, root string) { } default: - fmt.Fprintf(os.Stderr, "Unknown watch command: %s\n", subCmd) - fmt.Fprintln(os.Stderr, "Usage: codemap watch [start|stop|status]") - os.Exit(1) + return fmt.Errorf("unknown watch command %q (usage: codemap watch [start|stop|status])", subCmd) } + return nil } func runHandoffSubcommand(args []string) { diff --git a/main_more_test.go b/main_more_test.go index 4772f77..6f06729 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -463,6 +463,23 @@ func TestRunWatchSubcommandUsesNearestGitRootFromNestedDirectory(t *testing.T) { } } +func TestRunWatchSubcommandReturnsRuntimeRejection(t *testing.T) { + root := t.TempDir() + setup := t.TempDir() + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + selection, err := projectpath.SelectRuntime(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(selection.RuntimeDir, "project.json"), []byte(`{"canonical_root":"/other"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := runWatchSubcommand("start", root); err == nil { + t.Fatal("watch start accepted a rejected runtime identity") + } +} + func TestRunWatchStartPreservesSetupRoot(t *testing.T) { root := t.TempDir() if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { diff --git a/mcp/main.go b/mcp/main.go index d529ba2..461fa65 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" "sort" @@ -33,14 +34,25 @@ import ( // Global watcher registry - tracks active watchers per project var ( - watchers = make(map[string]*watch.Daemon) - watchersMu sync.RWMutex + watchers = make(map[string]*watch.Daemon) + watchersMu sync.RWMutex + runManagedWatchCommand = managedWatchCommand buildHandoffForMCP = handoff.BuildContext buildHandoffDetailMCP = handoff.BuildFileDetailContext writeLatestForMCP = handoff.WriteLatest ) +func managedWatchCommand(ctx context.Context, action, root string) (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + args := projectpath.PrependSetupRootArgs("watch", action, root) + out, err := exec.CommandContext(ctx, exe, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + const ( IntegrationClaudeSetup = "claude-setup" IntegrationCodexSetup = "codex-setup" @@ -1005,21 +1017,22 @@ func handleStartWatch(ctx context.Context, req *mcp.CallToolRequest, input Watch defer watchersMu.Unlock() // Check if already watching - if _, exists := watchers[absPath]; exists { + if daemon, exists := watchers[absPath]; exists && daemon != nil { return textResult(fmt.Sprintf("Already watching: %s\nUse get_activity to see recent changes.", absPath)), nil, nil } - // Start new watcher - daemon, err := watch.NewDaemon(absPath, false) + out, err := runManagedWatchCommand(ctx, "start", absPath) if err != nil { - return errorResult("Failed to create watcher: " + err.Error()), nil, nil + return errorResult("Failed to start watcher: " + strings.TrimSpace(out+" "+err.Error())), nil, nil } - - if err := daemon.Start(); err != nil { - return errorResult("Failed to start watcher: " + err.Error()), nil, nil + if strings.Contains(out, "already running") { + return textResult(fmt.Sprintf("Already watching: %s\nUse get_activity to see recent changes.", absPath)), nil, nil + } + state := watch.ReadState(absPath) + fileCount := 0 + if state != nil { + fileCount = state.FileCount } - - watchers[absPath] = daemon return textResult(fmt.Sprintf(`Live watcher started for: %s Tracking %d files @@ -1030,7 +1043,7 @@ The watcher is now running in background. I can now see: - Which files are "hot" (frequently edited) - What's uncommitted (dirty) -Use get_activity to see what you've been working on.`, absPath, daemon.FileCount())), nil, nil +Use get_activity to see what you've been working on.`, absPath, fileCount)), nil, nil } func handleStopWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchInput) (*mcp.CallToolResult, any, error) { @@ -1042,9 +1055,17 @@ func handleStopWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchI watchersMu.Lock() defer watchersMu.Unlock() - daemon, exists := watchers[absPath] - if !exists { - return textResult("No active watcher for: " + absPath), nil, nil + daemon, registered := watchers[absPath] + if daemon == nil { + out, err := runManagedWatchCommand(ctx, "stop", absPath) + if err != nil { + return errorResult("Failed to stop watcher: " + strings.TrimSpace(out+" "+err.Error())), nil, nil + } + delete(watchers, absPath) + if !registered && strings.Contains(out, "not running") { + return textResult("No active watcher for: " + absPath), nil, nil + } + return textResult(fmt.Sprintf("Watcher stopped for: %s\nTotal events captured: %d", absPath, 0)), nil, nil } // Get final stats before stopping @@ -1065,16 +1086,22 @@ func handleGetActivity(ctx context.Context, req *mcp.CallToolRequest, input Watc daemon, exists := watchers[absPath] watchersMu.RUnlock() - if !exists { - return errorResult(fmt.Sprintf("No active watcher for: %s\nUse start_watch first.", absPath)), nil, nil - } - minutes := input.Minutes if minutes <= 0 { minutes = 30 } - events := daemon.GetEvents(0) + var events []watch.Event + fileCount := 0 + if exists && daemon != nil { + events = daemon.GetEvents(0) + fileCount = daemon.FileCount() + } else if state := watch.ReadState(absPath); state != nil { + events = state.RecentEvents + fileCount = state.FileCount + } else { + return errorResult(fmt.Sprintf("No active watcher for: %s\nUse start_watch first.", absPath)), nil, nil + } cutoff := time.Now().Add(-time.Duration(minutes) * time.Minute) // Filter to recent events @@ -1096,7 +1123,7 @@ The user may be: - Reading code - Thinking/planning - Working in a different project -- Taking a break`, minutes, absPath, daemon.FileCount(), len(events))), nil, nil +- Taking a break`, minutes, absPath, fileCount, len(events))), nil, nil } // Aggregate by file diff --git a/mcp/main_more_test.go b/mcp/main_more_test.go index ef8d00a..86495a0 100644 --- a/mcp/main_more_test.go +++ b/mcp/main_more_test.go @@ -2,6 +2,7 @@ package codemapmcp import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -200,6 +201,47 @@ func TestMCPScansRespectConfiguredFilters(t *testing.T) { func TestHandleWatchLifecycleAndActivity(t *testing.T) { withWatcherRegistry(t) + previousCommand := runManagedWatchCommand + var managed *exec.Cmd + startCalls := 0 + runManagedWatchCommand = func(_ context.Context, action, root string) (string, error) { + switch action { + case "start": + startCalls++ + if pid, err := watch.ReadPID(root); err == nil && pid > 0 { + return "Watch daemon already running", nil + } + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + return "", err + } + managed = exec.Command("sh", "-c", "while :; do sleep 1; done", "codemap", "watch", "daemon", root) + if err := managed.Start(); err != nil { + return "", err + } + if err := watch.WriteProcessPID(root, managed.Process.Pid); err != nil { + return "", err + } + return "Watch daemon started", nil + case "stop": + if managed == nil { + return "Watch daemon not running", nil + } + _ = managed.Process.Kill() + _, _ = managed.Process.Wait() + managed = nil + watch.RemovePID(root) + return "Watch daemon stopped", nil + default: + return "", errors.New("unexpected watch action") + } + } + t.Cleanup(func() { + runManagedWatchCommand = previousCommand + if managed != nil { + _ = managed.Process.Kill() + _, _ = managed.Process.Wait() + } + }) startRoot := t.TempDir() if err := os.WriteFile(filepath.Join(startRoot, "main.go"), []byte("package main\n"), 0o644); err != nil { @@ -214,6 +256,20 @@ func TestHandleWatchLifecycleAndActivity(t *testing.T) { if !strings.Contains(startOut, "Live watcher started for:") { t.Fatalf("unexpected start output:\n%s", startOut) } + if pid, err := watch.ReadPID(startRoot); err != nil || pid <= 0 { + t.Fatalf("MCP watcher did not publish shared ownership: pid=%d err=%v", pid, err) + } + firstPID, _ := watch.ReadPID(startRoot) + watchersMu.Lock() + delete(watchers, startRoot) + watchersMu.Unlock() + sharedRes, _, err := handleStartWatch(context.Background(), nil, WatchInput{Path: startRoot}) + if err != nil || !strings.Contains(resultText(t, sharedRes), "Already watching:") { + t.Fatalf("existing CLI owner was not reused: err=%v out=%s", err, resultText(t, sharedRes)) + } + if pid, _ := watch.ReadPID(startRoot); pid != firstPID { + t.Fatalf("owner changed: %d -> %d", firstPID, pid) + } againRes, _, err := handleStartWatch(context.Background(), nil, WatchInput{Path: startRoot}) if err != nil { @@ -222,6 +278,9 @@ func TestHandleWatchLifecycleAndActivity(t *testing.T) { if !strings.Contains(resultText(t, againRes), "Already watching:") { t.Fatalf("expected already-watching response, got:\n%s", resultText(t, againRes)) } + if startCalls != 3 { + t.Fatalf("managed owner was not revalidated: start calls = %d, want 3", startCalls) + } stopRes, _, err := handleStopWatch(context.Background(), nil, WatchInput{Path: startRoot}) if err != nil { diff --git a/mcp/surface_hygiene_test.go b/mcp/surface_hygiene_test.go index bf9fbac..73b7779 100644 --- a/mcp/surface_hygiene_test.go +++ b/mcp/surface_hygiene_test.go @@ -72,8 +72,8 @@ func TestTextResultCallerClassification(t *testing.T) { "handleListProjects": {4, 0}, "handleGetImporters": {2, 0}, "handleGetHandoff": {3, 2}, - "handleStartWatch": {2, 0}, - "handleStopWatch": {2, 0}, + "handleStartWatch": {3, 0}, + "handleStopWatch": {3, 0}, "handleGetActivity": {2, 0}, "handleGetHubs": {2, 0}, "handleGetFileContext": {1, 0}, diff --git a/watch/daemon.go b/watch/daemon.go index 29d50f8..60c6937 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -20,17 +20,25 @@ import ( // Daemon is the watch daemon that keeps the graph updated type Daemon struct { - root string - graph *Graph - watcher *fsnotify.Watcher - gitCache *scanner.GitIgnoreCache - eventLog string // path to event log file - verbose bool - done chan struct{} + root string + runtimeDir string + graph *Graph + watcher *fsnotify.Watcher + gitCache *scanner.GitIgnoreCache + eventLog string // path to event log file + verbose bool + done chan struct{} eventLoopWG sync.WaitGroup } +func (d *Daemon) runtimeStateDir() (string, error) { + if d.runtimeDir != "" { + return d.runtimeDir, nil + } + return projectpath.CheckedRuntimeCodemapDir(d.root) +} + // NewDaemon creates a new watch daemon for the given root func NewDaemon(root string, verbose bool) (*Daemon, error) { absRoot, err := filepath.Abs(root) @@ -41,13 +49,17 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { absRoot = canonical } + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(absRoot) + if err != nil { + return nil, fmt.Errorf("resolve runtime state: %w", err) + } watcher, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create watcher: %w", err) } - gitCache := scanner.NewGitIgnoreCache(root) + gitCache := scanner.NewGitIgnoreCache(absRoot) // Check if git repo (fast, one-time) isGitRepo := false @@ -56,12 +68,13 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { } d := &Daemon{ - root: absRoot, - watcher: watcher, - gitCache: gitCache, - verbose: verbose, - done: make(chan struct{}), - eventLog: filepath.Join(projectpath.ProjectRuntimeDir(absRoot), "events.log"), + root: absRoot, + runtimeDir: runtimeDir, + watcher: watcher, + gitCache: gitCache, + verbose: verbose, + done: make(chan struct{}), + eventLog: filepath.Join(runtimeDir, "events.log"), graph: &Graph{ Root: absRoot, Files: make(map[string]*scanner.FileInfo), @@ -79,6 +92,12 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { // Start begins watching and returns immediately func (d *Daemon) Start() error { + // Keep project configuration in its configured .codemap directory while + // mutable daemon state uses the validated project runtime namespace. + codemapDir := d.runtimeDir + if err := os.MkdirAll(codemapDir, 0755); err != nil { + return fmt.Errorf("failed to create .codemap dir: %w", err) + } // Ensure the config directory exists; it is watched so config edits can // refresh the configured-file inventory. configDir := projectpath.CodemapDir(d.root) diff --git a/watch/events.go b/watch/events.go index 4449996..3edc9f4 100644 --- a/watch/events.go +++ b/watch/events.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "codemap/internal/projectpath" "codemap/internal/runtimefile" "codemap/limits" "codemap/scanner" @@ -638,9 +637,14 @@ func (d *Daemon) logEvent(e Event) { // writeState persists current state for hooks to read func (d *Daemon) writeState() { + runtimeDir, err := d.runtimeStateDir() + if err != nil { + return + } + d.graph.mu.RLock() defer d.graph.mu.RUnlock() - if err := os.MkdirAll(projectpath.ProjectRuntimeDir(d.root), 0o755); err != nil { + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { return } @@ -678,7 +682,7 @@ func (d *Daemon) writeState() { return } - stateFile := filepath.Join(projectpath.ProjectRuntimeDir(d.root), "state.json") + stateFile := filepath.Join(runtimeDir, "state.json") _ = runtimefile.WriteAtomic(stateFile, data, 0o644) } diff --git a/watch/state.go b/watch/state.go index 1f270e0..1d1c6e1 100644 --- a/watch/state.go +++ b/watch/state.go @@ -88,7 +88,10 @@ func WriteProcessPID(root string, pid int) error { if pid <= 0 { return fmt.Errorf("invalid daemon PID %d", pid) } - runtimeDir := projectpath.ProjectRuntimeDir(root) + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return err + } if err := os.MkdirAll(runtimeDir, 0o755); err != nil { return err } @@ -107,7 +110,11 @@ func ReadPID(root string) (int, error) { // RemovePID removes the project's PID file. func RemovePID(root string) { - pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return + } + pidFile := filepath.Join(runtimeDir, "watch.pid") os.Remove(pidFile) } diff --git a/watch/state_test.go b/watch/state_test.go index 248c5f0..202d89e 100644 --- a/watch/state_test.go +++ b/watch/state_test.go @@ -22,6 +22,35 @@ func TestHelperWatchDaemonProcess(t *testing.T) { time.Sleep(time.Minute) } +func TestMutableWatcherStateFailsClosedOnRuntimeIdentityMismatch(t *testing.T) { + root, setup := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + selection, err := projectpath.SelectRuntime(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(selection.RuntimeDir, "project.json"), []byte(`{"canonical_root":"/other"}`), 0o600); err != nil { + t.Fatal(err) + } + + if err := WriteProcessPID(root, 42); err == nil { + t.Fatal("WriteProcessPID accepted mismatched runtime identity") + } + if d, err := NewDaemon(root, false); err == nil { + d.watcher.Close() + t.Fatal("NewDaemon accepted mismatched runtime identity") + } + for _, name := range []string{"watch.pid", "state.json", "events.log"} { + if _, err := os.Stat(filepath.Join(root, ".codemap", name)); !os.IsNotExist(err) { + t.Fatalf("unsafe fallback artifact %s exists: %v", name, err) + } + } +} + func TestReadStateStaleButRunning(t *testing.T) { tmpDir, err := os.MkdirTemp("", "codemap-state-test") if err != nil { From b196eed0f7cadc85f3acae41aaa12e5a86d8e56d Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:11:14 +0200 Subject: [PATCH 4/6] fix(runtime): Wait for watcher readiness Wait for detached watcher initialization before reporting CLI or MCP success, and preserve bounded lifecycle completion across request cancellation. Co-Authored-By: GPT-5.6 Sol --- main.go | 138 ++++++++++++++++++++++++++++++++++++------ main_more_test.go | 55 ++++++++++++++++- mcp/main.go | 13 ++-- mcp/main_more_test.go | 43 +++++++++++++ watch/daemon.go | 2 +- 5 files changed, 227 insertions(+), 24 deletions(-) diff --git a/main.go b/main.go index 6e2bc52..60f6308 100644 --- a/main.go +++ b/main.go @@ -44,9 +44,19 @@ var ( notifySignals = signal.Notify terminalChecker = isTerminal acquireWatchTransition = watch.AcquireTransition + releaseWatchTransition = func(transition *watch.Transition) error { return transition.Release() } writeWatchProcessPID = watch.WriteProcessPID ) +const ( + watchReadinessEnv = "CODEMAP_WATCH_READINESS_FILE" + watchReadinessTimeout = 30 * time.Second +) + +type watchReadiness struct { + Error string `json:"error,omitempty"` +} + func main() { args, err := applyGlobalRootOptions(os.Args[1:]) if err != nil { @@ -742,7 +752,11 @@ func runWatchSubcommand(subCmd, root string) error { if err != nil { return fmt.Errorf("starting daemon: %w", err) } - defer transition.Release() + defer func() { + if transition != nil { + _ = releaseWatchTransition(transition) + } + }() active, err := watch.ResolveActiveRuntime(absRoot) if err != nil { return fmt.Errorf("starting daemon: %w", err) @@ -764,6 +778,18 @@ func runWatchSubcommand(subCmd, root string) error { cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil + readyFile, err := os.CreateTemp("", "codemap-watch-ready-*") + if err != nil { + return fmt.Errorf("creating daemon readiness file: %w", err) + } + readyPath := readyFile.Name() + _ = readyFile.Close() + _ = os.Remove(readyPath) + defer os.Remove(readyPath) + if cmd.Env == nil { + cmd.Env = os.Environ() + } + cmd.Env = append(cmd.Env, watchReadinessEnv+"="+readyPath) // Detach from parent process group (Unix only) setSysProcAttr(cmd) if err := cmd.Start(); err != nil { @@ -771,13 +797,27 @@ func runWatchSubcommand(subCmd, root string) error { } if err := writeWatchProcessPID(absRoot, cmd.Process.Pid); err != nil { _ = cmd.Process.Kill() + _ = cmd.Process.Release() return fmt.Errorf("publishing daemon PID: %w", err) } + if err := releaseWatchTransition(transition); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Process.Release() + return fmt.Errorf("releasing daemon transition: %w", err) + } + transition = nil + if err := waitWatchReadiness(readyPath, watchReadinessTimeout); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Process.Release() + _ = watch.RemoveProcessPID(absRoot, cmd.Process.Pid) + return fmt.Errorf("starting daemon: %w", err) + } + _ = cmd.Process.Release() fmt.Printf("Watch daemon started (pid %d)\n", cmd.Process.Pid) case "daemon": // Internal: run as the actual daemon process - runDaemon(absRoot) + return runDaemon(absRoot) case "stop": active, resolveErr := watch.ResolveActiveRuntime(absRoot) @@ -957,7 +997,14 @@ func runHandoffSubcommand(args []string) { } } -func runDaemon(root string) { +func runDaemon(root string) (runErr error) { + readyPath := os.Getenv(watchReadinessEnv) + readyPublished := false + defer func() { + if readyPath != "" && !readyPublished { + _ = publishWatchReadiness(readyPath, runErr) + } + }() var transition *watch.Transition deadline := time.Now().Add(2 * time.Second) for { @@ -967,37 +1014,49 @@ func runDaemon(root string) { break } if !errors.Is(err, watch.ErrTransitionLocked) || time.Now().After(deadline) { - fmt.Fprintf(os.Stderr, "Error claiming daemon transition: %v\n", err) - return + return fmt.Errorf("claiming daemon transition: %w", err) } time.Sleep(10 * time.Millisecond) } - defer transition.Release() + defer func() { + if transition != nil { + _ = releaseWatchTransition(transition) + } + }() active, err := watch.ResolveActiveRuntime(root) if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving daemon runtime: %v\n", err) - return + return fmt.Errorf("resolving daemon runtime: %w", err) } if active.PID > 0 && active.PID != os.Getpid() { - fmt.Fprintln(os.Stderr, "Error: another watch daemon is already running") - return + return errors.New("another watch daemon is already running") } daemon, err := newWatchProcess(root, false) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } if err := daemon.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Error starting watch: %v\n", err) - os.Exit(1) + return fmt.Errorf("starting watch: %w", err) } // Write PID file - writeWatchPID(root) - _ = transition.Release() + if err := writeWatchPID(root); err != nil { + daemon.Stop() + return fmt.Errorf("publishing daemon PID: %w", err) + } + if err := releaseWatchTransition(transition); err != nil { + daemon.Stop() + _ = watch.RemoveProcessPID(root, os.Getpid()) + return fmt.Errorf("releasing daemon transition: %w", err) + } transition = nil - + if readyPath != "" { + if err := publishWatchReadiness(readyPath, nil); err != nil { + daemon.Stop() + return fmt.Errorf("publishing daemon readiness: %w", err) + } + readyPublished = true + } // Wait for stop signal (SIGTERM or state file removal) sigChan := make(chan os.Signal, 1) notifySignals(sigChan, syscall.SIGTERM, syscall.SIGINT) @@ -1005,6 +1064,51 @@ func runDaemon(root string) { daemon.Stop() _ = watch.RemoveProcessPID(root, os.Getpid()) + return nil +} + +func publishWatchReadiness(path string, readinessErr error) error { + status := watchReadiness{} + if readinessErr != nil { + status.Error = readinessErr.Error() + } + data, err := json.Marshal(status) + if err != nil { + return err + } + tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func waitWatchReadiness(path string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + data, err := os.ReadFile(path) + if err == nil { + var status watchReadiness + if err := json.Unmarshal(data, &status); err != nil { + return fmt.Errorf("reading daemon readiness: %w", err) + } + if status.Error != "" { + return errors.New(status.Error) + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("reading daemon readiness: %w", err) + } + if time.Now().After(deadline) { + return fmt.Errorf("daemon readiness timed out after %s", timeout) + } + time.Sleep(10 * time.Millisecond) + } } // isGitHubURL checks if the input looks like a GitHub repo URL diff --git a/main_more_test.go b/main_more_test.go index 6f06729..c9c6c19 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -293,6 +294,56 @@ func TestMainWatchHelperProcess(t *testing.T) { time.Sleep(time.Minute) } +func TestRunWatchStartWaitsForChildReadinessFailure(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + withMainRuntimeStubs(t, nil, nil, func(string, ...string) *exec.Cmd { + return exec.Command("sh", "-c", `printf '{"error":"claim rejected"}' > "$CODEMAP_WATCH_READINESS_FILE"`) + }, func() (string, error) { return os.Args[0], nil }, nil, nil, nil) + + err := runWatchSubcommand("start", root) + if err == nil || !strings.Contains(err.Error(), "claim rejected") { + t.Fatalf("runWatchSubcommand(start) error = %v, want child readiness failure", err) + } +} + +func TestRunDaemonPublishesInitializationFailure(t *testing.T) { + root := t.TempDir() + readyPath := filepath.Join(t.TempDir(), "ready.json") + t.Setenv(watchReadinessEnv, readyPath) + withMainRuntimeStubs(t, func(string, bool) (watchProcess, error) { + return &fakeWatchProcess{startErr: fmt.Errorf("watch init rejected")}, nil + }, nil, nil, nil, nil, nil, nil) + + if err := runDaemon(root); err == nil || !strings.Contains(err.Error(), "watch init rejected") { + t.Fatalf("runDaemon() error = %v, want initialization failure", err) + } + if err := waitWatchReadiness(readyPath, time.Second); err == nil || !strings.Contains(err.Error(), "watch init rejected") { + t.Fatalf("waitWatchReadiness() error = %v, want published initialization failure", err) + } +} + +func TestRunDaemonPublishesTransitionReleaseFailure(t *testing.T) { + root := t.TempDir() + readyPath := filepath.Join(t.TempDir(), "ready.json") + t.Setenv(watchReadinessEnv, readyPath) + previousRelease := releaseWatchTransition + releaseWatchTransition = func(*watch.Transition) error { return errors.New("release rejected") } + t.Cleanup(func() { releaseWatchTransition = previousRelease }) + withMainRuntimeStubs(t, func(string, bool) (watchProcess, error) { + return &fakeWatchProcess{}, nil + }, nil, nil, nil, nil, nil, nil) + + if err := runDaemon(root); err == nil || !strings.Contains(err.Error(), "release rejected") { + t.Fatalf("runDaemon() error = %v, want transition release failure", err) + } + if err := waitWatchReadiness(readyPath, time.Second); err == nil || !strings.Contains(err.Error(), "release rejected") { + t.Fatalf("waitWatchReadiness() error = %v, want published release failure", err) + } +} + func writeMainWatchState(t *testing.T, root string, state watch.State, running bool) { t.Helper() @@ -496,7 +547,7 @@ func TestRunWatchStartPreservesSetupRoot(t *testing.T) { nil, func(_ string, args ...string) *exec.Cmd { gotArgs = append([]string(nil), args...) - return exec.Command("sh", "-c", "exit 0") + return exec.Command("sh", "-c", `printf '{}' > "$CODEMAP_WATCH_READINESS_FILE"`) }, func() (string, error) { return "/tmp/codemap", nil }, func(string) bool { return false }, @@ -866,7 +917,7 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) { func(name string, args ...string) *exec.Cmd { gotName = name gotArgs = append([]string(nil), args...) - return exec.Command("sh", "-c", "exit 0") + return exec.Command("sh", "-c", `printf '{}' > "$CODEMAP_WATCH_READINESS_FILE"`) }, func() (string, error) { return "/tmp/codemap-test", nil }, func(string) bool { return false }, diff --git a/mcp/main.go b/mcp/main.go index 461fa65..3e57b3b 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -34,22 +34,27 @@ import ( // Global watcher registry - tracks active watchers per project var ( - watchers = make(map[string]*watch.Daemon) - watchersMu sync.RWMutex - runManagedWatchCommand = managedWatchCommand + watchers = make(map[string]*watch.Daemon) + watchersMu sync.RWMutex + runManagedWatchCommand = managedWatchCommand + managedWatchExecCommand = exec.CommandContext buildHandoffForMCP = handoff.BuildContext buildHandoffDetailMCP = handoff.BuildFileDetailContext writeLatestForMCP = handoff.WriteLatest ) +const managedWatchLifecycleTimeout = 35 * time.Second + func managedWatchCommand(ctx context.Context, action, root string) (string, error) { exe, err := os.Executable() if err != nil { return "", err } args := projectpath.PrependSetupRootArgs("watch", action, root) - out, err := exec.CommandContext(ctx, exe, args...).CombinedOutput() + lifecycleCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), managedWatchLifecycleTimeout) + defer cancel() + out, err := managedWatchExecCommand(lifecycleCtx, exe, args...).CombinedOutput() return strings.TrimSpace(string(out)), err } diff --git a/mcp/main_more_test.go b/mcp/main_more_test.go index 86495a0..56cc6c6 100644 --- a/mcp/main_more_test.go +++ b/mcp/main_more_test.go @@ -354,6 +354,49 @@ func TestHandleWatchLifecycleAndActivity(t *testing.T) { } } +func TestHandleStartWatchReportsChildReadinessFailure(t *testing.T) { + withWatcherRegistry(t) + previousCommand := runManagedWatchCommand + runManagedWatchCommand = func(context.Context, string, string) (string, error) { + return "Error: starting daemon: claim rejected", errors.New("exit status 1") + } + t.Cleanup(func() { runManagedWatchCommand = previousCommand }) + + root := t.TempDir() + result, _, err := handleStartWatch(context.Background(), nil, WatchInput{Path: root}) + if err != nil { + t.Fatal(err) + } + if !result.IsError || !strings.Contains(resultText(t, result), "claim rejected") { + t.Fatalf("readiness failure result = %#v", result) + } +} + +func TestManagedWatchCommandOutlivesRequestCancellationWithinBound(t *testing.T) { + previousCommand := managedWatchExecCommand + var commandContextErr error + var commandDeadline time.Time + var hasDeadline bool + managedWatchExecCommand = func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + commandContextErr = ctx.Err() + commandDeadline, hasDeadline = ctx.Deadline() + return exec.Command("sh", "-c", "exit 0") + } + t.Cleanup(func() { managedWatchExecCommand = previousCommand }) + + requestContext, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := managedWatchCommand(requestContext, "start", t.TempDir()); err != nil { + t.Fatal(err) + } + if commandContextErr != nil { + t.Fatalf("lifecycle context inherited request cancellation: %v", commandContextErr) + } + if !hasDeadline || time.Until(commandDeadline) > managedWatchLifecycleTimeout { + t.Fatalf("lifecycle context deadline = %v, want bounded timeout", commandDeadline) + } +} + func TestHandleGraphContextHandlers(t *testing.T) { if !scanner.NewAstGrepAnalyzer().Available() { t.Skip("ast-grep not available") diff --git a/watch/daemon.go b/watch/daemon.go index 60c6937..ab32b45 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -25,7 +25,7 @@ type Daemon struct { graph *Graph watcher *fsnotify.Watcher gitCache *scanner.GitIgnoreCache - eventLog string // path to event log file + eventLog string verbose bool done chan struct{} From e3f888c5934ade1aa5e0d0be63997a17a556edde Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:41:59 +0200 Subject: [PATCH 5/6] fix(watch): Detect prior setup-root daemon state --- watch/state.go | 2 ++ watch/state_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/watch/state.go b/watch/state.go index 1d1c6e1..c99eb0f 100644 --- a/watch/state.go +++ b/watch/state.go @@ -149,6 +149,8 @@ func ResolveActiveRuntime(root string) (ActiveRuntime, error) { var stalePath string for _, candidate := range []ActiveRuntime{ base, + // Keep the immediately-prior explicit setup-root layout readable during migration. + {Directory: filepath.Join(selection.LegacyDir, "projects", projectpath.ProjectKey(selection.ProjectRoot)), CanonicalRoot: selection.ProjectRoot, Legacy: true}, {Directory: selection.LegacyDir, CanonicalRoot: selection.ProjectRoot, Legacy: true}, } { if candidate.Legacy && candidate.Directory == base.Directory { diff --git a/watch/state_test.go b/watch/state_test.go index 202d89e..992b5d6 100644 --- a/watch/state_test.go +++ b/watch/state_test.go @@ -308,6 +308,41 @@ func TestResolveActiveRuntimeUsesOnlyExactlyOwnedLiveLegacyDaemon(t *testing.T) } } +func TestResolveActiveRuntimeFindsPriorExplicitSetupProjectDaemon(t *testing.T) { + setup := t.TempDir() + project := filepath.Join(t.TempDir(), "project") + if err := os.Mkdir(project, 0o755); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + selection, err := projectpath.SelectRuntime(project) + if err != nil { + t.Fatal(err) + } + legacyDir := filepath.Join(selection.LegacyDir, "projects", projectpath.ProjectKey(selection.ProjectRoot)) + process := exec.Command(os.Args[0], "-test.run=TestHelperWatchDaemonProcess", "--", "watch", "daemon", selection.ProjectRoot) + process.Env = append(os.Environ(), "CODEMAP_TEST_WATCH_HELPER=1") + if err := process.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = process.Process.Kill(); _, _ = process.Process.Wait() }) + if err := os.MkdirAll(legacyDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyDir, "watch.pid"), []byte(fmt.Sprint(process.Process.Pid)), 0o644); err != nil { + t.Fatal(err) + } + + active, err := ResolveActiveRuntime(project) + if err != nil { + t.Fatal(err) + } + if active.Directory != legacyDir || !active.Legacy || active.PID != process.Process.Pid { + t.Fatalf("ResolveActiveRuntime() = %#v, want prior setup-root daemon", active) + } +} + func TestTransitionLockSerializesStartsAndReleases(t *testing.T) { root := t.TempDir() first, err := AcquireTransition(root) From dea7b15561dbf0bffd970674f1e72174b4de5b61 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:42:04 +0200 Subject: [PATCH 6/6] fix(watch): Preserve daemon PID across release --- main.go | 7 ++++--- main_more_test.go | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 60f6308..37ce956 100644 --- a/main.go +++ b/main.go @@ -795,7 +795,8 @@ func runWatchSubcommand(subCmd, root string) error { if err := cmd.Start(); err != nil { return fmt.Errorf("starting daemon: %w", err) } - if err := writeWatchProcessPID(absRoot, cmd.Process.Pid); err != nil { + pid := cmd.Process.Pid + if err := writeWatchProcessPID(absRoot, pid); err != nil { _ = cmd.Process.Kill() _ = cmd.Process.Release() return fmt.Errorf("publishing daemon PID: %w", err) @@ -809,11 +810,11 @@ func runWatchSubcommand(subCmd, root string) error { if err := waitWatchReadiness(readyPath, watchReadinessTimeout); err != nil { _ = cmd.Process.Kill() _ = cmd.Process.Release() - _ = watch.RemoveProcessPID(absRoot, cmd.Process.Pid) + _ = watch.RemoveProcessPID(absRoot, pid) return fmt.Errorf("starting daemon: %w", err) } _ = cmd.Process.Release() - fmt.Printf("Watch daemon started (pid %d)\n", cmd.Process.Pid) + fmt.Printf("Watch daemon started (pid %d)\n", pid) case "daemon": // Internal: run as the actual daemon process diff --git a/main_more_test.go b/main_more_test.go index c9c6c19..a4b96d3 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -307,6 +307,9 @@ func TestRunWatchStartWaitsForChildReadinessFailure(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "claim rejected") { t.Fatalf("runWatchSubcommand(start) error = %v, want child readiness failure", err) } + if _, statErr := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid")); !os.IsNotExist(statErr) { + t.Fatalf("readiness failure left daemon PID behind: %v", statErr) + } } func TestRunDaemonPublishesInitializationFailure(t *testing.T) { @@ -940,6 +943,9 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) { if !strings.Contains(stdout, "Watch daemon started (pid ") { t.Fatalf("expected start output, got:\n%s", stdout) } + if strings.Contains(stdout, "pid -1") { + t.Fatalf("start output used released process PID: %s", stdout) + } }) }