Skip to content

fix: refresh cached project sessions from disk and guard fix writes against stale content - #37

Merged
Arthurvdv merged 5 commits into
mainfrom
fix/stale-session-refresh
Sep 23, 2026
Merged

Arthurvdv merged 5 commits into
mainfrom
fix/stale-session-refresh

Conversation

@Arthurvdv

@Arthurvdv Arthurvdv commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Summary

  • ProjectSession.RefreshFromDiskAsync incrementally syncs the in-memory workspace with on-disk .al files before every get_fixes / apply_fix / apply_fix_all call. Only changed/added/removed files are touched (length+mtime gate, then ordinal content compare); untouched documents keep their DocumentId and compilation state. Mutations go through Workspace.OnDocument* like almcp's ProjectWatcher.
  • GuardedFileWriter.WriteIfUnchangedAsync verifies the file still equals the text the fix was computed from before writing. apply_fix returns { error: "StaleFile" } on conflict; apply_fix_all writes non-conflicting files, lists the rest in conflicts and keeps their diagnostics in unfixedDiagnostics.
  • ReloadProjectAsync (full teardown + reload) is removed; the incremental refresh replaces it.
  • Robustness from the review rounds: a locked, vanished or permission-denied file is skipped for that refresh (stderr Warning:) instead of failing the whole call; a failing write in apply_fix_all becomes a conflicts entry instead of aborting the loop; refresh bookkeeping is committed in a finally, so a cancelled refresh cannot leave the tracked-document map out of sync with the workspace; ProjectSession.Dispose is idempotent and waits (bounded) for an in-flight refresh.

Root cause

ProjectSessionManager cached a ProjectSession per project path for the life of the server. ProjectLoader.LoadProjectAsync eagerly read every .al file into in-memory TextLoaders at load time, and nothing re-read them except the ReloadProjectAsync that apply_fix/apply_fix_all called after their own write.

External edits made between calls were invisible to get_fixes, apply_fix, and apply_fix_all. apply_fix_all wrote whole-file content computed from stale text and silently reverted external edits.

The issue mentions al_downloadsymbols force:true as a workaround. That only recreates almcp's own workspace (CompilationService.ReloadWorkspace), never ours — the in-process ProjectSession was unaffected by almcp's reload, so the staleness persisted.

AA0248

The this.MaxStrLen false positives from issue #36 come from Microsoft's CodeCop Rule248AddThis, which excludes built-ins via TargetMethod.IsStatic (every listed built-in is isStatic: true in the compiler symbol tables); the syntactic AddThisCodeAction only fires where the analyzer reported. Those false positives most plausibly came from unresolved invocations (error symbols, non-static) in a stale or symbol-less in-process compilation — a downstream effect of this same staleness bug. No code change for AA0248 in this PR; the refresh fix should eliminate the root cause.

Behaviour changes

apply_fix

On conflict (file changed on disk after the fix was computed):

{
  "error": "StaleFile",
  "message": "<filePath> changed on disk after the fix was computed; nothing was written. Re-run get_fixes and apply_fix.",
  "filePath": "...",
  "diagnosticId": "..."
}

apply_fix_all

The Completed response now always includes conflicts (empty [] when none):

{
  "applied": true,
  "dryRun": false,
  "diagnosticId": "LC0020",
  "filesChanged": ["PageA.al"],
  "conflicts": [
    { "filePath": "PageB.al", "message": "PageB.al changed on disk after the fix was computed; not overwritten." }
  ],
  "message": "1 file(s) were skipped because they changed on disk while the fix was being computed; their diagnostics are included in unfixedDiagnostics; re-run apply_fix_all to fix them.",
  "unfixedDiagnostics": [
    { "filePath": "PageB.al", "line": 11, "column": 17 }
  ]
}
  • Diagnostics of files skipped as conflicts are included in unfixedDiagnostics (deduplicated against the ones the fix-all pass itself could not fix), so callers that check unfixedDiagnostics.length == 0 correctly see them as unresolved. Their positions are the analysis-time positions and may have shifted if the file was edited; the tool description says so.
  • applied is true as soon as at least one file was written; conflicts and message carry the partial-success detail.
  • A file whose write fails with an I/O or permission error is reported as a conflicts entry too; the remaining files are still processed.

No post-write reload

There is no explicit ReloadProjectAsync after writing. The next GetOrLoadProjectAsync call picks the written file up via the same RefreshFromDiskAsync that runs on every cache hit.

Tests

# Test Asserts
1 NothingChanged_SummaryAllZero_SameSolution Refresh with no disk changes: summary all zero, same Solution instance, same DocumentIds
2 EditFile_UpdatedCountIsOne_NewTextVisible Append // edited to PageB.al: Updated == 1, new text in GetDocument, IDs preserved
3 AddFile_AddedCountIsOne_DocumentAccessible Create PageD.al: Added == 1, document accessible, doc count increased
4 DeleteFile_RemovedCountIsOne_CompilationSucceeds Delete PageC.al: Removed == 1, document null, compilation succeeds
5 TouchWithoutContentChange_SummaryZero_SameSolution Touch mtime only: summary zero, same Solution (content-compare fallback)
6 TouchWithoutContentChange_StampConverges After a touch-only refresh the tracked stamp equals the file's stamp, so the next refresh skips the read
7 FileInAlPackages_NotAdded Create .alpackages/X.al: Added == 0
8 ViaSessionManager_EditVisibleOnSecondCall_SameInstance Edit between two GetOrLoadProjectAsync calls: same ProjectSession instance, edit visible
9 LockedFile_RefreshCompletes_FileSkipped Windows-only: PageB.al held with an exclusive lock and touched; refresh completes with zero summary, no exception
10 PreCancelledToken_ThrowsBeforeMutating_NextRefreshPicksUpEverything Already-cancelled token throws before any mutation; the next refresh adds both new files with no duplicates
11 Dispose_Idempotent Calling Dispose twice does not throw
12 TryStat_NonexistentPath_ReturnsNull / TryReadAsync_NonexistentPath_ReturnsNull The per-file guards return null instead of throwing
13 ApplyFixAll_ExternalEditBetweenCalls_PreservesExternalEdit THE #36 regression test: prime session, rename OtherField to RenamedField in PageB.al, run apply_fix_all LC0020. Asserts PageB still contains RenamedField AND has exactly one ApplicationArea.
14 ApplyFixAll_FileAddedAfterLoad_FixesNewFile Add PageD.al after load: diagnosticsFound == 3, PageD in filesChanged
15 ApplyFixAll_FileDeletedAfterLoad_FixesRemainingFiles Delete PageB.al after load: diagnosticsFound == 1, only PageA in filesChanged
16 Existing ApplyFixAll_ProjectScope_FixesAllOccurrencesAcrossFiles Added Assert.Empty(conflicts)
17 ApplyFixAll_Changes_CarryDiagnosticLocations CodeFixRunner-level: each FixAllFileChange has non-empty Diagnostics with correct 1-based line numbers
18 MergeUnfixed_* (4 cases) No conflicts returns the same list; a conflict appends its file's diagnostics; a diagnostic already unfixed is listed once; path match is case-insensitive
19 ApplyFix_ExternalEditBetweenCalls_PreservesEditAndAppliesFix Prime, append // edited, apply fix: applied == true, file contains // edited, exactly one ApplicationArea
20 GuardedFileWriterTests (4 cases) Matching file writes; differing file returns a conflict and stays untouched; missing file and missing parent directory return a conflict
21 EnumerateAlFiles_ExcludesAlPackages_ReturnsFullPaths .alpackages/*.al excluded, full paths returned

Regression test vs old code: The new tests cannot compile against the old code (CodeFixResult gained OriginalContent, the JSON response gained conflicts), so the stash-and-run dance is impractical. From the diff: the old ApplyFixAllTool called File.WriteAllTextAsync(change.FilePath, change.ModifiedContent) with ModifiedContent computed from the cached (stale) workspace text, so test 13's RenamedField rename would have been overwritten by the stale OtherField content. The new code calls RefreshFromDiskAsync before computing the fix and uses GuardedFileWriter for the write, making the rename survive.

Verification

  • dotnet build --configuration Release: 0 warnings, 0 errors. dotnet test --configuration Release: 257 passed, 0 skipped (Windows, DevTools 18.0.41).
  • CI did not run on this PR: the GitHub Actions runner quota was exhausted on 2026-09-22. The Ubuntu leg of the matrix has therefore not exercised the new tests; the only OS-gated test (LockedFile_RefreshCompletes_FileSkipped) returns early off Windows.
  • Four high-effort review rounds; every confirmed finding was fixed in the fix: address review round N commits. Follow-up: ProjectSession.Dispose disposes the workspace while a timed-out refresh may still be running #38 (Dispose still disposes the workspace when its bounded wait for an in-flight refresh times out; shutdown-only).

Fixes #36

🤖 Generated with Claude Code

Arthurvdv and others added 5 commits September 22, 2026 15:04
…gainst stale content

ProjectSession.RefreshFromDiskAsync incrementally syncs the in-memory
workspace with on-disk .al files before every get_fixes / apply_fix /
apply_fix_all call. Only changed/added/removed files are touched;
untouched documents keep their DocumentId and compilation state.
Mutations go through Workspace.OnDocument* (matching almcp's
ProjectWatcher pattern), never TryApplyChanges on a forked solution.

GuardedFileWriter.WriteIfUnchangedAsync verifies the file still equals
the text the fix was computed from before writing. apply_fix returns
{ error: "StaleFile" } on conflict; apply_fix_all writes non-conflicting
files and lists the rest in "conflicts".

Fixes #36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
F0: Always commit updated file stamps after refresh loops, even when no
    content changed. Prevents re-reading touched-but-unchanged files on
    every subsequent call. Rebuild the DocumentId map only on add/remove.
    Added TrackedDocuments property and convergence test.

F1: Widen per-file catch in RefreshFromDiskAsync to also handle
    UnauthorizedAccessException (locked/permission-denied files skip
    with a stderr warning instead of aborting the whole call).

F2: GuardedFileWriter now catches DirectoryNotFoundException alongside
    FileNotFoundException. ApplyFixAllTool write loop has a per-file
    guard so an IOException/UnauthorizedAccessException on one file
    becomes a conflict entry instead of aborting accounting for already-
    written files. Added MissingParentDirectory test.

F3: Removed dead try/catch (IOException) around in-memory workspace
    OnDocument* calls that can never throw IOException.

F4: README and AGENTS.md now name the three tools that refresh from disk
    (get_fixes, apply_fix, apply_fix_all) instead of overstating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
G1: Extract TryStat/TryReadAsync helpers in ProjectSession so that
FileStamp.Of (which actually stats the file) and File.ReadAllTextAsync
are both guarded against IOException/UnauthorizedAccessException. The
previous code only wrapped `new FileInfo(path)` which never touches
disk. All four duplicated try/catch sites now use the shared helpers.
Tests: LockedFile_RefreshCompletes_FileSkipped (Windows-only exclusive
lock), TryStat/TryReadAsync on nonexistent paths returning null.

G2: FixAllFileChange now carries the original Diagnostics per changed
file. CodeFixRunner populates them via a shared ToLocation helper
(extracted from FindRemainingDiagnosticsAsync). ApplyFixAllTool.MergeUnfixed
reconciles: when a file write is skipped as a conflict, its diagnostics
are appended to unfixedDiagnostics so callers see them as unresolved.
Tool description updated. Tests: Changes_CarryDiagnosticLocations
(CodeFixRunner level), MergeUnfixed_NoConflicts, _WithConflict,
_CaseInsensitivePathMatch (all unit-level).

G3: ProjectSession.Dispose() now sets a _disposed flag, acquires the
gate with a 5s timeout before disposing, and RefreshFromDiskAsync
checks _disposed after acquiring the gate (throws
ObjectDisposedException). The finally block swallows
ObjectDisposedException from Release() to avoid crashing on shutdown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
H1: RefreshFromDiskAsync — move _documents commit to finally block so
a cancellation or exception mid-refresh cannot desync _documents from
the workspace. newDocs and counters declared before try; newDocs is not
null guard protects the _disposed throw path. New test:
CancelledToken_ThrowsAndLeavesStateConsistent.

H2: Dispose — idempotent early return when _disposed; gate is only
disposed after a successful Wait (leaked on timeout to avoid
ObjectDisposedException on pending waiters). New test:
Dispose_Idempotent.

H3: apply_fix_all description — added "(positions as analysed, so they
may have shifted if the file was edited)" after unfixedDiagnostics.

Skipped: deterministic mid-pass cancellation test (no clean hook to
cancel between the remove and add loops without introducing test-only
seams into production code).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- MergeUnfixed dedupes the merged list so a conflict file's diagnostic that was
  already reported as unfixed is listed once; regression test added.
- Rename the pre-cancelled-token refresh test to say what it covers (the gate
  rejects an already-cancelled token before any mutation) and note why mid-pass
  cancellation has no deterministic test hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Arthurvdv
Arthurvdv merged commit 04c742b into main Sep 23, 2026
7 checks passed
@Arthurvdv
Arthurvdv deleted the fix/stale-session-refresh branch September 23, 2026 07:26
Arthurvdv added a commit that referenced this pull request Oct 3, 2026
…pply_fix and apply_fix_all (#48)

apply_fix and apply_fix_all were the only tools that wrote .al files, and the write was a
truncating in-place File.WriteAllTextAsync with the default encoding: a UTF-8-with-BOM file lost
its BOM (whole-file git diff), a UTF-16 file was converted to UTF-8, a crash mid-write could leave
a truncated file, and a failed write in apply_fix_all left the earlier files of the batch modified.

GuardedFileWriter is now a DI singleton and the only code that writes .al files:

- Atomic: the new bytes go to a sibling <file>.<guid>.alcops.tmp in the same directory, which is
  then moved over the target with File.Move(overwrite: true). The temp is deleted in finally; a
  stray one left by a hard kill is swept on project load and, when older than 30 s, on refresh.
  Never File.Replace and never a backup file: almcp's ProjectWatcher treats a move-over as an
  in-place update that keeps the DocumentId, while replace-with-backup removes and re-adds the
  document.
- Encoding-preserving: the file is read as bytes for the existing stale check, the BOM is sniffed
  and the text decoded strictly in that encoding (UTF-8 with/without BOM, UTF-16 LE/BE, UTF-32
  LE/BE), and the same encoding and BOM are written back. Line endings round-trip. A file with
  bytes that are invalid in its detected encoding (e.g. Windows-1252) is refused as
  UnsupportedEncoding instead of being rewritten with U+FFFD. The encoding is detected at write
  time rather than stored on TrackedDocument, so an editor changing the BOM between load and
  write is honoured and nothing is plumbed through the fix pipeline.
- Batch rollback: apply_fix_all stages every file first (changed, deleted, unreadable or
  invalidly encoded files drop out as conflicts and the rest proceed, as in #37), then commits
  one by one. Any exception during a commit restores every file already written in that call
  from the original bytes held in memory and reports the whole staged set in conflicts with
  applied: false. A file edited after this call wrote it, or whose rollback fails, is named in
  message and stays in filesChanged.
- conflicts entries gain a kind (StaleFile, UnsupportedEncoding, ReadFailed, WriteFailed,
  RolledBack, NotWritten); apply_fix returns the kind as error. Additive; #41 owns the vocabulary.

Tests: 257 -> 293. BOM and invalid-byte fixtures are created at test time, nothing with a BOM is
committed; failure injection goes through an internal move seam so every new test runs on the
ubuntu CI matrix.

Fixes #40

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apply_fix_all uses stale cached file content, silently reverting manual edits

1 participant