diff --git a/cmd/hooks.go b/cmd/hooks.go index 8b39cad..461524d 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -159,6 +159,27 @@ func RunHookWithTimeout(hookName, root string, timeout time.Duration) error { return RunHook(hookName, root) } + if hookName == "session-stop" { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + resolvedRoot, _, err := ResolveNearestGitRoot(root) + if err != nil { + return err + } + root, err = ValidateProjectPath(resolvedRoot) + if err != nil { + return err + } + out := io.Writer(os.Stdout) + if os.Getenv("CODEX") == "1" { + out = io.Discard + } + err = hookSessionStopContext(ctx, root, out) + if errors.Is(err, context.DeadlineExceeded) { + return &HookTimeoutError{Hook: hookName, Timeout: timeout} + } + return err + } return runWithTimeout(hookName, timeout, func() error { return RunHook(hookName, root) }) @@ -1422,20 +1443,43 @@ func hookPreCompact(root string) error { // hookSessionStop summarizes what changed in the session and stops the daemon func hookSessionStop(root string) error { - sessionID := hookSessionIDFromStdin() - // Read state BEFORE stopping daemon (includes timeline) - state := watch.ReadState(root) + return hookSessionStopContext(context.Background(), root, os.Stdout) +} - finishSessionDaemon(root, sessionID) +func hookSessionStopContext(ctx context.Context, root string, out io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + sessionID, err := hookSessionIDFromStdinContext(ctx) + if err != nil { + return err + } + flushCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + state, flushErr := watch.FlushState(flushCtx, root) + cancel() + if flushErr != nil { + state = watch.ReadState(root) + fmt.Fprintln(out, "warning: session summary may be incomplete") + } + if err := ctx.Err(); err != nil { + return err + } - fmt.Println() - fmt.Println("📊 Session Summary") - fmt.Println("==================") + if err := finishSessionDaemonContext(ctx, root, sessionID); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + + fmt.Fprintln(out) + fmt.Fprintln(out, "📊 Session Summary") + fmt.Fprintln(out, "==================") // Show timeline from daemon events (if available) if state != nil && len(state.RecentEvents) > 0 { - fmt.Println() - fmt.Println("Edit Timeline:") + fmt.Fprintln(out) + fmt.Fprintln(out, "Edit Timeline:") // Calculate stats totalDelta := 0 @@ -1455,7 +1499,7 @@ func hookSessionStop(root string) error { start := 0 if len(events) > 10 { start = len(events) - 10 - fmt.Printf(" ... %d earlier events\n", start) + fmt.Fprintf(out, " ... %d earlier events\n", start) } for _, e := range events[start:] { @@ -1471,7 +1515,7 @@ func hookSessionStop(root string) error { hubStr = " ⚠️HUB" } - fmt.Printf(" %s %-6s %s%s%s\n", + fmt.Fprintf(out, " %s %-6s %s%s%s\n", e.Time.Format("15:04:05"), e.Op, e.Path, @@ -1481,69 +1525,82 @@ func hookSessionStop(root string) error { } // Show stats - fmt.Println() - fmt.Printf("Stats: %d events, %d files touched, %+d lines", + fmt.Fprintln(out) + fmt.Fprintf(out, "Stats: %d events, %d files touched, %+d lines", len(state.RecentEvents), len(fileEdits), totalDelta) if hubEdits > 0 { - fmt.Printf(", %d hub edits", hubEdits) + fmt.Fprintf(out, ", %d hub edits", hubEdits) } - fmt.Println() + fmt.Fprintln(out) } else { // Fallback to git diff if no daemon events - gitCmd := exec.Command("git", "diff", "--name-only") + gitCmd := exec.CommandContext(ctx, "git", "diff", "--name-only") gitCmd.Dir = root output, err := gitCmd.Output() if err != nil { - fmt.Println("No changes tracked.") + fmt.Fprintln(out, "No changes tracked.") return nil } modified := strings.TrimSpace(string(output)) if modified == "" { - fmt.Println("No files modified.") + fmt.Fprintln(out, "No files modified.") return nil } info := getHubInfoNoFallback(root) - fmt.Println() - fmt.Println("Files modified:") + fmt.Fprintln(out) + fmt.Fprintln(out, "Files modified:") lineScanner := bufio.NewScanner(strings.NewReader(modified)) count := 0 for lineScanner.Scan() { file := lineScanner.Text() count++ if count > 10 { - fmt.Printf(" ... and more\n") + fmt.Fprintln(out, " ... and more") break } if info != nil && info.isHub(file) { importers := len(info.Importers[file]) - fmt.Printf(" ⚠️ %s (HUB - imported by %d files)\n", file, importers) + fmt.Fprintf(out, " ⚠️ %s (HUB - imported by %d files)\n", file, importers) } else { - fmt.Printf(" • %s\n", file) + fmt.Fprintf(out, " • %s\n", file) } } } - if err := writeSessionHandoff(root, state); err == nil { - fmt.Printf("🤝 Saved handoff to .codemap/handoff.latest.json\n") + if err := ctx.Err(); err != nil { + return err + } + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) >= 250*time.Millisecond { + if err := writeSessionHandoffContext(ctx, root, state); err != nil { + return err + } + fmt.Fprintln(out, "🤝 Saved handoff to .codemap/handoff.latest.json") } - fmt.Println() - return nil + fmt.Fprintln(out) + return ctx.Err() } -func writeSessionHandoff(root string, state *watch.State) error { - baseRef := resolveHandoffBaseRef(root) +func writeSessionHandoffContext(ctx context.Context, root string, state *watch.State) error { + if err := ctx.Err(); err != nil { + return err + } + baseRef := resolveHandoffBaseRefContext(ctx, root) artifact, err := handoff.Build(root, handoff.BuildOptions{ + Context: ctx, State: state, BaseRef: baseRef, }) if err != nil { return err } + if err := ctx.Err(); err != nil { + return err + } // Record this agent session in handoff history agentEntry := handoff.AgentEntry{ @@ -1565,6 +1622,9 @@ func writeSessionHandoff(root string, state *watch.State) error { if prev, err := handoff.ReadLatest(root); err == nil && prev != nil { artifact.AgentHistory = prev.AgentHistory } + if err := ctx.Err(); err != nil { + return err + } artifact.AgentHistory = append(artifact.AgentHistory, agentEntry) // Cap history to last 20 entries @@ -1601,20 +1661,24 @@ func sessionStartTime(state *watch.State) time.Time { } func resolveHandoffBaseRef(root string) string { - if remoteDefault, ok := gitSymbolicRef(root, "refs/remotes/origin/HEAD"); ok && remoteDefault != "" { - if gitRefExists(root, remoteDefault) { + return resolveHandoffBaseRefContext(context.Background(), root) +} + +func resolveHandoffBaseRefContext(ctx context.Context, root string) string { + if remoteDefault, ok := gitSymbolicRefContext(ctx, root, "refs/remotes/origin/HEAD"); ok && remoteDefault != "" { + if gitRefExistsContext(ctx, root, remoteDefault) { return remoteDefault } } for _, ref := range []string{"main", "master", "trunk", "develop"} { - if gitRefExists(root, ref) { + if gitRefExistsContext(ctx, root, ref) { return ref } } for _, ref := range []string{"origin/main", "origin/master", "origin/trunk", "origin/develop"} { - if gitRefExists(root, ref) { + if gitRefExistsContext(ctx, root, ref) { return ref } } @@ -1623,14 +1687,14 @@ func resolveHandoffBaseRef(root string) string { return "HEAD" } -func gitRefExists(root, ref string) bool { - cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref) +func gitRefExistsContext(ctx context.Context, root, ref string) bool { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "--quiet", ref) cmd.Dir = root return cmd.Run() == nil } -func gitSymbolicRef(root, ref string) (string, bool) { - cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", ref) +func gitSymbolicRefContext(ctx context.Context, root, ref string) (string, bool) { + cmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--quiet", "--short", ref) cmd.Dir = root out, err := cmd.Output() if err != nil { @@ -1644,15 +1708,34 @@ func gitSymbolicRef(root, ref string) (string, bool) { } func hookSessionIDFromStdin() string { + sessionID, _ := hookSessionIDFromStdinContext(context.Background()) + return sessionID +} + +func hookSessionIDFromStdinContext(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } info, err := os.Stdin.Stat() if err == nil && info.Mode()&os.ModeCharDevice != 0 { - return "" + return "", nil + } + if deadline, ok := ctx.Deadline(); ok { + if err := os.Stdin.SetReadDeadline(deadline); err == nil { + defer os.Stdin.SetReadDeadline(time.Time{}) + } } input, err := io.ReadAll(os.Stdin) + if ctxErr := ctx.Err(); ctxErr != nil { + return "", ctxErr + } + if errors.Is(err, os.ErrDeadlineExceeded) { + return "", context.DeadlineExceeded + } if err != nil || len(strings.TrimSpace(string(input))) == 0 { - return "" + return "", nil } - return sessionIDFromHookInput(input) + return sessionIDFromHookInput(input), nil } // sessionIDFromHookInput extracts the agent session id from a raw hook payload. @@ -1683,21 +1766,35 @@ func ensureSessionDaemon(root, sessionID string) { } } -func finishSessionDaemon(root, sessionID string) { +func finishSessionDaemonContext(ctx context.Context, root, sessionID string) error { + if err := ctx.Err(); err != nil { + return err + } if sessionID == "" { - stopDaemon(root) - return + return stopDaemonContext(ctx, root) } - if err := updateSessionLease(root, sessionID, false, time.Now(), func(active int) { + var stopErr error + if err := updateSessionLeaseContext(ctx, root, sessionID, false, time.Now(), func(active int) { if active == 0 { - stopDaemon(root) + stopErr = stopDaemonContext(ctx, root) } }); err != nil { - stopDaemon(root) + return stopDaemonContext(ctx, root) + } + if stopErr != nil { + return stopErr } + return ctx.Err() } func updateSessionLease(root, sessionID string, active bool, now time.Time, action func(int)) error { + return updateSessionLeaseContext(context.Background(), root, sessionID, active, now, action) +} + +func updateSessionLeaseContext(ctx context.Context, root, sessionID string, active bool, now time.Time, action func(int)) error { + if err := ctx.Err(); err != nil { + return err + } if strings.TrimSpace(sessionID) == "" { if action != nil { action(0) @@ -1712,7 +1809,7 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti return err } lockPath := filepath.Join(codemapDir, "sessions.lock") - if err := acquireSessionLock(lockPath); err != nil { + if err := acquireSessionLockContext(ctx, lockPath); err != nil { return err } defer os.Remove(lockPath) @@ -1726,6 +1823,9 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti return err } for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } if entry.IsDir() { continue } @@ -1739,6 +1839,9 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti leaseName := fmt.Sprintf("%x.json", sha256.Sum256([]byte(detectAgentID()+"\x00"+sessionID))) leasePath := filepath.Join(leaseDir, leaseName) if active { + if err := ctx.Err(); err != nil { + return err + } payload, err := json.Marshal(map[string]any{ "agent": detectAgentID(), "updated_at": now.UTC().Format(time.RFC3339Nano), @@ -1769,9 +1872,12 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti return nil } -func acquireSessionLock(lockPath string) error { +func acquireSessionLockContext(ctx context.Context, lockPath string) error { deadline := time.Now().Add(sessionLockWait) for { + if err := ctx.Err(); err != nil { + return err + } err := os.Mkdir(lockPath, 0o700) if err == nil { return nil @@ -1786,22 +1892,47 @@ func acquireSessionLock(lockPath string) error { if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for session lock %s", lockPath) } - time.Sleep(10 * time.Millisecond) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } } } // stopDaemon stops the watch daemon func stopDaemon(root string) { + _ = stopDaemonContext(context.Background(), root) +} + +func stopDaemonContext(ctx context.Context, root string) error { + if err := ctx.Err(); err != nil { + return err + } if !hookWatchIsRunning(root) { - return + return nil } exe, err := hookExecutablePath() if err != nil { - return + return err } args := projectpath.PrependSetupRootArgs("watch", "stop", root) cmd := hookExecCommand(exe, args...) - cmd.Run() + if err := cmd.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return err + case <-ctx.Done(): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-done + return ctx.Err() + } } // extractFilePathsFromStdin reads Claude or Codex hook JSON from stdin and diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 7e80c86..b8bc8f4 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -1,6 +1,8 @@ package cmd import ( + "bytes" + "context" "encoding/json" "errors" "fmt" @@ -749,6 +751,85 @@ func TestHookSessionStopSummaryBranches(t *testing.T) { }) } +func TestHookSessionStopContextReturnsWithoutPostDeadlineOutput(t *testing.T) { + root := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var out bytes.Buffer + if err := hookSessionStopContext(ctx, root, &out); err == nil { + t.Fatal("hookSessionStopContext succeeded after cancellation") + } + before := out.String() + time.Sleep(20 * time.Millisecond) + if got := out.String(); got != before { + t.Fatalf("output changed after return: before %q after %q", before, got) + } +} + +func TestFinishSessionDaemonContextPropagatesStopFailure(t *testing.T) { + root := t.TempDir() + withHookRuntimeStubs(t, + func() (string, error) { return "codemap", nil }, + func(string, ...string) *exec.Cmd { return exec.Command(filepath.Join(root, "missing-codemap")) }, + func(string) bool { return true }, + nil, + ) + if err := finishSessionDaemonContext(context.Background(), root, "session-a"); err == nil { + t.Fatal("finishSessionDaemonContext discarded stop failure") + } +} + +func TestHookSessionStopContextPropagatesHandoffFailure(t *testing.T) { + root := makeRepoOnBranch(t, "feature/handoff-failure") + writeStateOnly(t, root, watch.State{UpdatedAt: time.Now(), RecentEvents: []watch.Event{{Time: time.Now(), Op: "WRITE", Path: "main.go"}}}) + if err := os.MkdirAll(handoff.LatestPath(root), 0o755); err != nil { + t.Fatal(err) + } + if err := hookSessionStopContext(context.Background(), root, io.Discard); err == nil { + t.Fatal("hookSessionStopContext discarded handoff failure") + } +} + +type cancelOnWrite struct { + cancel context.CancelFunc + match string +} + +func (w cancelOnWrite) Write(p []byte) (int, error) { + if strings.Contains(string(p), w.match) { + w.cancel() + } + return len(p), nil +} + +func TestHookSessionStopContextChecksDeadlineAfterHandoff(t *testing.T) { + root := makeRepoOnBranch(t, "feature/handoff-deadline") + writeStateOnly(t, root, watch.State{UpdatedAt: time.Now(), RecentEvents: []watch.Event{{Time: time.Now(), Op: "WRITE", Path: "main.go"}}}) + ctx, cancel := context.WithCancel(context.Background()) + err := hookSessionStopContext(ctx, root, cancelOnWrite{cancel: cancel, match: "Saved handoff"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("hookSessionStopContext error = %v, want context.Canceled", err) + } +} + +func TestHookSessionIDFromStdinContextBoundsOpenPipe(t *testing.T) { + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer reader.Close() + defer writer.Close() + original := os.Stdin + os.Stdin = reader + defer func() { os.Stdin = original }() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, err = hookSessionIDFromStdinContext(ctx) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want deadline exceeded", err) + } +} + func TestDaemonCommandHelpersAndMultiRepoShellout(t *testing.T) { t.Run("start daemon shells out to watch start", func(t *testing.T) { var gotName string diff --git a/handoff/build.go b/handoff/build.go index 4b80329..30b77d9 100644 --- a/handoff/build.go +++ b/handoff/build.go @@ -59,7 +59,11 @@ func normalizeOptions(opts BuildOptions, fileCount int) BuildOptions { // Build creates a multi-agent handoff artifact from git + daemon state. func Build(root string, opts BuildOptions) (*Artifact, error) { - return BuildContext(context.Background(), root, opts) + ctx := opts.Context + if ctx == nil { + ctx = context.Background() + } + return BuildContext(ctx, root, opts) } // BuildContext creates a handoff artifact while honoring caller cancellation diff --git a/handoff/handoff_test.go b/handoff/handoff_test.go index 1467a1b..e60bcf2 100644 --- a/handoff/handoff_test.go +++ b/handoff/handoff_test.go @@ -1,7 +1,9 @@ package handoff import ( + "context" "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -13,6 +15,15 @@ import ( "codemap/watch" ) +func TestBuildHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Build(t.TempDir(), BuildOptions{Context: ctx}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Build error = %v, want context canceled", err) + } +} + func runCmd(t *testing.T, dir, name string, args ...string) { t.Helper() cmd := exec.Command(name, args...) diff --git a/handoff/types.go b/handoff/types.go index f016907..8daaee5 100644 --- a/handoff/types.go +++ b/handoff/types.go @@ -1,6 +1,7 @@ package handoff import ( + "context" "time" "codemap/watch" @@ -119,6 +120,7 @@ type FileDetail struct { // BuildOptions controls handoff generation behavior. type BuildOptions struct { + Context context.Context BaseRef string Since time.Duration State *watch.State diff --git a/main_more_test.go b/main_more_test.go index a4b96d3..21ada7d 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -911,6 +911,8 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) { t.Run("watch start shells out to daemon entrypoint", func(t *testing.T) { root := t.TempDir() + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) var gotName string var gotArgs []string withMainRuntimeStubs( @@ -928,7 +930,11 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) { nil, ) - stdout, _ := captureMainStreams(t, func() { runWatchSubcommand("start", root) }) + var startErr error + stdout, stderr := captureMainStreams(t, func() { startErr = runWatchSubcommand("start", root) }) + if startErr != nil { + t.Fatalf("watch start failed: %v\nstderr:\n%s", startErr, stderr) + } if gotName != "/tmp/codemap-test" { t.Fatalf("watch start executable = %q, want /tmp/codemap-test", gotName) } diff --git a/watch/daemon.go b/watch/daemon.go index e386747..8bf99e0 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -36,7 +36,9 @@ type Daemon struct { verbose bool done chan struct{} - eventLoopWG sync.WaitGroup + eventLoopWG sync.WaitGroup + publisher *statePublisher + closeWatcher func() error } func (d *Daemon) runtimeStateDir() (string, error) { @@ -46,6 +48,21 @@ func (d *Daemon) runtimeStateDir() (string, error) { return projectpath.CheckedRuntimeCodemapDir(d.root) } +func (d *Daemon) ensurePublisher() error { + if d.publisher != nil { + return nil + } + runtimeDir, err := d.runtimeStateDir() + if err != nil { + return err + } + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + return err + } + d.publisher = newStatePublisher(d, filepath.Join(runtimeDir, "state.json"), "legacy-test-instance") + return nil +} + // NewDaemon creates a new watch daemon for the given root func NewDaemon(root string, verbose bool) (*Daemon, error) { absRoot, err := filepath.Abs(root) @@ -75,14 +92,15 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { } d := &Daemon{ - root: absRoot, - configDir: selection.PolicyDir, - runtimeDir: runtimeDir, - watcher: watcher, - gitCache: gitCache, - verbose: verbose, - done: make(chan struct{}), - eventLog: filepath.Join(runtimeDir, "events.log"), + root: absRoot, + configDir: selection.PolicyDir, + runtimeDir: runtimeDir, + watcher: watcher, + gitCache: gitCache, + verbose: verbose, + done: make(chan struct{}), + closeWatcher: watcher.Close, + eventLog: filepath.Join(runtimeDir, "events.log"), graph: &Graph{ Root: absRoot, Files: make(map[string]*scanner.FileInfo), @@ -94,15 +112,26 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { IsGitRepo: isGitRepo, }, } + instance, err := newDaemonInstance() + if err != nil { + watcher.Close() + return nil, fmt.Errorf("create daemon identity: %w", err) + } + d.publisher = newStatePublisher(d, filepath.Join(runtimeDir, "state.json"), instance) return d, nil } // 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 := d.ensurePublisher(); err != nil { + return fmt.Errorf("resolve runtime state: %w", err) + } + runtimeDir, err := d.runtimeStateDir() + if err != nil { + return fmt.Errorf("resolve runtime state: %w", err) + } + codemapDir := runtimeDir if err := os.MkdirAll(codemapDir, 0755); err != nil { return fmt.Errorf("failed to create .codemap dir: %w", err) } @@ -130,9 +159,17 @@ func (d *Daemon) Start() error { if err := d.watcher.Add(configDir); err != nil { return fmt.Errorf("failed to watch .codemap dir: %w", err) } + if err := ensureControlDirectory(d.publisher.flushDir); err != nil { + return fmt.Errorf("create flush directory: %w", err) + } + if err := d.watcher.Add(d.publisher.flushDir); err != nil { + return fmt.Errorf("watch flush directory: %w", err) + } // Write initial state for hooks to read immediately - d.writeState() + if err := d.publisher.publish(); err != nil { + return fmt.Errorf("publish initial state: %w", err) + } // Start event loop d.eventLoopWG.Add(1) @@ -193,8 +230,8 @@ func (d *Daemon) computeTopology() { // Stop gracefully shuts down the daemon func (d *Daemon) Stop() { close(d.done) - d.watcher.Close() d.eventLoopWG.Wait() + _ = d.closeWatcher() } // GetGraph returns the current graph (thread-safe) @@ -240,7 +277,10 @@ func shouldComputeDependencyGraph(fileCount int) bool { // WriteInitialState writes state after initial scan (for hooks) func (d *Daemon) WriteInitialState() { - d.writeState() + if d.ensurePublisher() != nil { + return + } + _ = d.publisher.publish() } // fullScan does a complete scan of the project diff --git a/watch/events.go b/watch/events.go index 9084ca2..4869c75 100644 --- a/watch/events.go +++ b/watch/events.go @@ -3,7 +3,6 @@ package watch import ( "bufio" "bytes" - "encoding/json" "fmt" "io" "os" @@ -167,6 +166,9 @@ func (d *Daemon) eventLoop() { } } delay, ok := debouncer.nextDelay(now) + if publishDelay, publishOK := d.publisher.nextDelay(now); publishOK && (!ok || publishDelay < delay) { + delay, ok = publishDelay, true + } if !ok { timerC = nil return @@ -178,6 +180,9 @@ func (d *Daemon) eventLoop() { for _, event := range debouncer.takeDue(now) { d.handleEvent(event) } + if d.publisher.due(now) { + _ = d.publisher.publish() + } } // Control events are coalesced separately from file events; a trailing-edge @@ -209,11 +214,15 @@ func (d *Daemon) eventLoop() { for _, event := range debouncer.takeAll() { d.handleEvent(event) } + if d.publisher.dirty || len(d.publisher.pending) > 0 { + _ = d.publisher.publish() + } }() for { select { case <-d.done: + d.drainQueued(debouncer, d.watcher.Events) return case <-timerC: @@ -229,6 +238,11 @@ func (d *Daemon) eventLoop() { return } now := time.Now() + if filepath.Clean(filepath.Dir(event.Name)) == filepath.Clean(d.publisher.flushDir) { + d.publisher.scanRequests(now) + armTimer(now) + continue + } if resetIgnoreCache, control := d.filterControlEvent(event.Name); control { // OR the flag across the burst: a coalesced refresh must still // reset the ignore cache if any event in it was a .gitignore. @@ -285,6 +299,7 @@ func (d *Daemon) eventLoop() { if d.verbose { fmt.Printf("[watch] Error: %v\n", err) } + d.publisher.failPending("watch_error") } } } @@ -328,6 +343,71 @@ func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) { } } +func (d *Daemon) processQueuedEvent(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) { + if filepath.Clean(filepath.Dir(event.Name)) == filepath.Clean(d.publisher.flushDir) { + d.publisher.scanRequests(now) + return + } + for _, pending := range debouncer.takeDueBeforeEvent(event, now) { + d.handleEvent(pending) + } + if d.handleTopologyControlEvent(event) { + return + } + isCreate := event.Op&fsnotify.Create != 0 + if !d.isSourceFile(event.Name) && !isTopologyManifest(event.Name) { + if !isCreate { + return + } + if info, err := os.Stat(event.Name); err != nil || !info.IsDir() { + return + } + } + if isTransientFile(event.Name) { + return + } + switch d.debounceAction(debouncer, event, now) { + case debounceSkip: + debouncer.cancelPending(event.Name) + case debounceDefer: + debouncer.deferEvent(event, now) + case debounceProcess: + d.handleEvent(event) + } +} + +func (d *Daemon) drainQueued(debouncer *eventDebouncer, events <-chan fsnotify.Event) { + quiet := time.NewTimer(5 * time.Millisecond) + deadline := time.NewTimer(50 * time.Millisecond) + defer quiet.Stop() + defer deadline.Stop() + for { + select { + case event, ok := <-events: + if !ok { + goto drained + } + d.processQueuedEvent(debouncer, event, time.Now()) + if !quiet.Stop() { + select { + case <-quiet.C: + default: + } + } + quiet.Reset(5 * time.Millisecond) + case <-quiet.C: + goto drained + case <-deadline.C: + goto drained + } + } + +drained: + for _, event := range debouncer.takeAll() { + d.handleEvent(event) + } + d.publisher.scanRequests(time.Now()) +} func (d *Daemon) handleTopologyControlEvent(event fsnotify.Event) bool { rel, err := filepath.Rel(d.configDir, event.Name) if err != nil || filepath.Clean(rel) != "config.json" { @@ -350,6 +430,7 @@ func (d *Daemon) debounceAction(debouncer *eventDebouncer, event fsnotify.Event, d.graph.mu.RLock() cached := d.graph.State[relPath] + _, configured := d.graph.ConfiguredFiles[relPath] var cachedSize, cachedModTime int64 if cached != nil { cachedSize = cached.Size @@ -364,9 +445,15 @@ func (d *Daemon) debounceAction(debouncer *eventDebouncer, event fsnotify.Event, if err != nil { return debounceProcess } + // An identical write carries no new information and is skipped for every + // file. Configured files still bypass the quiet-window defer so their + // changes reach graph invalidation immediately. if cachedModTime != 0 && cachedSize == info.Size() && cachedModTime == info.ModTime().UnixNano() { return debounceSkip } + if configured { + return debounceProcess + } return debounceDefer } @@ -432,6 +519,8 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { // Update graph and calculate deltas d.graph.mu.Lock() + _, wasConfigured := d.graph.ConfiguredFiles[relPath] + var isConfigured bool switch op { case "CREATE", "WRITE": info, err := os.Stat(fsEvent.Name) @@ -500,6 +589,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { } if d.isConfiguredFile(relPath) { d.graph.ConfiguredFiles[relPath] = struct{}{} + isConfigured = true } else { delete(d.graph.ConfiguredFiles, relPath) } @@ -548,6 +638,14 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { } d.graph.mu.Unlock() + if d.publisher != nil { + d.publisher.markDirty(time.Now()) + } + // Persist the stale graph state synchronously so hooks and direct callers + // observe the invalidation even when the coalesced publish loop is idle. + if wasConfigured || isConfigured { + d.writeState() + } // Log event d.logEvent(event) @@ -670,59 +768,14 @@ func (d *Daemon) logEvent(e Event) { _ = trimEventLogToBytes(d.eventLog, int64(limits.MaxEventLogBytes), int64(limits.EventLogTrimToBytes)) - // Update state file for hooks to read - d.writeState() } // 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(runtimeDir, 0o755); err != nil { + if d.ensurePublisher() != nil { return } - - // Keep state snapshots small and deterministic for hook reads. - events := d.graph.Events - if len(events) > limits.MaxStateRecentEvents { - events = events[len(events)-limits.MaxStateRecentEvents:] - } - eventsCopy := append([]Event(nil), events...) - - configuredFileCount := len(d.graph.ConfiguredFiles) - if d.graph.ConfiguredFiles == nil { - configuredFileCount = len(d.graph.Files) - } - state := State{ - Root: canonicalRoot(d.root), - UpdatedAt: time.Now(), - FileCount: len(d.graph.Files), - ConfiguredFileCount: &configuredFileCount, - Hubs: []string{}, - Importers: map[string][]string{}, - Imports: map[string][]string{}, - RecentEvents: eventsCopy, - WorkingSet: d.graph.WorkingSet.Snapshot(50), - } - if d.graph.FileGraph != nil { - state.Hubs = d.graph.FileGraph.HubFiles() - state.Importers = d.graph.FileGraph.Importers - state.Imports = d.graph.FileGraph.Imports - state.Coverage = d.graph.FileGraph.Coverage - } - - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return - } - - stateFile := filepath.Join(runtimeDir, "state.json") - _ = runtimefile.WriteAtomic(stateFile, data, 0o644) + _ = d.publisher.publish() } func appendBoundedEvents(events []Event, event Event) []Event { diff --git a/watch/events_debounce_test.go b/watch/events_debounce_test.go index 30bc8cd..2b1ed47 100644 --- a/watch/events_debounce_test.go +++ b/watch/events_debounce_test.go @@ -1,6 +1,7 @@ package watch import ( + "encoding/json" "os" "path/filepath" "testing" @@ -111,6 +112,75 @@ func TestEventDebouncerSkipsRapidWrites(t *testing.T) { } } +func TestDaemonDrainQueuedProcessesSourceAndFlushBeforeFinalPublication(t *testing.T) { + root := canonicalDaemonRoot(t) + path := filepath.Join(root, "main.go") + if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + if err := d.fullScan(); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(d.publisher.flushDir, 0o755); err != nil { + t.Fatal(err) + } + if err := d.publisher.publish(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("package main\n\nvar changed = true\n"), 0o644); err != nil { + t.Fatal(err) + } + req := flushRequest{Version: flushProtocolVersion, CanonicalRoot: d.root, DaemonInstance: d.publisher.instance, Nonce: "drain", ObservedGeneration: d.publisher.generation, Timestamp: time.Now()} + data, _ := json.Marshal(req) + requestPath := filepath.Join(d.publisher.flushDir, "request-drain.json") + if err := os.WriteFile(requestPath, data, 0o600); err != nil { + t.Fatal(err) + } + events := make(chan fsnotify.Event, 2) + events <- fsnotify.Event{Name: path, Op: fsnotify.Write} + events <- fsnotify.Event{Name: requestPath, Op: fsnotify.Create} + close(events) + d.drainQueued(newEventDebouncer(100*time.Millisecond), events) + if err := d.publisher.publish(); err != nil { + t.Fatal(err) + } + if got := d.GetEvents(0); len(got) == 0 || got[len(got)-1].Path != "main.go" { + t.Fatalf("queued source event not published: %#v", got) + } + if _, err := os.Stat(filepath.Join(d.publisher.flushDir, "ack-drain.json")); err != nil { + t.Fatalf("queued flush not acknowledged: %v", err) + } +} + +func TestStopClosesWatcherAfterEventLoopDrain(t *testing.T) { + d, err := NewDaemon(t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + drained := make(chan struct{}) + d.eventLoopWG.Add(1) + go func() { + defer d.eventLoopWG.Done() + <-d.done + close(drained) + }() + closeWatcher := d.closeWatcher + d.closeWatcher = func() error { + select { + case <-drained: + default: + t.Error("watcher closed before event-loop drain") + } + return closeWatcher() + } + d.Stop() +} + func TestEventDebouncerDoesNotSkipNonWriteOps(t *testing.T) { debouncer := newEventDebouncer(100 * time.Millisecond) base := time.Unix(0, 0) diff --git a/watch/flush.go b/watch/flush.go new file mode 100644 index 0000000..33f1d4c --- /dev/null +++ b/watch/flush.go @@ -0,0 +1,152 @@ +package watch + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "codemap/internal/runtimefile" +) + +const flushProtocolVersion = 1 + +var ErrFlushUnsupported = errors.New("flush_unsupported") + +func ensureControlDirectory(path string) error { + if info, err := os.Lstat(path); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsafe control directory %q", path) + } + return nil + } else if !os.IsNotExist(err) { + return err + } + if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsafe control directory %q", path) + } + return nil +} + +type flushRequest struct { + Version int `json:"version"` + CanonicalRoot string `json:"canonical_root"` + DaemonInstance string `json:"daemon_instance"` + Nonce string `json:"nonce"` + ObservedGeneration uint64 `json:"observed_generation"` + Timestamp time.Time `json:"timestamp"` +} +type flushAck struct { + Version int `json:"version"` + CanonicalRoot string `json:"canonical_root"` + DaemonInstance string `json:"daemon_instance"` + Nonce string `json:"nonce"` + ObservedGeneration uint64 `json:"observed_generation"` + PublishedGeneration uint64 `json:"published_generation"` + Timestamp time.Time `json:"timestamp"` + Success bool `json:"success"` + ErrorCode string `json:"error_code,omitempty"` +} + +func (r flushRequest) identity() string { + return fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%d", r.Version, r.CanonicalRoot, r.DaemonInstance, r.Nonce, r.ObservedGeneration) +} +func validateFlushAck(r flushRequest, a flushAck) error { + if !a.Success { + return fmt.Errorf("flush failed: %s", a.ErrorCode) + } + if a.Version != r.Version || a.CanonicalRoot != r.CanonicalRoot || a.DaemonInstance != r.DaemonInstance || a.Nonce != r.Nonce || a.ObservedGeneration != r.ObservedGeneration || a.PublishedGeneration <= r.ObservedGeneration { + return errors.New("invalid flush acknowledgement") + } + return nil +} + +func readStateFromActive(active ActiveRuntime) (*State, error) { + data, err := runtimefile.Read(filepath.Join(active.Directory, "state.json")) + if err != nil { + return nil, err + } + var s State + if err = json.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} + +func FlushState(ctx context.Context, root string) (*State, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + active, err := ResolveActiveRuntime(root) + if err != nil { + return nil, err + } + if active.Legacy { + return nil, ErrFlushUnsupported + } + state, err := readStateFromActive(active) + if err != nil { + return nil, err + } + if state.DaemonInstance == "" || state.CanonicalRoot != active.CanonicalRoot { + return nil, ErrFlushUnsupported + } + var b [16]byte + if _, err = rand.Read(b[:]); err != nil { + return nil, err + } + nonce := hex.EncodeToString(b[:]) + req := flushRequest{Version: flushProtocolVersion, CanonicalRoot: active.CanonicalRoot, DaemonInstance: state.DaemonInstance, Nonce: nonce, ObservedGeneration: state.Generation, Timestamp: time.Now().UTC()} + data, _ := json.Marshal(req) + dir := filepath.Join(active.Directory, "flush") + if err = ensureControlDirectory(dir); err != nil { + return nil, err + } + requestPath := filepath.Join(dir, "request-"+nonce+".json") + ackPath := filepath.Join(dir, "ack-"+nonce+".json") + defer os.Remove(requestPath) + defer os.Remove(ackPath) + if err = runtimefile.WriteAtomic(requestPath, data, 0o600); err != nil { + return nil, err + } + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + ackData, readErr := runtimefile.Read(ackPath) + if readErr != nil { + continue + } + var ack flushAck + if json.Unmarshal(ackData, &ack) != nil { + continue + } + if err = validateFlushAck(req, ack); err != nil { + return nil, err + } + current, readErr := readStateFromActive(active) + if readErr != nil { + return nil, readErr + } + if current.CanonicalRoot != req.CanonicalRoot || current.DaemonInstance != req.DaemonInstance || current.Generation < ack.PublishedGeneration { + return nil, errors.New("acknowledged state identity changed") + } + return current, nil + } + } +} diff --git a/watch/publication.go b/watch/publication.go new file mode 100644 index 0000000..925444d --- /dev/null +++ b/watch/publication.go @@ -0,0 +1,214 @@ +package watch + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "codemap/internal/runtimefile" + "codemap/limits" +) + +const ( + publicationQuietWindow = 100 * time.Millisecond + publicationRetryDelay = 250 * time.Millisecond + flushArtifactTTL = 5 * time.Minute + maxFlushArtifacts = 256 +) + +type statePublisher struct { + daemon *Daemon + path string + flushDir string + instance string + generation uint64 + dirty bool + deadline time.Time + pending map[string]flushRequest + seen map[string]time.Time +} + +func newDaemonInstance() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func newStatePublisher(d *Daemon, path, instance string) *statePublisher { + return &statePublisher{daemon: d, path: path, flushDir: filepath.Join(filepath.Dir(path), "flush"), instance: instance, pending: map[string]flushRequest{}, seen: map[string]time.Time{}} +} + +func (p *statePublisher) markDirty(now time.Time) { + p.dirty = true + p.deadline = now.Add(publicationQuietWindow) +} +func (p *statePublisher) nextDelay(now time.Time) (time.Duration, bool) { + if !p.dirty && len(p.pending) == 0 { + return 0, false + } + d := p.deadline.Sub(now) + if d < 0 { + d = 0 + } + return d, true +} +func (p *statePublisher) due(now time.Time) bool { + return (p.dirty || len(p.pending) > 0) && !p.deadline.After(now) +} + +func (p *statePublisher) snapshot(generation uint64) State { + d := p.daemon + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + events := d.graph.Events + if len(events) > limits.MaxStateRecentEvents { + events = events[len(events)-limits.MaxStateRecentEvents:] + } + configuredFileCount := len(d.graph.ConfiguredFiles) + if d.graph.ConfiguredFiles == nil { + configuredFileCount = len(d.graph.Files) + } + root := canonicalRoot(d.root) + s := State{Root: root, CanonicalRoot: root, DaemonInstance: p.instance, Generation: generation, UpdatedAt: time.Now(), FileCount: len(d.graph.Files), ConfiguredFileCount: &configuredFileCount, Hubs: []string{}, Importers: map[string][]string{}, Imports: map[string][]string{}, RecentEvents: append([]Event(nil), events...), WorkingSet: d.graph.WorkingSet.Snapshot(50)} + if d.graph.FileGraph != nil { + s.Hubs = d.graph.FileGraph.HubFiles() + s.Importers = d.graph.FileGraph.Importers + s.Imports = d.graph.FileGraph.Imports + s.Coverage = d.graph.FileGraph.Coverage + } + return s +} + +func (p *statePublisher) publish() error { + next := p.generation + 1 + data, err := json.MarshalIndent(p.snapshot(next), "", " ") + if err != nil { + return err + } + if err = runtimefile.WriteAtomic(p.path, data, 0o644); err != nil { + p.failPending("publication_failed") + p.deadline = time.Now().Add(publicationRetryDelay) + return err + } + p.generation = next + p.dirty = false + p.deadline = time.Time{} + for nonce, req := range p.pending { + p.writeAck(req, flushAck{Version: flushProtocolVersion, CanonicalRoot: req.CanonicalRoot, DaemonInstance: req.DaemonInstance, Nonce: req.Nonce, ObservedGeneration: req.ObservedGeneration, PublishedGeneration: next, Timestamp: time.Now().UTC(), Success: true}) + delete(p.pending, nonce) + } + return nil +} + +func (p *statePublisher) failPending(code string) { + for nonce, req := range p.pending { + p.writeAck(req, flushAck{Version: flushProtocolVersion, CanonicalRoot: req.CanonicalRoot, DaemonInstance: req.DaemonInstance, Nonce: req.Nonce, ObservedGeneration: req.ObservedGeneration, Timestamp: time.Now().UTC(), ErrorCode: code}) + delete(p.pending, nonce) + } +} +func (p *statePublisher) writeAck(req flushRequest, ack flushAck) { + data, err := json.Marshal(ack) + if err == nil { + _ = runtimefile.WriteAtomic(filepath.Join(p.flushDir, "ack-"+req.Nonce+".json"), data, 0o600) + } +} + +func (p *statePublisher) scanRequests(now time.Time) { + if err := ensureControlDirectory(p.flushDir); err != nil { + p.failPending("control_unavailable") + return + } + entries, err := os.ReadDir(p.flushDir) + if err != nil { + p.failPending("control_unavailable") + return + } + p.pruneControlArtifacts(entries, now) + entries, err = os.ReadDir(p.flushDir) + if err != nil { + p.failPending("control_unavailable") + return + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || len(name) < 14 || name[:8] != "request-" || filepath.Ext(name) != ".json" { + continue + } + data, err := runtimefile.Read(filepath.Join(p.flushDir, name)) + if err != nil { + continue + } + var req flushRequest + if json.Unmarshal(data, &req) != nil { + continue + } + key := req.identity() + if _, ok := p.seen[key]; ok { + continue + } + if req.Version != flushProtocolVersion || req.CanonicalRoot != p.daemon.root || req.DaemonInstance != p.instance || "request-"+req.Nonce+".json" != name { + continue + } + p.seen[key] = now + p.pending[req.Nonce] = req + p.markDirty(now) + } +} + +func (p *statePublisher) pruneControlArtifacts(entries []os.DirEntry, now time.Time) { + cutoff := now.Add(-flushArtifactTTL) + type artifact struct { + path string + mod time.Time + } + artifacts := make([]artifact, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !(strings.HasPrefix(name, "request-") || strings.HasPrefix(name, "ack-")) || filepath.Ext(name) != ".json" { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + path := filepath.Join(p.flushDir, name) + if info.ModTime().Before(cutoff) { + _ = os.Remove(path) + continue + } + artifacts = append(artifacts, artifact{path: path, mod: info.ModTime()}) + } + for key, seenAt := range p.seen { + if seenAt.Before(cutoff) { + delete(p.seen, key) + } + } + if len(p.seen) > maxFlushArtifacts { + type identity struct { + key string + at time.Time + } + identities := make([]identity, 0, len(p.seen)) + for key, at := range p.seen { + identities = append(identities, identity{key: key, at: at}) + } + slices.SortFunc(identities, func(a, b identity) int { return a.at.Compare(b.at) }) + for _, item := range identities[:len(identities)-maxFlushArtifacts] { + delete(p.seen, item.key) + } + } + if len(artifacts) <= maxFlushArtifacts { + return + } + slices.SortFunc(artifacts, func(a, b artifact) int { return a.mod.Compare(b.mod) }) + for _, item := range artifacts[:len(artifacts)-maxFlushArtifacts] { + _ = os.Remove(item.path) + } +} diff --git a/watch/publication_test.go b/watch/publication_test.go new file mode 100644 index 0000000..92dacd6 --- /dev/null +++ b/watch/publication_test.go @@ -0,0 +1,333 @@ +package watch + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "codemap/internal/projectpath" +) + +func TestStatePublisherCoalescesBurstIntoOneGeneration(t *testing.T) { + root := canonicalDaemonRoot(t) + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + + p := newStatePublisher(d, filepath.Join(root, "state.json"), "instance-a") + now := time.Now() + for range 20 { + p.markDirty(now) + } + if p.generation != 0 { + t.Fatalf("generation advanced before publication: %d", p.generation) + } + if err := p.publish(); err != nil { + t.Fatal(err) + } + if p.generation != 1 { + t.Fatalf("generation = %d, want 1", p.generation) + } + state := readStateAt(t, filepath.Join(root, "state.json")) + if state.DaemonInstance != "instance-a" || state.Generation != 1 || state.CanonicalRoot != d.root { + t.Fatalf("published identity = %#v", state) + } +} + +func TestStatePublisherFailureBacksOffWithoutLosingDirtyState(t *testing.T) { + root := t.TempDir() + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + path := filepath.Join(root, "state.json") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + p := newStatePublisher(d, path, "instance-a") + now := time.Now() + p.markDirty(now.Add(-publicationQuietWindow)) + if err := p.publish(); err == nil { + t.Fatal("publication unexpectedly succeeded") + } + if delay, ok := p.nextDelay(now); !ok || delay <= 0 { + t.Fatalf("failed publication retry delay = %v, %v; want positive", delay, ok) + } + if !p.dirty { + t.Fatal("failed publication discarded dirty state") + } +} + +func TestDaemonStartFailsWhenInitialStateCannotPublish(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), 0o755); err != nil { + t.Fatal(err) + } + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + if err := d.Start(); err == nil { + t.Fatal("Start succeeded without initial state") + } +} + +func TestStatePublisherPrunesExpiredControlArtifacts(t *testing.T) { + root := t.TempDir() + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + p := newStatePublisher(d, filepath.Join(root, "state.json"), "instance-a") + p.flushDir = filepath.Join(root, "flush") + if err := os.Mkdir(p.flushDir, 0o755); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Hour) + req := flushRequest{Version: flushProtocolVersion, CanonicalRoot: d.root, DaemonInstance: "instance-a", Nonce: "old", Timestamp: old} + data, _ := json.Marshal(req) + for _, name := range []string{"request-old.json", "ack-old.json"} { + path := filepath.Join(p.flushDir, name) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + } + p.scanRequests(time.Now()) + for _, name := range []string{"request-old.json", "ack-old.json"} { + if _, err := os.Stat(filepath.Join(p.flushDir, name)); !os.IsNotExist(err) { + t.Fatalf("expired %s retained: %v", name, err) + } + } + if len(p.seen) != 0 { + t.Fatalf("expired identity retained: %d", len(p.seen)) + } +} + +func TestStatePublisherBoundsRecentRequestIdentities(t *testing.T) { + root := t.TempDir() + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + p := newStatePublisher(d, filepath.Join(root, "state.json"), "instance-a") + p.flushDir = filepath.Join(root, "flush") + if err := os.Mkdir(p.flushDir, 0o755); err != nil { + t.Fatal(err) + } + now := time.Now() + for i := 0; i <= maxFlushArtifacts; i++ { + p.seen[fmt.Sprintf("request-%03d", i)] = now.Add(time.Duration(i) * time.Nanosecond) + } + p.scanRequests(now.Add(time.Second)) + if len(p.seen) > maxFlushArtifacts { + t.Fatalf("seen identities = %d, want <= %d", len(p.seen), maxFlushArtifacts) + } +} + +func TestStatePublisherReplacementAlwaysDecodes(t *testing.T) { + root := t.TempDir() + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + path := filepath.Join(root, "state.json") + p := newStatePublisher(d, path, "instance-a") + if err := p.publish(); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + errCh := make(chan error, 1) + wg.Add(1) + go func() { + defer wg.Done() + for range 500 { + data, err := os.ReadFile(path) + if err != nil { + errCh <- err + return + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + errCh <- err + return + } + } + }() + for range 50 { + p.markDirty(time.Now()) + if err := p.publish(); err != nil { + t.Fatal(err) + } + } + wg.Wait() + select { + case err := <-errCh: + t.Fatal(err) + default: + } +} + +func TestFlushRequestValidationRequiresExactGenerationIdentity(t *testing.T) { + req := flushRequest{Version: flushProtocolVersion, CanonicalRoot: "/repo", DaemonInstance: "daemon-a", Nonce: "nonce-a", ObservedGeneration: 4} + ack := flushAck{Version: flushProtocolVersion, CanonicalRoot: "/repo", DaemonInstance: "daemon-a", Nonce: "nonce-a", ObservedGeneration: 4, PublishedGeneration: 5, Success: true} + if err := validateFlushAck(req, ack); err != nil { + t.Fatal(err) + } + ack.PublishedGeneration = 4 + if err := validateFlushAck(req, ack); err == nil { + t.Fatal("accepted same-generation acknowledgement") + } + ack.PublishedGeneration = 5 + ack.DaemonInstance = "daemon-b" + if err := validateFlushAck(req, ack); err == nil { + t.Fatal("accepted acknowledgement from replacement daemon") + } +} + +func TestFlushRequestWaitsForQuietWindowAndAcknowledgesNextGeneration(t *testing.T) { + root := canonicalDaemonRoot(t) + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.watcher.Close() + p := newStatePublisher(d, filepath.Join(root, "state.json"), "instance-a") + p.flushDir = filepath.Join(root, "flush") + if err := os.MkdirAll(p.flushDir, 0o755); err != nil { + t.Fatal(err) + } + if err := p.publish(); err != nil { + t.Fatal(err) + } + now := time.Now() + req := flushRequest{Version: flushProtocolVersion, CanonicalRoot: d.root, DaemonInstance: "instance-a", Nonce: "nonce-a", ObservedGeneration: 1, Timestamp: now} + data, _ := json.Marshal(req) + if err := os.WriteFile(filepath.Join(p.flushDir, "request-nonce-a.json"), data, 0o600); err != nil { + t.Fatal(err) + } + p.scanRequests(now) + if p.due(now.Add(publicationQuietWindow - time.Millisecond)) { + t.Fatal("request acknowledged before quiet window") + } + p.markDirty(now.Add(50 * time.Millisecond)) + if p.due(now.Add(publicationQuietWindow)) { + t.Fatal("later event did not reset quiet window") + } + if !p.due(now.Add(151 * time.Millisecond)) { + t.Fatal("request never became due") + } + if err := p.publish(); err != nil { + t.Fatal(err) + } + ackData, err := os.ReadFile(filepath.Join(p.flushDir, "ack-nonce-a.json")) + if err != nil { + t.Fatal(err) + } + var ack flushAck + if err := json.Unmarshal(ackData, &ack); err != nil { + t.Fatal(err) + } + if err := validateFlushAck(req, ack); err != nil { + t.Fatal(err) + } + if ack.PublishedGeneration != 2 { + t.Fatalf("published generation = %d, want 2", ack.PublishedGeneration) + } +} + +func TestFlushStateHonorsExpiredContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := FlushState(ctx, t.TempDir()) + if err == nil { + t.Fatal("FlushState succeeded with expired context") + } +} + +func TestControlDirectoryRejectsSymlink(t *testing.T) { + root := t.TempDir() + target := t.TempDir() + path := filepath.Join(root, "flush") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if err := ensureControlDirectory(path); err == nil { + t.Fatal("accepted symlink control directory") + } +} + +func TestDaemonProcessesFlushRequestThroughWatchedDirectory(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + if err := d.Start(); err != nil { + t.Fatal(err) + } + defer d.Stop() + req := flushRequest{Version: flushProtocolVersion, CanonicalRoot: d.root, DaemonInstance: d.publisher.instance, Nonce: "watched", ObservedGeneration: d.publisher.generation, Timestamp: time.Now()} + data, _ := json.Marshal(req) + if err := os.WriteFile(filepath.Join(d.publisher.flushDir, "request-watched.json"), data, 0o600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for { + ackData, readErr := os.ReadFile(filepath.Join(d.publisher.flushDir, "ack-watched.json")) + if readErr == nil { + var ack flushAck + if err := json.Unmarshal(ackData, &ack); err != nil { + t.Fatal(err) + } + if err := validateFlushAck(req, ack); err != nil { + t.Fatal(err) + } + break + } + if time.Now().After(deadline) { + t.Fatal("daemon did not acknowledge flush request") + } + time.Sleep(10 * time.Millisecond) + } +} + +func readStateAt(t *testing.T, path string) State { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + t.Fatal(err) + } + return state +} + +func canonicalDaemonRoot(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return root +} diff --git a/watch/topology_test.go b/watch/topology_test.go index 1b189d9..c1684f4 100644 --- a/watch/topology_test.go +++ b/watch/topology_test.go @@ -195,7 +195,7 @@ func TestTopologyCacheNeverChangesLegacyStateShape(t *testing.T) { } daemon.writeState() - data, err := os.ReadFile(filepath.Join(daemon.root, ".codemap", "state.json")) + data, err := os.ReadFile(daemon.publisher.path) if err != nil { t.Fatal(err) } diff --git a/watch/types.go b/watch/types.go index 7714ba5..870da8a 100644 --- a/watch/types.go +++ b/watch/types.go @@ -61,6 +61,9 @@ type Graph struct { // State represents the daemon state that hooks can read type State struct { Root string `json:"root,omitempty"` + CanonicalRoot string `json:"canonical_root,omitempty"` + DaemonInstance string `json:"daemon_instance,omitempty"` + Generation uint64 `json:"generation,omitempty"` UpdatedAt time.Time `json:"updated_at"` FileCount int `json:"file_count"` ConfiguredFileCount *int `json:"configured_file_count,omitempty"`