diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9920aa..5664d1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,33 @@ descriptor. A file replaced during the read keeps the version read. One rewritten in place is read again, and one that never settles records no hash. Applies to `read_file` (windowed and pattern search) and `read_symbol`. +- **A write records the version it wrote.** After a successful write, plumb + refreshed the session's read record by stat'ing and hashing the path again. + The per-path lock excludes only plumb's own writers, so a process outside + plumb that wrote the file in that gap had its content recorded as this + session's version, and the session's next write passed "changed since you + read it" and the same-mtime `expected_mtime` check over a change it never + saw. Writes now record the hash of the bytes plumb wrote and the mtime of the + file it wrote them to, taken from the closed staged file just before the + rename publishes it. `rename_file`, which writes no bytes, records the version + it moved, read from the source before the move. Applies to every write tool, + `undo_edit`, and the `fail_on_new_errors` rollbacks. `edit_file`'s reply had + the same gap: its `mtime:` line re-read the path after the post-write + diagnostics wait, so an outside write in that wait handed the caller an + `expected_mtime` that let its next write through. The reply now prints the + version plumb wrote, with or without `apply_partial` (issue #528). +- **A file read through one spelling and written through another keeps its read + record.** Read tracking keyed a read on the path as spelled, while the write + lock, write tracking and undo resolve symlinks and fold case where the volume + does. A file read through a symlinked parent, macOS `/tmp` versus + `/private/tmp`, or a case variant, and then written through another spelling, + had no read record at the write: the "changed since you read it" guard let the + write overwrite a peer's change, and strict mode refused the edit as unread. + Reads are now keyed the way writes are, in memory and in the persisted + session state. Rows saved by an older daemon are re-keyed when they are + restored after a restart; where one collides with a row this version saved, + the newer row wins even if a tool such as `cp -p` moved the file's mtime + backwards (issue #524). - **Contested-pin messages no longer recommend `session_id` as the fix.** The contested-pin note in `session_start`, the boundary and re-pin refusals, and the `git`, `run_task` and `undo_edit` refusals told agents sharing a diff --git a/internal/cli/conn_persist_alias_test.go b/internal/cli/conn_persist_alias_test.go new file mode 100644 index 00000000..d814cef9 --- /dev/null +++ b/internal/cli/conn_persist_alias_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/plumbkit/plumb/internal/config" + "github.com/plumbkit/plumb/internal/sessionstate" +) + +// TestPersist_SpelledReadRowRehydratesUnderEverySpelling: a daemon predating +// issue #524 persisted a read under the path as the agent spelled it. After a +// restart that row must answer for the file under any spelling, or strict mode +// refuses an edit through the canonical path ("has not been read") and the +// default staleness guard fails open over a peer's change. +func TestPersist_SpelledReadRowRehydratesUnderEverySpelling(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + store := config.NewStore(config.Defaults()) + ss, err := sessionstate.Open() + if err != nil { + t.Fatalf("sessionstate.Open: %v", err) + } + defer ss.Close() + + root := freshTempDir(t) + mustGitDir(t, root) + if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + realPath := filepath.Join(root, "real", "a.go") + if err := os.WriteFile(realPath, []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + mtime := time.Unix(1_700_000_000, 444) + + // An older daemon's row: keyed by the alias spelling, written straight to + // the store as that daemon's persist sink would have. + before := newPersistSession(t, store, ss, "proxyX") + before.attachWorkspace(context.Background(), "file://"+root) + ws := before.view().acquiredRoot + if err := ss.UpsertRead("proxyX", ws, filepath.Join(root, "alias", "a.go"), mtime, "sha-a"); err != nil { + t.Fatal(err) + } + before.close() + + after := newPersistSession(t, store, ss, "proxyX") + after.attachWorkspace(context.Background(), "file://"+root) + for _, p := range []string{realPath, filepath.Join(root, "alias", "a.go")} { + if got := after.readTracker.Mtime(p); !got.Equal(mtime) { + t.Fatalf("rehydrated Mtime(%s) = %v, want %v — a spelled row must answer for every spelling", p, got, mtime) + } + } +} diff --git a/internal/tools/copy_file.go b/internal/tools/copy_file.go index 6f32a1c3..4f5c20bc 100644 --- a/internal/tools/copy_file.go +++ b/internal/tools/copy_file.go @@ -111,10 +111,11 @@ func (t *CopyFile) Execute(ctx context.Context, raw json.RawMessage) (string, er if err != nil { return "", err } - if _, err := safeWrite(to, data, perm); err != nil { + res, err := safeWrite(to, data, perm) + if err != nil { return "", fmt.Errorf("copy_file: writing destination: %w", err) } - t.copyFilePostWrite(ctx, to) + t.copyFilePostWrite(ctx, to, res.written) return fmt.Sprintf("copied %s → %s (%d bytes)", from, to, len(data)), nil } @@ -162,11 +163,11 @@ func copyFilePreconditions(ctx context.Context, deps WriteDeps, from, to string, return data, info.Mode().Perm(), nil } -func (t *CopyFile) copyFilePostWrite(ctx context.Context, to string) { +func (t *CopyFile) copyFilePostWrite(ctx context.Context, to string, written fileSnapshot) { if err := notifyLSP(ctx, t.deps.Client, to, protocol.FileCreated); err != nil { slog.Warn("copy_file: LSP create-notify failed", "path", to, "err", err) } invalidateCache(t.deps.Cache, "file://"+to) t.deps.notifyTopology(to) - t.deps.recordWritten(ctx, to) + t.deps.recordWritten(ctx, to, written) } diff --git a/internal/tools/edit_apply.go b/internal/tools/edit_apply.go index af88912b..a85032f2 100644 --- a/internal/tools/edit_apply.go +++ b/internal/tools/edit_apply.go @@ -80,13 +80,15 @@ func applyWorkspaceEditDetailed(we *protocol.WorkspaceEdit, onApplied func([]wor } var modified []string - for _, p := range plans { - if _, err := safeWrite(p.path, p.after, p.mode); err != nil { + for i, p := range plans { + res, err := safeWrite(p.path, p.after, p.mode) + if err != nil { if rbErr := rollbackWorkspaceEdit(plans, modified); rbErr != nil { return modified, plans, fmt.Errorf("writing %s: %w; rollback failed: %w", p.path, err, rbErr) } return modified, plans, fmt.Errorf("writing %s: %w", p.path, err) } + plans[i].written = res.written modified = append(modified, p.path) } if onApplied != nil { @@ -100,6 +102,8 @@ type workspaceEditPlan struct { before []byte after []byte mode os.FileMode + // written is the version the apply published, set once the write lands. + written fileSnapshot } // workspaceEditTarget is one file's share of a WorkspaceEdit, resolved to a diff --git a/internal/tools/edit_file_apply.go b/internal/tools/edit_file_apply.go index 4d1b19f9..441a9ff2 100644 --- a/internal/tools/edit_file_apply.go +++ b/internal/tools/edit_file_apply.go @@ -54,7 +54,7 @@ func (t *EditFile) editFileApply(ctx context.Context, path string, a editFileArg } } invalidateCache(t.deps.Cache, uri) - t.deps.recordWritten(ctx, path) + t.deps.recordWritten(ctx, path, result.written) t.deps.recordUndo(ctx, path, before, content, true, "edit_file") // Still inside the per-path lock taken in Execute: the write, the @@ -67,12 +67,19 @@ func (t *EditFile) editFileApply(ctx context.Context, path string, a editFileArg before: before, existedBefore: true, wrote: content, diag: diag, }) } - return t.formatEditFileSuccess(path, attempt, a.Edits, before, content, notes, diag), nil + return t.formatEditFileSuccess(path, attempt, a.Edits, before, content, notes, diag, result.written), nil } return "", fmt.Errorf("edit_file: failed after %d attempts: %w", maxEditRetries, lastErr) } -func (t *EditFile) formatEditFileSuccess(path string, attempt int, edits []strEdit, before, content string, notes []string, diag postWriteDiagResult) string { +// formatEditFileSuccess renders the reply. Its mtime line is written — the +// version this edit published and recordWritten recorded — never a re-stat of +// the path: this runs after the post-write diagnostics wait (seconds, with +// await_diagnostics), and an outside writer landing in it would otherwise hand +// the caller ITS mtime. Passed back as expected_mtime, that matched the file, +// and changedAtSameMtime could not second-guess it (the recorded read is at +// plumb's mtime), so the next write went over a change it never saw (#528). +func (t *EditFile) formatEditFileSuccess(path string, attempt int, edits []strEdit, before, content string, notes []string, diag postWriteDiagResult, written fileSnapshot) string { noun := "edit" if len(edits) > 1 { noun = "edits" @@ -86,8 +93,8 @@ func (t *EditFile) formatEditFileSuccess(path string, attempt int, edits []strEd if attempt > 1 { fmt.Fprintf(&sb, " (succeeded on attempt %d)", attempt) } - if info, err := os.Stat(path); err == nil { - fmt.Fprintf(&sb, "\nmtime: %s", info.ModTime().Format(time.RFC3339Nano)) + if !written.mtime.IsZero() { + fmt.Fprintf(&sb, "\nmtime: %s", written.mtime.Format(time.RFC3339Nano)) } for _, n := range notes { fmt.Fprintf(&sb, "\n%s", n) diff --git a/internal/tools/edit_file_partial.go b/internal/tools/edit_file_partial.go index 071f9d0d..08b18f3e 100644 --- a/internal/tools/edit_file_partial.go +++ b/internal/tools/edit_file_partial.go @@ -34,14 +34,19 @@ func (t *EditFile) executePartial( baseline := t.deps.capturePreWriteBaseline(ctx, uri) results, res, original, content, writeErr := t.tryEditPartial(ctx, path, edits) applied := countApplied(results) - var sb strings.Builder - sb.WriteString(t.formatPartialHeader(path, original, content, applied, len(edits), writeErr)) - sb.WriteString(formatPartialEditsResults(results)) + var post strings.Builder if writeErr == nil && applied > 0 { - _ = res - t.executePartialPostWrite(ctx, path, uri, original, content, awaitFresh, &sb, baseline) + t.executePartialPostWrite(ctx, path, uri, original, content, res.written, awaitFresh, &post, baseline) t.deps.recordUndo(ctx, path, original, content, true, "edit_file") } + // The header is rendered after the post-write pipeline, as edit_file's + // ordinary reply is: that pipeline is where an outside write can land, so + // both replies face the same window and one regression test pins both to + // the written version (#528). The output order is unchanged. + var sb strings.Builder + sb.WriteString(t.formatPartialHeader(path, original, content, applied, len(edits), writeErr, res.written)) + sb.WriteString(formatPartialEditsResults(results)) + sb.WriteString(post.String()) return sb.String() } @@ -55,26 +60,28 @@ func countApplied(results []partialEditResult) int { return n } -func (t *EditFile) formatPartialHeader(path, original, content string, applied, total int, writeErr error) string { +func (t *EditFile) formatPartialHeader(path, original, content string, applied, total int, writeErr error, written fileSnapshot) string { switch { case writeErr != nil: return fmt.Sprintf("partial apply: write failed after %d successful edit(s): %v\n\n", applied, writeErr) case applied == 0: return "partial apply: all edits failed — file not modified\n\n" default: - return t.formatPartialAppliedHeader(path, original, content, applied, total) + return t.formatPartialAppliedHeader(path, original, content, applied, total, written) } } // formatPartialAppliedHeader renders the header for the case where at least one -// edit landed and the write succeeded: the count, the fresh mtime, a line-change -// summary, and (when enabled) the diff. -func (t *EditFile) formatPartialAppliedHeader(path, original, content string, applied, total int) string { +// edit landed and the write succeeded: the count, the written version's mtime, a +// line-change summary, and (when enabled) the diff. The mtime is the version the +// write published, not a re-stat of the path, for the reason formatEditFileSuccess +// gives (#528). +func (t *EditFile) formatPartialAppliedHeader(path, original, content string, applied, total int, written fileSnapshot) string { var sb strings.Builder fmt.Fprintf(&sb, "partial apply: applied %d of %d edit(s) to %s (%d bytes)\n", applied, total, path, len(content)) - if info, err := os.Stat(path); err == nil { - fmt.Fprintf(&sb, "mtime: %s\n", info.ModTime().Format(time.RFC3339Nano)) + if !written.mtime.IsZero() { + fmt.Fprintf(&sb, "mtime: %s\n", written.mtime.Format(time.RFC3339Nano)) } if s := summariseLineChanges(original, content); s != "" { fmt.Fprintf(&sb, "%s\n", s) @@ -108,7 +115,7 @@ func formatPartialEditsResults(results []partialEditResult) string { return sb.String() } -func (t *EditFile) executePartialPostWrite(ctx context.Context, path, uri, before, content string, awaitFresh bool, sb *strings.Builder, baseline *diagBaseline) { +func (t *EditFile) executePartialPostWrite(ctx context.Context, path, uri, before, content string, written fileSnapshot, awaitFresh bool, sb *strings.Builder, baseline *diagBaseline) { notifyFailed := false if err := notifyLSP(ctx, t.deps.Client, path, protocol.FileChanged); err != nil { notifyFailed = true @@ -121,7 +128,7 @@ func (t *EditFile) executePartialPostWrite(ctx context.Context, path, uri, befor } } invalidateCache(t.deps.Cache, uri) - t.deps.recordWritten(ctx, path) + t.deps.recordWritten(ctx, path, written) // apply_partial cannot request fail_on_new_errors (the preconditions refuse // the combination), so this path only ever reports. opt := postWriteDiagOpts{awaitFresh: awaitFresh, structured: awaitFresh, lspNotifyFailed: notifyFailed} diff --git a/internal/tools/fail_on_new_errors.go b/internal/tools/fail_on_new_errors.go index 76f68ea4..2fa53593 100644 --- a/internal/tools/fail_on_new_errors.go +++ b/internal/tools/fail_on_new_errors.go @@ -141,24 +141,26 @@ func (d WriteDeps) revertWrite(ctx context.Context, req rollbackRequest) (holds if err := os.Remove(req.path); err != nil && !os.IsNotExist(err) { return "the content this call wrote (the file plumb created is still there)", fmt.Errorf("removing %q: %w", req.path, err) } - d.notifyReverted(ctx, req.path, req.uri, protocol.FileDeleted) + d.notifyReverted(ctx, req.path, req.uri, protocol.FileDeleted, fileSnapshot{}) return "", nil } perm := os.FileMode(0o644) if info, statErr := os.Stat(req.path); statErr == nil && info.Mode().Perm() != 0 { perm = info.Mode().Perm() } - if _, err := safeWrite(req.path, []byte(req.before), perm); err != nil { + res, err := safeWrite(req.path, []byte(req.before), perm) + if err != nil { return "the content this call wrote (the restore itself failed)", fmt.Errorf("restoring %q: %w", req.path, err) } - d.notifyReverted(ctx, req.path, req.uri, protocol.FileChanged) + d.notifyReverted(ctx, req.path, req.uri, protocol.FileChanged, res.written) return "", nil } // notifyReverted mirrors the post-write notification, so the language server, // the symbol cache, the topology index and this session's own read/write state -// all see the restored content rather than the reverted one. -func (d WriteDeps) notifyReverted(ctx context.Context, path, uri string, ct protocol.FileChangeType) { +// all see the restored content rather than the reverted one. restored is the +// version the restoring write published (unused for a deletion). +func (d WriteDeps) notifyReverted(ctx context.Context, path, uri string, ct protocol.FileChangeType, restored fileSnapshot) { if err := notifyLSP(ctx, d.Client, path, ct); err != nil { slog.Warn("fail_on_new_errors: LSP notification after rollback failed", "path", path, "err", err) } @@ -169,7 +171,7 @@ func (d WriteDeps) notifyReverted(ctx context.Context, path, uri string, ct prot } invalidateCache(d.Cache, uri) if ct != protocol.FileDeleted { - d.recordWritten(ctx, path) + d.recordWritten(ctx, path, restored) } d.notifyTopology(path) } diff --git a/internal/tools/file_write_helpers.go b/internal/tools/file_write_helpers.go index 60a7bbfa..8f6b472c 100644 --- a/internal/tools/file_write_helpers.go +++ b/internal/tools/file_write_helpers.go @@ -231,6 +231,10 @@ type writeResult struct { // the temp file. Used as a reference to detect whether the target was // modified by a third party after we started but before our rename landed. tempWrittenAt time.Time + // written is the version this write published (stagedSnapshot): the hash of + // the bytes written and the closed staged file's mtime, which the rename + // carries to the target. recordWritten records it rather than re-reading the path. + written fileSnapshot } // safeWrite writes data to path using temp-file-then-atomic-rename. @@ -308,6 +312,10 @@ func safeWrite(path string, data []byte, perm os.FileMode) (writeResult, error) _ = os.Remove(tmpPath) return res, fmt.Errorf("closing temp file: %w", err) } + if res.written, err = stagedSnapshot(tmpPath, data); err != nil { + _ = os.Remove(tmpPath) + return res, fmt.Errorf("stat temp file: %w", err) + } res.tempWrittenAt = time.Now() @@ -359,6 +367,10 @@ func safeWriteSibling(path string, data []byte, perm os.FileMode, modTimeBefore _ = os.Remove(sibling) return res, fmt.Errorf("closing sibling temp file: %w", err) } + if res.written, err = stagedSnapshot(sibling, data); err != nil { + _ = os.Remove(sibling) + return res, fmt.Errorf("stat sibling temp file: %w", err) + } res.tempWrittenAt = time.Now() if err := os.Rename(sibling, path); err != nil { diff --git a/internal/tools/find_replace.go b/internal/tools/find_replace.go index 3a5e3fff..f87b1237 100644 --- a/internal/tools/find_replace.go +++ b/internal/tools/find_replace.go @@ -317,7 +317,7 @@ func (t *findReplaceTool) findReplaceProcessFile(ctx context.Context, path strin unlock() return fmt.Errorf("find_replace: %q has uncommitted changes; review and commit first, or pass dirty_ok: true to proceed", path) } - _, writeErr := safeWrite(path, newData, 0o644) + res, writeErr := safeWrite(path, newData, 0o644) unlock() if writeErr != nil { return fmt.Errorf("find_replace: writing %s: %w", path, writeErr) @@ -326,7 +326,7 @@ func (t *findReplaceTool) findReplaceProcessFile(ctx context.Context, path strin slog.Warn("find_replace: LSP notification failed", "path", path, "err", err) } invalidateCache(t.deps.Cache, "file://"+path) - t.deps.recordWritten(ctx, path) + t.deps.recordWritten(ctx, path, res.written) return nil } diff --git a/internal/tools/move_symbol.go b/internal/tools/move_symbol.go index 0f1f3ac4..85b8ae85 100644 --- a/internal/tools/move_symbol.go +++ b/internal/tools/move_symbol.go @@ -269,7 +269,7 @@ func (t *MoveSymbol) applyMove(ctx, lspCtx context.Context, waited time.Duration return } for _, p := range plans { - deps.recordWritten(ctx, p.path) + deps.recordWritten(ctx, p.path, p.written) deps.recordUndo(ctx, p.path, string(p.before), string(p.after), p.existedBefore, "move_symbol") } } @@ -540,6 +540,8 @@ type movePlan struct { after []byte mode os.FileMode existedBefore bool + // written is the version the move published, set once the write lands. + written fileSnapshot } // applyMovePlans writes each plan in order and rolls every prior write back on a @@ -550,13 +552,15 @@ type movePlan struct { // also directly unit-testable. func applyMovePlans(plans []movePlan, onApplied func()) ([]string, error) { var written []movePlan - for _, p := range plans { - if _, err := safeWrite(p.path, p.after, p.mode); err != nil { + for i, p := range plans { + res, err := safeWrite(p.path, p.after, p.mode) + if err != nil { if rbErr := rollbackMove(written); rbErr != nil { return nil, fmt.Errorf("writing %s: %w; rollback failed: %w", p.path, err, rbErr) } return nil, fmt.Errorf("writing %s: %w", p.path, err) } + plans[i].written = res.written written = append(written, p) } if onApplied != nil { diff --git a/internal/tools/read_snapshot.go b/internal/tools/read_snapshot.go index 6d4c90aa..cfca6f1e 100644 --- a/internal/tools/read_snapshot.go +++ b/internal/tools/read_snapshot.go @@ -78,6 +78,28 @@ func snapshotLines(path string) ([]string, fileSnapshot, error) { return lines, snap, err } +// stagedSnapshot is the version a write is about to publish: the SHA-256 of the +// bytes plumb wrote into its staged file, and that file's mtime. The rename that +// publishes the staged file moves its inode, mtime included, so this IS the +// target's version the moment the rename lands — known without re-reading a path +// an outside writer may already have replaced (issue #528, the write-side twin of +// the read race above). +// +// Call it after the staged file is CLOSED and before the rename: some +// filesystems (SMB, WSL's drvfs) stamp the mtime at close, so a stat of the +// still-open descriptor would trail the published mtime and the session's own +// next write would look stale. The staged name is plumb's own (a CreateTemp +// name, or the sibling under the path lock); Lstat describes exactly the entry +// the rename will move. +func stagedSnapshot(staged string, data []byte) (fileSnapshot, error) { + info, err := os.Lstat(staged) + if err != nil { + return fileSnapshot{}, err + } + sum := sha256.Sum256(data) + return fileSnapshot{mtime: info.ModTime(), size: int64(len(data)), sha: hex.EncodeToString(sum[:])}, nil +} + func readSnapshotOnce(path string, consume func(io.Reader) error) (fileSnapshot, bool, error) { f, err := os.Open(path) //nolint:gosec // G304: path was resolved and boundary-checked by the calling tool if err != nil { diff --git a/internal/tools/read_tracker.go b/internal/tools/read_tracker.go index 25323bf5..06d4b76e 100644 --- a/internal/tools/read_tracker.go +++ b/internal/tools/read_tracker.go @@ -1,7 +1,6 @@ package tools import ( - "path/filepath" "sync" "time" ) @@ -16,17 +15,26 @@ import ( // mtime unchanged (a same-tick write, or a tool that preserves mtime such as // `cp -p` or some formatters) — a change an mtime-only comparison misses. // +// Entries are keyed by lockPathKey, the key the write lock, the WriteTracker and +// the undo store agree on, so a file read through one spelling (a symlinked +// parent, macOS /tmp versus /private/tmp, a case variant on a volume that folds +// case) and written through another finds its read record. Keyed on the spelled +// path, the write guards saw "never read": the default staleness guard failed +// open over a peer's change and strict mode failed closed (issue #524). +// // Concurrency: all methods are safe for concurrent use. The optional persist // sink set by SetPersistSink is invoked outside the tracker lock, so it may do // blocking I/O without stalling concurrent reads. type ReadTracker struct { mu sync.RWMutex - entries map[string]readEntry // filepath.Clean(path) → last-read state + entries map[string]readEntry // lockPathKey(path) → last-read state persist func(path string, mtime time.Time, sha string) } // ReadRecord is one path's recorded read state, used to rehydrate a tracker -// from a persisted store (e.g. after a daemon restart). +// from a persisted store (e.g. after a daemon restart). Path is the identity key +// the tracker persists and Records returns, not necessarily a spelling to open: +// on a volume that folds case it is lowercased (see paths.CanonicalKey). type ReadRecord struct { Path string Mtime time.Time @@ -52,15 +60,16 @@ func (r *ReadTracker) Record(path string, mtime time.Time, sha string) { if r == nil { return } - clean := filepath.Clean(path) + // Resolve outside the lock: lockPathKey touches the filesystem. + key := lockPathKey(path) r.mu.Lock() - r.entries[clean] = readEntry{mtime: mtime, sha: sha} + r.entries[key] = readEntry{mtime: mtime, sha: sha} persist := r.persist r.mu.Unlock() // Persist outside the lock; last-writer-wins races on the store are benign // and converge with the in-memory map. if persist != nil { - persist(clean, mtime, sha) + persist(key, mtime, sha) } } @@ -79,17 +88,54 @@ func (r *ReadTracker) SetPersistSink(fn func(path string, mtime time.Time, sha s // Hydrate loads previously-recorded reads into the tracker without firing the // persist sink (the records came from the store; re-persisting them is wasted // work). Existing entries for the same paths are overwritten. nil-safe. +// +// Every record is re-keyed through lockPathKey, because a daemon that predates +// issue #524 persisted the path as the agent spelled it. Two spellings of one +// file then collapse onto one key, and one must win whatever order the store +// returned them in (see hydrateOutranks). func (r *ReadTracker) Hydrate(records []ReadRecord) { if r == nil || len(records) == 0 { return } - r.mu.Lock() + // Resolve outside the lock: lockPathKey touches the filesystem. + loaded := make(map[string]hydratedRead, len(records)) for _, rec := range records { - r.entries[filepath.Clean(rec.Path)] = readEntry{mtime: rec.Mtime, sha: rec.SHA} + key := lockPathKey(rec.Path) + c := hydratedRead{readEntry: readEntry{mtime: rec.Mtime, sha: rec.SHA}, canonical: rec.Path == key} + if prev, dup := loaded[key]; dup && !hydrateOutranks(c, prev) { + continue + } + loaded[key] = c + } + r.mu.Lock() + for key, e := range loaded { + r.entries[key] = e.readEntry } r.mu.Unlock() } +// hydratedRead is one row on its way into Hydrate, remembering whether it was +// already stored under its key. +type hydratedRead struct { + readEntry + canonical bool +} + +// hydrateOutranks reports whether c should replace prev when both name one file. +// A row already stored under its canonical key wins: a daemon with this keying +// writes nothing else, so a row spelled any other way was left by an older +// daemon and predates it. File mtime cannot decide that pair, because +// mtime-preserving tools (cp -p, rsync -t) move it backwards, and an older read +// winning would make the guards refuse a file the session did read, on every +// restart. Between two rows of the same kind, the later file mtime wins: the +// newest version seen. +func hydrateOutranks(c, prev hydratedRead) bool { + if c.canonical != prev.canonical { + return c.canonical + } + return c.mtime.After(prev.mtime) +} + // Reset forgets every recorded read. Called on a deliberate workspace re-pin so // strict-mode read tracking starts clean for the new project: a read of a file // in the old workspace must not satisfy the read-before-edit check for a @@ -110,9 +156,10 @@ func (r *ReadTracker) Mtime(path string) time.Time { if r == nil { return time.Time{} } + key := lockPathKey(path) r.mu.RLock() defer r.mu.RUnlock() - return r.entries[filepath.Clean(path)].mtime + return r.entries[key].mtime } // recorded returns the full last-read state for path and whether the session @@ -121,9 +168,10 @@ func (r *ReadTracker) recorded(path string) (readEntry, bool) { if r == nil { return readEntry{}, false } + key := lockPathKey(path) r.mu.RLock() defer r.mu.RUnlock() - e, ok := r.entries[filepath.Clean(path)] + e, ok := r.entries[key] return e, ok } diff --git a/internal/tools/read_tracker_alias_test.go b/internal/tools/read_tracker_alias_test.go new file mode 100644 index 00000000..0b96a91a --- /dev/null +++ b/internal/tools/read_tracker_alias_test.go @@ -0,0 +1,244 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Issue #524: the ReadTracker keyed a read on the path AS SPELLED while the +// write tracker, the undo store and the write lock key on lockPathKey. A file +// read through one spelling and written through another then had no read record +// at the write, and changedSinceSessionRead — which treats "never read" as "not +// stale" — let the write overwrite a peer's change. These tests read and write +// one file through two spellings (a symlinked parent, built by aliasedProject so +// the alias exists on Linux too) and assert that every consumer of the read +// record sees the same record whichever spelling it is handed. + +// aliasedFile creates name under both spellings of one project and returns the +// two spellings of the file. +func aliasedFile(t *testing.T, name, content string) (realPath, aliasPath string) { + t.Helper() + realRoot, aliasRoot := aliasedProject(t) + realPath = filepath.Join(realRoot, name) + if err := os.WriteFile(realPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return realPath, filepath.Join(aliasRoot, name) +} + +// peerRewrite replaces path's content the way an outside writer would, and moves +// its mtime clearly past anything recorded earlier. +func peerRewrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(path, future, future); err != nil { + t.Fatal(err) + } +} + +func readVia(t *testing.T, tracker *ReadTracker, path string) { + t.Helper() + if _, err := NewReadFile(tracker).Execute(context.Background(), mustJSON(map[string]any{"file_path": path})); err != nil { + t.Fatalf("read_file %s: %v", path, err) + } +} + +// TestWriteFile_StaleGuardHoldsAcrossSpellings is the issue's reproduction: read +// through one spelling, a peer changes the file, write through the other. The +// guard must refuse in both directions and leave the peer's content alone. +func TestWriteFile_StaleGuardHoldsAcrossSpellings(t *testing.T) { + for _, tc := range []struct { + name string + readAlias bool // read through the alias, write through the real path; else the reverse + }{ + {name: "read alias, write real", readAlias: true}, + {name: "read real, write alias", readAlias: false}, + } { + t.Run(tc.name, func(t *testing.T) { + realPath, aliasPath := aliasedFile(t, "f.txt", "v1\n") + readPath, writePath := realPath, aliasPath + if tc.readAlias { + readPath, writePath = aliasPath, realPath + } + tracker := NewReadTracker() + readVia(t, tracker, readPath) + peerRewrite(t, realPath, "peer change\n") + + _, err := NewWriteFile(WriteDeps{Reads: tracker, Writes: NewWriteTracker()}).Execute(context.Background(), + mustJSON(map[string]any{"file_path": writePath, "content": "my overwrite\n"})) + if err == nil || !strings.Contains(err.Error(), "changed on disk since you read it") { + t.Fatalf("a write through another spelling of a file changed since the read must be refused, got: %v", err) + } + if got, _ := os.ReadFile(realPath); string(got) != "peer change\n" { + t.Fatalf("the refused write clobbered the peer's change: %q", got) + } + }) + } +} + +// TestWriteFile_OwnWritesAcrossSpellingsNotFlagged is the positive control: with +// no peer, a session alternating spellings is writing over its own knowledge and +// must never be refused. Before the fix the write through the alias compared the +// file against the stale alias-keyed READ, ignoring the session's own write +// recorded under the real path, and refused. +func TestWriteFile_OwnWritesAcrossSpellingsNotFlagged(t *testing.T) { + realPath, aliasPath := aliasedFile(t, "f.txt", "v1\n") + tracker := NewReadTracker() + tool := NewWriteFile(WriteDeps{Reads: tracker, Writes: NewWriteTracker()}) + readVia(t, tracker, aliasPath) + for i, p := range []string{realPath, aliasPath, realPath} { + if _, err := tool.Execute(context.Background(), mustJSON(map[string]any{ + "file_path": p, "content": strings.Repeat("x", i+2) + "\n", + })); err != nil { + t.Fatalf("write %d via %s: the session's own writes must not be flagged: %v", i, p, err) + } + } +} + +// TestEditFile_StrictModeAcceptsReadThroughAlias: strict mode's read-before-edit +// check must accept a read made through another spelling of the file. It failed +// closed before the fix ("has not been read"), the mirror image of the default +// guard failing open. +func TestEditFile_StrictModeAcceptsReadThroughAlias(t *testing.T) { + realPath, aliasPath := aliasedFile(t, "f.go", "a\nb\n") + tracker := NewReadTracker() + deps := WriteDeps{Reads: tracker, Writes: NewWriteTracker(), Strict: func() bool { return true }} + readVia(t, tracker, aliasPath) + if _, err := NewEditFile(deps).Execute(context.Background(), mustJSON(map[string]any{ + "file_path": realPath, + "edits": []map[string]string{{"old_string": "a", "new_string": "A"}}, + })); err != nil { + t.Fatalf("strict mode must accept a read made through another spelling: %v", err) + } + // Negative half: a peer change after the read is still refused, through + // either spelling. + peerRewrite(t, realPath, "A\nb\nPEER\n") + _, err := NewEditFile(deps).Execute(context.Background(), mustJSON(map[string]any{ + "file_path": aliasPath, + "edits": []map[string]string{{"old_string": "b", "new_string": "B"}}, + })) + if err == nil || !strings.Contains(err.Error(), "changed since you read it") { + t.Fatalf("strict mode must refuse an edit over a peer change, got: %v", err) + } +} + +// TestWriteFile_StaleGuardHoldsAcrossCaseSpellings covers the case-variant alias +// on a volume that folds case (APFS, HFS+ and NTFS by default). It skips where +// the temp volume is case-sensitive, because the two spellings are then two +// files and there is nothing to merge. +func TestWriteFile_StaleGuardHoldsAcrossCaseSpellings(t *testing.T) { + dir := t.TempDir() + lower := filepath.Join(dir, "case.txt") + if err := os.WriteFile(lower, []byte("v1\n"), 0o644); err != nil { + t.Fatal(err) + } + upper := filepath.Join(dir, "CASE.txt") + if a, err := os.Stat(lower); err != nil { + t.Fatal(err) + } else if b, err := os.Stat(upper); err != nil || !os.SameFile(a, b) { + t.Skip("temp volume is case-sensitive; the case alias does not exist here") + } + tracker := NewReadTracker() + readVia(t, tracker, upper) + peerRewrite(t, lower, "peer change\n") + _, err := NewWriteFile(WriteDeps{Reads: tracker}).Execute(context.Background(), + mustJSON(map[string]any{"file_path": lower, "content": "my overwrite\n"})) + if err == nil || !strings.Contains(err.Error(), "changed on disk since you read it") { + t.Fatalf("a write through a case variant of a file changed since the read must be refused, got: %v", err) + } +} + +// TestReadTracker_PersistsCanonicalKey: the persisted read_tracking row carries +// the same key the in-memory map does, so a rehydrated read answers for every +// spelling exactly as the live one did. +func TestReadTracker_PersistsCanonicalKey(t *testing.T) { + realPath, aliasPath := aliasedFile(t, "f.txt", "v1\n") + rt := NewReadTracker() + var persisted string + rt.SetPersistSink(func(path string, _ time.Time, _ string) { persisted = path }) + rt.Record(aliasPath, time.Unix(1, 0), "sha") + if want := lockPathKey(realPath); persisted != want { + t.Fatalf("persisted key = %q, want the canonical key %q", persisted, want) + } + if recs := rt.Records(); len(recs) != 1 || recs[0].Path != lockPathKey(realPath) { + t.Fatalf("Records() = %+v, want one record under %q", recs, lockPathKey(realPath)) + } +} + +// TestReadTracker_HydrateCanonicalisesSpelledRows: rows persisted by a daemon +// that predates the fix carry the path as the agent spelled it. Hydration must +// fold them onto the canonical key, and when two spelled rows of one file +// collide the later read wins whichever order the store returns them in — it is +// the newest version the session is known to have seen. +func TestReadTracker_HydrateCanonicalisesSpelledRows(t *testing.T) { + realPath, aliasPath := aliasedFile(t, "f.txt", "v1\n") + // Both spellings unclean, so neither equals the key on any platform. + spelledReal := filepath.Dir(realPath) + "/./" + filepath.Base(realPath) + older := ReadRecord{Path: aliasPath, Mtime: time.Unix(100, 0), SHA: "sha-old"} + newer := ReadRecord{Path: spelledReal, Mtime: time.Unix(200, 0), SHA: "sha-new"} + for name, recs := range map[string][]ReadRecord{ + "older first": {older, newer}, + "newer first": {newer, older}, + } { + t.Run(name, func(t *testing.T) { + rt := NewReadTracker() + rt.Hydrate(recs) + for _, p := range []string{realPath, aliasPath} { + e, ok := rt.recorded(p) + if !ok || e.sha != "sha-new" || !e.mtime.Equal(newer.Mtime) { + t.Fatalf("recorded(%s) = (%+v, %v), want the newer read", p, e, ok) + } + } + if n := len(rt.Records()); n != 1 { + t.Fatalf("two spellings of one file hydrated into %d records, want 1", n) + } + }) + } + // A spelled row alone must answer for the canonical spelling. + rt := NewReadTracker() + rt.Hydrate([]ReadRecord{older}) + if got := rt.Mtime(realPath); !got.Equal(older.Mtime) { + t.Fatalf("Mtime(real) after hydrating the alias row = %v, want %v", got, older.Mtime) + } +} + +// TestReadTracker_HydrateCanonicalRowOutranksSpelledRow: a row stored under its +// canonical key was written by a daemon with this keying, so it is newer than +// any spelled row an older daemon left, even when its file mtime is OLDER — +// cp -p and rsync -t move mtimes backwards. Deciding by mtime there restored the +// stale spelled read, and the guard then refused a file the session had read. +func TestReadTracker_HydrateCanonicalRowOutranksSpelledRow(t *testing.T) { + realPath, aliasPath := aliasedFile(t, "f.txt", "B\n") + olderMtime := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(realPath, olderMtime, olderMtime); err != nil { // B restored with an older mtime + t.Fatal(err) + } + shaB, err := fileSHA256(realPath) + if err != nil { + t.Fatal(err) + } + current := ReadRecord{Path: lockPathKey(realPath), Mtime: olderMtime, SHA: shaB} + stale := ReadRecord{Path: aliasPath, Mtime: olderMtime.Add(time.Hour), SHA: strings.Repeat("a", 64)} + for name, recs := range map[string][]ReadRecord{ + "canonical first": {current, stale}, + "spelled first": {stale, current}, + } { + t.Run(name, func(t *testing.T) { + rt := NewReadTracker() + rt.Hydrate(recs) + if e, _ := rt.recorded(realPath); e.sha != shaB { + t.Fatalf("hydrated %+v, want the canonical row's read (sha %s)", e, shaB) + } + if changedSinceSessionRead(rt, realPath) { + t.Fatal("the file the session last read is unchanged, but the guard calls it stale") + } + }) + } +} diff --git a/internal/tools/rename_file.go b/internal/tools/rename_file.go index da817cb5..a43f29cd 100644 --- a/internal/tools/rename_file.go +++ b/internal/tools/rename_file.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -120,6 +121,14 @@ func (t *RenameFile) Execute(ctx context.Context, raw json.RawMessage) (string, if err := renameFilePreconditions(ctx, t.deps, from, to, a); err != nil { return "", err } + // The version the move publishes at `to`, read from the source through one + // descriptor BEFORE the rename: a rename moves the inode — bytes and mtime + // intact — so this is what `to` holds when it lands. rename_file writes no + // bytes of its own, and re-reading `to` afterwards would record whatever an + // outside writer had put there by then as this session's version (issue #528). + // A source this snapshot cannot read leaves the version unknown, which records + // no read state rather than a guessed one. + moved, _ := readSnapshot(from, func(io.Reader) error { return nil }) if err := os.Rename(from, to); err != nil { return "", fmt.Errorf("rename_file: %w", err) } @@ -130,7 +139,7 @@ func (t *RenameFile) Execute(ctx context.Context, raw json.RawMessage) (string, if filepath.Dir(to) != filepath.Dir(from) { syncDirBestEffort("rename_file", filepath.Dir(to)) } - t.renameFilePostRename(ctx, from, to) + t.renameFilePostRename(ctx, from, to, moved) return fmt.Sprintf("renamed %s → %s", from, to), nil } @@ -173,7 +182,7 @@ func renameFilePreconditions(ctx context.Context, deps WriteDeps, from, to strin return nil } -func (t *RenameFile) renameFilePostRename(ctx context.Context, from, to string) { +func (t *RenameFile) renameFilePostRename(ctx context.Context, from, to string, moved fileSnapshot) { if err := notifyLSP(ctx, t.deps.Client, from, protocol.FileDeleted); err != nil { slog.Warn("rename_file: LSP delete-notify failed", "path", from, "err", err) } @@ -186,5 +195,5 @@ func (t *RenameFile) renameFilePostRename(ctx context.Context, from, to string) // processDelete. Then enqueue to so the new path is indexed immediately. t.deps.notifyTopology(from) t.deps.notifyTopology(to) - t.deps.recordWritten(ctx, to) + t.deps.recordWritten(ctx, to, moved) } diff --git a/internal/tools/rename_symbol.go b/internal/tools/rename_symbol.go index bb81493d..1d782ee3 100644 --- a/internal/tools/rename_symbol.go +++ b/internal/tools/rename_symbol.go @@ -502,7 +502,7 @@ func (t *RenameSymbol) recordRenameWrites(ctx context.Context, plans []workspace return } for _, p := range plans { - deps.recordWritten(ctx, p.path) + deps.recordWritten(ctx, p.path, p.written) deps.recordUndo(ctx, p.path, string(p.before), string(p.after), true, "rename_symbol") } } diff --git a/internal/tools/symbol_edits_apply.go b/internal/tools/symbol_edits_apply.go index 41e65a01..3196d0fb 100644 --- a/internal/tools/symbol_edits_apply.go +++ b/internal/tools/symbol_edits_apply.go @@ -136,7 +136,8 @@ func applySingleEdit(ctx context.Context, client lsp.Client, c *cache.Cache, dep if err != nil { return "", fmt.Errorf("applying edit: %w", err) } - if _, err := safeWrite(path, after, mode); err != nil { + res, err := safeWrite(path, after, mode) + if err != nil { return "", fmt.Errorf("applying edit: %w", err) } diff := "" @@ -149,7 +150,7 @@ func applySingleEdit(ctx context.Context, client lsp.Client, c *cache.Cache, dep sb.WriteString("\n") sb.WriteString(diff) } - sb.WriteString(semanticPostWrite(ctx, deps, client, c, path, uri, string(before), string(after), toolName, baseline)) + sb.WriteString(semanticPostWrite(ctx, deps, client, c, path, uri, string(before), string(after), toolName, baseline, res.written)) return sb.String(), nil } @@ -205,13 +206,14 @@ func captureSemanticBaseline(ctx context.Context, deps *WriteDeps, uri string) * // semanticPostWrite is the full post-write pipeline for callers still holding // the target's path lock: write-tracker/undo bookkeeping (which requires the -// held lock) plus the notify/diagnostics/quality half. -func semanticPostWrite(ctx context.Context, deps *WriteDeps, client lsp.Client, c *cache.Cache, path, uri, before, after, toolName string, baseline *diagBaseline) string { +// held lock) plus the notify/diagnostics/quality half. written is the version +// the edit published (writeResult.written). +func semanticPostWrite(ctx context.Context, deps *WriteDeps, client lsp.Client, c *cache.Cache, path, uri, before, after, toolName string, baseline *diagBaseline, written fileSnapshot) string { if deps == nil { notifySymbolEditWritten(ctx, client, c, path, uri) return "" } - deps.recordWritten(ctx, path) + deps.recordWritten(ctx, path, written) deps.recordUndo(ctx, path, before, after, true, toolName) return semanticNotifyPostWrite(ctx, deps, client, c, path, uri, before, after, toolName, baseline) } diff --git a/internal/tools/transaction.go b/internal/tools/transaction.go index 90002a20..0d67f049 100644 --- a/internal/tools/transaction.go +++ b/internal/tools/transaction.go @@ -126,13 +126,15 @@ type transactionApplyArgs struct { // txPrepared is the in-memory result of validating one operation: the // pre-edit content, the post-edit content, the file's pre-write mtime, and -// the file mode for the eventual safeWrite. +// the file mode for the eventual safeWrite. written is filled in by the write +// phase: the version the transaction published (writeResult.written). type txPrepared struct { path string before string after string preMtime time.Time perm os.FileMode + written fileSnapshot } func (t *TransactionApply) Execute(ctx context.Context, raw json.RawMessage) (string, error) { @@ -448,12 +450,14 @@ func (t *TransactionApply) txPhase2Write(ctx context.Context, prepared []txPrepa slog.Warn("transaction_apply: txlog record failed — this write is not durable", "path", p.path, "err", err) } - if _, err := safeWrite(p.path, []byte(p.after), p.perm); err != nil { + res, err := safeWrite(p.path, []byte(p.after), p.perm) + if err != nil { rollback(written) txl.Rollback() return nil, nil, fmt.Errorf("transaction_apply: write %q failed: %w; rolled back %d writes", p.path, err, len(written)) } + p.written = res.written written = append(written, p) } // No extra directory fsync here: every write above went through safeWrite, @@ -483,7 +487,7 @@ func (t *TransactionApply) txPhase3Notify(ctx context.Context, written []txPrepa } } invalidateCache(t.deps.Cache, uri) - t.deps.recordWritten(ctx, p.path) + t.deps.recordWritten(ctx, p.path, p.written) } return failed } diff --git a/internal/tools/transaction_diag.go b/internal/tools/transaction_diag.go index 69e9944d..6c7c8982 100644 --- a/internal/tools/transaction_diag.go +++ b/internal/tools/transaction_diag.go @@ -154,16 +154,13 @@ func txFilesWithNewErrors(rep txDiagReport) int { // files the rollback actually restored — a file left alone by the verification // still holds what the transaction wrote, so it must NOT be announced as // reverted. -func (t *TransactionApply) txNotifyRestored(ctx context.Context, written []txPrepared, restored []string) { - restoredSet := make(map[string]bool, len(restored)) - for _, p := range restored { - restoredSet[p] = true - } +func (t *TransactionApply) txNotifyRestored(ctx context.Context, written []txPrepared, restored map[string]fileSnapshot) { for _, p := range written { - if !restoredSet[p.path] { + v, ok := restored[p.path] + if !ok { continue } - t.deps.notifyReverted(ctx, p.path, "file://"+p.path, protocol.FileChanged) + t.deps.notifyReverted(ctx, p.path, "file://"+p.path, protocol.FileChanged, v) } } @@ -176,7 +173,11 @@ func (t *TransactionApply) txNotifyRestored(ctx context.Context, written []txPre // a bounded wait for a language server, so an external process has had real time // to write — and silently reverting someone else's change is the one outcome a // safety feature must not produce. -func rollbackVerified(written []txPrepared) (restored, skipped []string) { +// +// restored maps each restored path to the version its restore published, so the +// trackers record that rather than re-reading the path (issue #528). +func rollbackVerified(written []txPrepared) (restored map[string]fileSnapshot, skipped []string) { + restored = make(map[string]fileSnapshot, len(written)) for _, p := range written { cur, err := os.ReadFile(p.path) if err != nil { @@ -189,12 +190,13 @@ func rollbackVerified(written []txPrepared) (restored, skipped []string) { skipped = append(skipped, p.path) continue } - if _, err := safeWrite(p.path, []byte(p.before), p.perm); err != nil { + res, err := safeWrite(p.path, []byte(p.before), p.perm) + if err != nil { slog.Error("transaction_apply: rollback failed", "path", p.path, "err", err) skipped = append(skipped, p.path) continue } - restored = append(restored, p.path) + restored[p.path] = res.written } return restored, skipped } diff --git a/internal/tools/undo_edit.go b/internal/tools/undo_edit.go index f16180e6..1e247d21 100644 --- a/internal/tools/undo_edit.go +++ b/internal/tools/undo_edit.go @@ -147,11 +147,12 @@ func (t *UndoEdit) applyUndo(ctx context.Context, path string, snap undoSnapshot if info, err := os.Stat(path); err == nil && info.Mode().Perm() != 0 { perm = info.Mode().Perm() } - if _, err := safeWrite(path, []byte(snap.before), perm); err != nil { + res, err := safeWrite(path, []byte(snap.before), perm) + if err != nil { return "", fmt.Errorf("undo_edit: %w", err) } t.notifyUndo(ctx, path, uri, protocol.FileChanged) - t.deps.recordWritten(ctx, path) + t.deps.recordWritten(ctx, path, res.written) t.deps.notifyTopology(path) return t.formatUndoRestore(path, string(current), snap), nil } diff --git a/internal/tools/write_deps.go b/internal/tools/write_deps.go index 4e890b7a..c3f3c6f8 100644 --- a/internal/tools/write_deps.go +++ b/internal/tools/write_deps.go @@ -2,7 +2,6 @@ package tools import ( "context" - "os" "path/filepath" "time" @@ -371,18 +370,20 @@ func (d WriteDeps) resolvePath(ctx context.Context, path string) (string, error) // changedSinceSessionRead staleness guard does not false-positive on the // session's own consecutive writes (read → edit → edit no longer warns). // -// Both calls stat under the caller's held per-path lock, so they observe the -// same post-write mtime. nil-safe on both trackers. Both trackers resolve per -// logical agent (PLAN-286), so a write is recorded against the calling agent's -// own read/write state. -func (d WriteDeps) recordWritten(ctx context.Context, path string) { +// v is the version the caller WROTE — writeResult.written, the hash of its +// bytes and the mtime of the file it wrote them to — never a re-read of the +// path. The per-path lock excludes only plumb's own writers, so by the time this +// runs an outside process may have replaced the file, and re-reading it recorded +// that process's content as this session's version: the session's next write +// then passed both guards over a change it never saw (issue #528). A zero v +// (version unknown) records the write but leaves the read state alone. nil-safe +// on both trackers. Both trackers resolve per logical agent (PLAN-286), so a +// write is recorded against the calling agent's own read/write state. +func (d WriteDeps) recordWritten(ctx context.Context, path string, v fileSnapshot) { if w := d.writes(ctx); w != nil { - w.Record(path) + w.recordAt(path, v.mtime) } - if r := d.reads(ctx); r != nil { - if info, err := os.Stat(path); err == nil { - sha, _ := fileSHA256(path) // best-effort; empty on error - r.Record(path, info.ModTime(), sha) - } + if r := d.reads(ctx); r != nil && !v.mtime.IsZero() { + r.Record(path, v.mtime, v.sha) } } diff --git a/internal/tools/write_file.go b/internal/tools/write_file.go index ee61fae2..bb8af007 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -155,11 +155,12 @@ func (t *WriteFile) Execute(ctx context.Context, raw json.RawMessage) (string, e // can tell errors this write introduced from ones already present. baseline := t.deps.capturePreWriteBaseline(ctx, uri) - if _, err := safeWrite(path, []byte(a.Content), 0o644); err != nil { + res, err := safeWrite(path, []byte(a.Content), 0o644) + if err != nil { return "", fmt.Errorf("write_file: %w", err) } - notifyFailed := t.writeFilePostWrite(ctx, path, uri, isNew) + notifyFailed := t.writeFilePostWrite(ctx, path, uri, isNew, res.written) if undoOK { t.deps.recordUndo(ctx, path, undoBefore, a.Content, !isNew, "write_file") } @@ -269,7 +270,7 @@ func (t *WriteFile) writeFileCapture(ctx context.Context, path string, isNew, wa // reports whether a notification FAILED: the diagnostics pass needs to know, // because a server that was never told the file changed cannot produce a result // that reflects this write. -func (t *WriteFile) writeFilePostWrite(ctx context.Context, path, uri string, isNew bool) (notifyFailed bool) { +func (t *WriteFile) writeFilePostWrite(ctx context.Context, path, uri string, isNew bool, written fileSnapshot) (notifyFailed bool) { changeType := protocol.FileChanged if isNew { changeType = protocol.FileCreated @@ -285,7 +286,7 @@ func (t *WriteFile) writeFilePostWrite(ctx context.Context, path, uri string, is } } invalidateCache(t.deps.Cache, uri) - t.deps.recordWritten(ctx, path) + t.deps.recordWritten(ctx, path, written) return notifyFailed } diff --git a/internal/tools/write_record_race_test.go b/internal/tools/write_record_race_test.go new file mode 100644 index 00000000..f61efdd4 --- /dev/null +++ b/internal/tools/write_record_race_test.go @@ -0,0 +1,284 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/plumbkit/plumb/internal/lsp" + "github.com/plumbkit/plumb/internal/lsp/protocol" + "github.com/plumbkit/plumb/internal/paths" +) + +// Issue #528: after a write, recordWritten stat'ed and re-hashed the PATH to +// refresh the session's read record. An outside writer landing between plumb's +// write and that hash had its content recorded as the version this session +// wrote, and the session's next unguarded write went straight over it. The +// per-path lock only excludes plumb's own writers, so the window is real. +// +// Each test below lands an outside write inside that window deterministically, +// through a post-write hook that runs before the bookkeeping (the adapter hook +// or the language-server notification), then asserts the next write still sees +// the change. Each also has a positive control: without the outside writer the +// session's own consecutive writes are never flagged. + +// outsiderWrite overwrites path the way a process outside plumb would. With +// keepMtime it also restores the file's mtime, as cp -p, rsync -t and a +// same-tick write do, so only the recorded SHA can tell the versions apart. +func outsiderWrite(t *testing.T, path, content string, keepMtime bool) { + t.Helper() + before, err := os.Stat(path) + if err != nil { + t.Errorf("outsider: stat %s: %v", path, err) + return + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Errorf("outsider: write %s: %v", path, err) + return + } + when := before.ModTime() + if !keepMtime { + when = when.Add(2 * time.Second) + } + if err := os.Chtimes(path, when, when); err != nil { + t.Errorf("outsider: chtimes %s: %v", path, err) + } +} + +// onceHook returns a hook that runs fn the first time it is called for path. +func onceHook(path string, fn func()) func(string) { + var once sync.Once + return func(p string) { + if p == path { + once.Do(fn) + } + } +} + +// notifyHookClient is an lsp.Client whose didChangeWatchedFiles runs a hook per +// changed path. The embedded nil interface panics on any other method, which is +// the point: the write path under test touches nothing else. +type notifyHookClient struct { + lsp.Client + hook func(path string) +} + +func (c *notifyHookClient) DidChangeWatchedFiles(_ context.Context, p protocol.DidChangeWatchedFilesParams) error { + for _, ch := range p.Changes { + if ch.Type != protocol.FileDeleted { + c.hook(paths.URIToPath(ch.URI)) + } + } + return nil +} + +// assertNextWriteSeesOutsider runs an unguarded write_file over path and +// asserts it is refused and the outsider's content survives. +func assertNextWriteSeesOutsider(t *testing.T, deps WriteDeps, path, outsider string) { + t.Helper() + deps.PostWriteNotifyFn, deps.Client = nil, nil + _, err := NewWriteFile(deps).Execute(context.Background(), mustJSON(map[string]any{"file_path": path, "content": "clobber\n"})) + if err == nil || !strings.Contains(err.Error(), "changed on disk since you read it") { + t.Fatalf("the next write must see the outside change made after plumb's write, got: %v", err) + } + if got, _ := os.ReadFile(path); string(got) != outsider { + t.Fatalf("outside change was overwritten: %q", got) + } +} + +func raceDeps() WriteDeps { + return WriteDeps{Reads: NewReadTracker(), Writes: NewWriteTracker()} +} + +var outsiderModes = []struct { + name string + keepMtime bool +}{ + {"mtime advances", false}, + {"mtime preserved", true}, +} + +func TestWriteFile_OutsiderWriteAfterOwnWriteIsNotRecordedAsOwn(t *testing.T) { + for _, m := range outsiderModes { + t.Run(m.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "f.txt") + deps := raceDeps() + hook := onceHook(path, func() { outsiderWrite(t, path, "outsider\n", m.keepMtime) }) + deps.PostWriteNotifyFn = func(_ context.Context, p string) error { hook(p); return nil } + if _, err := NewWriteFile(deps).Execute(context.Background(), mustJSON(map[string]any{"file_path": path, "content": "mine\n"})); err != nil { + t.Fatal(err) + } + assertNextWriteSeesOutsider(t, deps, path, "outsider\n") + if m.keepMtime { + return // the write tracker is mtime-only; nothing to see here + } + // The write tracker records the same written version, so read_file's + // concurrent-edit note still fires for the outsider's later mtime. + out, err := NewReadFile(NewReadTracker()).WithWrites(deps.Writes).Execute(context.Background(), mustJSON(map[string]any{"file_path": path})) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "changed on disk since plumb last wrote it") { + t.Fatalf("read_file must warn that the file changed after plumb's write:\n%s", out) + } + }) + } +} + +func TestEditFile_OutsiderWriteAfterOwnEditIsNotRecordedAsOwn(t *testing.T) { + for _, m := range outsiderModes { + t.Run(m.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "f.go") + if err := os.WriteFile(path, []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + deps := raceDeps() + readVia(t, deps.Reads, path) + hook := onceHook(path, func() { outsiderWrite(t, path, "outsider\n", m.keepMtime) }) + deps.PostWriteNotifyFn = func(_ context.Context, p string) error { hook(p); return nil } + if _, err := NewEditFile(deps).Execute(context.Background(), mustJSON(map[string]any{ + "file_path": path, + "edits": []map[string]string{{"old_string": "a", "new_string": "A"}}, + })); err != nil { + t.Fatal(err) + } + assertNextWriteSeesOutsider(t, deps, path, "outsider\n") + }) + } +} + +func TestTransactionApply_OutsiderWriteAfterCommitIsNotRecordedAsOwn(t *testing.T) { + for _, m := range outsiderModes { + t.Run(m.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "f.go") + if err := os.WriteFile(path, []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + deps := raceDeps() + hook := onceHook(path, func() { outsiderWrite(t, path, "outsider\n", m.keepMtime) }) + deps.PostWriteNotifyFn = func(_ context.Context, p string) error { hook(p); return nil } + if _, err := NewTransactionApply(deps).Execute(context.Background(), mustJSON(map[string]any{ + "operations": []map[string]any{{ + "file_path": path, + "edits": []map[string]string{{"old_string": "a", "new_string": "A"}}, + }}, + })); err != nil { + t.Fatal(err) + } + assertNextWriteSeesOutsider(t, deps, path, "outsider\n") + }) + } +} + +// TestRenameFile_OutsiderWriteAfterMoveIsNotRecordedAsOwn covers the one write +// that holds no bytes: rename_file moves an inode. The version it records must +// be the one it moved, not whatever the destination holds by the time the +// bookkeeping runs. +func TestRenameFile_OutsiderWriteAfterMoveIsNotRecordedAsOwn(t *testing.T) { + for _, m := range outsiderModes { + t.Run(m.name, func(t *testing.T) { + dir := t.TempDir() + from, to := filepath.Join(dir, "a.txt"), filepath.Join(dir, "b.txt") + if err := os.WriteFile(from, []byte("moved\n"), 0o644); err != nil { + t.Fatal(err) + } + deps := raceDeps() + deps.BlockDirtyFn = func() bool { return false } + deps.Client = ¬ifyHookClient{hook: onceHook(to, func() { outsiderWrite(t, to, "outsider\n", m.keepMtime) })} + if _, err := NewRenameFile(deps).Execute(context.Background(), mustJSON(map[string]any{"from": from, "to": to})); err != nil { + t.Fatal(err) + } + assertNextWriteSeesOutsider(t, deps, to, "outsider\n") + }) + } +} + +// TestRenameFile_UnreadableSourceRecordsNoReadState: when rename_file cannot +// read the version it moves, it must not invent one. The destination then has no +// read record, exactly like a file the session never read, rather than a +// zero-mtime record that makes every later write look stale. +func TestRenameFile_UnreadableSourceRecordsNoReadState(t *testing.T) { + dir := t.TempDir() + from, to := filepath.Join(dir, "a.txt"), filepath.Join(dir, "b.txt") + if err := os.WriteFile(from, []byte("secret\n"), 0o000); err != nil { + t.Fatal(err) + } + if f, err := os.Open(from); err == nil { + _ = f.Close() + t.Skip("a mode-0 file is readable here (running as root?)") + } + deps := raceDeps() + deps.BlockDirtyFn = func() bool { return false } + if _, err := NewRenameFile(deps).Execute(context.Background(), mustJSON(map[string]any{"from": from, "to": to})); err != nil { + t.Fatal(err) + } + if e, ok := deps.Reads.recorded(to); ok { + t.Fatalf("an unreadable move recorded read state %+v; want none", e) + } + if !deps.Writes.Wrote(to) { + t.Fatal("the move must still be recorded as this session's write") + } +} + +// TestRecordWritten_OwnWritesStayUnflagged is the positive control for every +// tool above: with no outside writer, the version plumb records is the version +// on disk, so the session's next write — and strict mode's next edit — pass. +func TestRecordWritten_OwnWritesStayUnflagged(t *testing.T) { + t.Setenv("PLUMB_STRICT_EDITS", "1") + dir := t.TempDir() + path := filepath.Join(dir, "f.go") + deps := raceDeps() + deps.BlockDirtyFn = func() bool { return false } + ctx := context.Background() + steps := []struct { + name string + run func() error + }{ + {"write_file creates", func() error { + _, err := NewWriteFile(deps).Execute(ctx, mustJSON(map[string]any{"file_path": path, "content": "a\nb\n"})) + return err + }}, + {"edit_file (strict, no re-read)", func() error { + _, err := NewEditFile(deps).Execute(ctx, mustJSON(map[string]any{ + "file_path": path, "edits": []map[string]string{{"old_string": "a", "new_string": "A"}}, + })) + return err + }}, + {"transaction_apply", func() error { + _, err := NewTransactionApply(deps).Execute(ctx, mustJSON(map[string]any{ + "operations": []map[string]any{{"file_path": path, "edits": []map[string]string{{"old_string": "b", "new_string": "B"}}}}, + })) + return err + }}, + {"rename_file", func() error { + moved := filepath.Join(dir, "g.go") + if _, err := NewRenameFile(deps).Execute(ctx, mustJSON(map[string]any{"from": path, "to": moved})); err != nil { + return err + } + path = moved + return nil + }}, + {"write_file overwrites", func() error { + _, err := NewWriteFile(deps).Execute(ctx, mustJSON(map[string]any{"file_path": path, "content": "final\n"})) + return err + }}, + } + for _, s := range steps { + if err := s.run(); err != nil { + t.Fatalf("%s: the session's own write must not be flagged: %v", s.name, err) + } + e, ok := deps.Reads.recorded(path) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + sha, _ := fileSHA256(path) + if !ok || !e.mtime.Equal(info.ModTime()) || e.sha != sha { + t.Fatalf("%s: recorded (%v, %s, ok=%v), want the on-disk version (%v, %s)", s.name, e.mtime, e.sha, ok, info.ModTime(), sha) + } + } +} diff --git a/internal/tools/write_reply_version_test.go b/internal/tools/write_reply_version_test.go new file mode 100644 index 00000000..96b34fda --- /dev/null +++ b/internal/tools/write_reply_version_test.go @@ -0,0 +1,127 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// The reply half of issue #528: edit_file's `mtime:` line is what an agent +// passes back as expected_mtime, so it must name the version plumb wrote and +// recorded, not whatever the path holds by the time the reply is formatted. +// edit_file formats it after the post-write diagnostics wait, and a re-stat +// there handed the caller an outside writer's mtime. That mtime matched the +// file, changedAtSameMtime could not second-guess it (the recorded read sits at +// plumb's mtime), and the next write went over the outside change. + +var replyMtimeRe = regexp.MustCompile(`(?m)^mtime: (\S+)`) + +func replyMtime(t *testing.T, out string) string { + t.Helper() + m := replyMtimeRe.FindStringSubmatch(out) + if m == nil { + t.Fatalf("no mtime line in reply:\n%s", out) + } + return m[1] +} + +// editReplyCases are the two edit_file replies that print an mtime. +var editReplyCases = []struct { + name string + partial bool +}{ + {"edit_file", false}, + {"edit_file apply_partial", true}, +} + +func editArgs(path string, partial bool, expectedMtime, old, repl string) map[string]any { + args := map[string]any{ + "file_path": path, + "edits": []map[string]string{{"old_string": old, "new_string": repl}}, + } + if partial { + args["apply_partial"] = true + } + if expectedMtime != "" { + args["expected_mtime"] = expectedMtime + } + return args +} + +// TestEditFile_ReplyMtimeIsTheWrittenVersion: an outsider lands after the +// rename and before the reply is rendered. The reply's mtime must be the one +// plumb recorded, and a write guarded by it must be refused. +func TestEditFile_ReplyMtimeIsTheWrittenVersion(t *testing.T) { + for _, c := range editReplyCases { + for _, m := range outsiderModes { + t.Run(c.name+"/"+m.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "f.go") + if err := os.WriteFile(path, []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + deps := raceDeps() + readVia(t, deps.Reads, path) + hook := onceHook(path, func() { outsiderWrite(t, path, "outsider\n", m.keepMtime) }) + deps.PostWriteNotifyFn = func(_ context.Context, p string) error { hook(p); return nil } + out, err := NewEditFile(deps).Execute(context.Background(), mustJSON(editArgs(path, c.partial, "", "a", "A"))) + if err != nil { + t.Fatal(err) + } + got := replyMtime(t, out) + e, _ := deps.Reads.recorded(path) + if want := e.mtime.Format(time.RFC3339Nano); got != want { + t.Fatalf("reply mtime %s, want the recorded (written) version %s", got, want) + } + deps.PostWriteNotifyFn = nil + _, err = NewWriteFile(deps).Execute(context.Background(), mustJSON(map[string]any{ + "file_path": path, "content": "clobber\n", "expected_mtime": got, + })) + if err == nil || !strings.Contains(err.Error(), "modified since you read it") { + t.Fatalf("a write guarded by the reply's mtime must be refused over the outside change, got: %v", err) + } + if data, _ := os.ReadFile(path); string(data) != "outsider\n" { + t.Fatalf("outside change was overwritten: %q", data) + } + }) + } + } +} + +// TestEditFile_ReplyMtimeRoundTrips is the positive control: with no outsider, +// each reply's mtime is accepted as the next call's expected_mtime (strict mode +// on, no re-read), and a peer write after it is still refused. +func TestEditFile_ReplyMtimeRoundTrips(t *testing.T) { + t.Setenv("PLUMB_STRICT_EDITS", "1") + for _, c := range editReplyCases { + t.Run(c.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "f.go") + deps := raceDeps() + ctx := context.Background() + if _, err := NewWriteFile(deps).Execute(ctx, mustJSON(map[string]any{"file_path": path, "content": "a\nb\nc\n"})); err != nil { + t.Fatal(err) + } + out, err := NewEditFile(deps).Execute(ctx, mustJSON(editArgs(path, c.partial, "", "a", "A"))) + if err != nil { + t.Fatal(err) + } + if info, err := os.Stat(path); err != nil || replyMtime(t, out) != info.ModTime().Format(time.RFC3339Nano) { + t.Fatalf("reply mtime %s is not the file's mtime (%v)", replyMtime(t, out), err) + } + out, err = NewEditFile(deps).Execute(ctx, mustJSON(editArgs(path, c.partial, replyMtime(t, out), "b", "B"))) + if err != nil || strings.Contains(out, "FAILED") { + t.Fatalf("an edit guarded by the previous reply's mtime must apply: %v\n%s", err, out) + } + mt := replyMtime(t, out) + peerRewrite(t, path, "A\nB\nPEER\n") + if _, err := NewWriteFile(deps).Execute(ctx, mustJSON(map[string]any{ + "file_path": path, "content": "x\n", "expected_mtime": mt, + })); err == nil { + t.Fatal("a write guarded by a reply's mtime must be refused after a peer write") + } + }) + } +} diff --git a/internal/tools/write_tracker.go b/internal/tools/write_tracker.go index 4c473cfc..a5de5438 100644 --- a/internal/tools/write_tracker.go +++ b/internal/tools/write_tracker.go @@ -3,6 +3,7 @@ package tools import ( "os" "sync" + "time" ) // WriteTracker records the set of file paths plumb has written during a single @@ -36,19 +37,34 @@ func NewWriteTracker() *WriteTracker { } // Record marks path as written by plumb this session, capturing the file's -// current mtime so a later read can spot a concurrent external edit. Called -// after every successful write. nil-safe. +// current mtime so a later read can spot a concurrent external edit. The write +// tools record through recordAt (via WriteDeps.recordWritten) with the mtime +// they wrote instead of a fresh stat. nil-safe. func (w *WriteTracker) Record(path string) { if w == nil { return } - key := lockPathKey(path) - var mtime int64 + var mtime time.Time if info, err := os.Stat(path); err == nil { - mtime = info.ModTime().UnixNano() + mtime = info.ModTime() + } + w.recordAt(path, mtime) +} + +// recordAt is Record with the mtime the writer already knows — the version it +// wrote, not whatever a stat of the path would find by now (issue #528). A zero +// mtime records the write with its mtime unknown. nil-safe. +func (w *WriteTracker) recordAt(path string, mtime time.Time) { + if w == nil { + return + } + key := lockPathKey(path) + var ns int64 + if !mtime.IsZero() { + ns = mtime.UnixNano() } w.mu.Lock() - w.written[key] = mtime + w.written[key] = ns w.mu.Unlock() }