From 045db854ce7d56d281b624f853d679920ee964c4 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kukrash Date: Tue, 21 Jul 2026 00:58:33 +0200 Subject: [PATCH] Fix transient file watcher events and flaky tests Signed-off-by: Aliaksandr Kukrash --- .../FileWatcherByteIdentityTests.cs | 7 +- .../FileWatcherFailedRecompileTests.cs | 11 ++- .../FileWatcherFilterAndArmingTests.cs | 18 +++-- src/Heddle.Tests/FileWatcherReloadTests.cs | 78 ++++++++++--------- .../FileWatcherSwapConcurrencyTests.cs | 15 ++-- src/Heddle/HeddleTemplate.cs | 29 ++++++- 6 files changed, 102 insertions(+), 56 deletions(-) diff --git a/src/Heddle.Tests/FileWatcherByteIdentityTests.cs b/src/Heddle.Tests/FileWatcherByteIdentityTests.cs index df599fa3..3b91d881 100644 --- a/src/Heddle.Tests/FileWatcherByteIdentityTests.cs +++ b/src/Heddle.Tests/FileWatcherByteIdentityTests.cs @@ -11,6 +11,9 @@ namespace Heddle.Tests /// public class FileWatcherByteIdentityTests { + // Per-test watched-file stem: isolates this test from concurrent tests and parallel TFM hosts. + private readonly string _stem = FileWatcherTestSupport.NewStem(); + private const string Source = "

@()

|@raw()"; // Pre-recorded baseline for Source with model "x" under the default options (Html profile, // legacy WebUtility encoder): the unnamed sink encodes, @raw does not. @@ -31,8 +34,8 @@ public void FlagOffAndInlineOutputsAreByteIdentical() var dir = FileWatcherTestSupport.NewTempDir(); try { - File.WriteAllText(Path.Combine(dir, "home.heddle"), Source); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); + File.WriteAllText(Path.Combine(dir, _stem + ".heddle"), Source); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); options.EnableFileChangeCheck = false; using var fromFile = new HeddleTemplate(options); Assert.True(fromFile.CompileResult.Success, fromFile.CompileResult.ToString()); diff --git a/src/Heddle.Tests/FileWatcherFailedRecompileTests.cs b/src/Heddle.Tests/FileWatcherFailedRecompileTests.cs index 020116b0..0966a85a 100644 --- a/src/Heddle.Tests/FileWatcherFailedRecompileTests.cs +++ b/src/Heddle.Tests/FileWatcherFailedRecompileTests.cs @@ -10,6 +10,9 @@ namespace Heddle.Tests /// public class FileWatcherFailedRecompileTests { + // Per-test watched-file stem: isolates this test from concurrent tests and parallel TFM hosts. + private readonly string _stem = FileWatcherTestSupport.NewStem(); + /// The pinned scenario: break the file, reload → CompileResult.Success == false with /// the error in ErrorList while Generate still renders the previous content; fix the /// file, reload → success again with the new content (the edit-to-fix loop D2 arms for). @@ -19,21 +22,21 @@ public void FailedRecompileKeepsLastGoodAndSurfacesErrorOnCompileResult() var dir = FileWatcherTestSupport.NewTempDir(); try { - var path = Path.Combine(dir, "home.heddle"); + var path = Path.Combine(dir, _stem + ".heddle"); File.WriteAllText(path, "GOOD"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); File.WriteAllText(path, "@profile(){{pdf}}broken"); // deterministic compile error (unknown profile) - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.False(template.CompileResult.Success, "the broken edit must surface a failed CompileResult"); Assert.NotEmpty(template.CompileResult.ErrorList); Assert.Equal("GOOD", template.Generate(null)); // last-good stays published and renderable File.WriteAllText(path, "FIXED"); // the edit-to-fix save recovers - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("FIXED", template.Generate(null)); } diff --git a/src/Heddle.Tests/FileWatcherFilterAndArmingTests.cs b/src/Heddle.Tests/FileWatcherFilterAndArmingTests.cs index 0dd80b87..b2e58c99 100644 --- a/src/Heddle.Tests/FileWatcherFilterAndArmingTests.cs +++ b/src/Heddle.Tests/FileWatcherFilterAndArmingTests.cs @@ -24,6 +24,11 @@ public static string NewTempDir() return dir; } + /// A per-test watched-file stem, unique across concurrently running tests AND across the + /// parallel per-TFM test hosts — no two watchers can ever agree on a file name, so a write in one + /// process can never be picked up by another's watcher. + public static string NewStem() => "home-" + Guid.NewGuid().ToString("N").Substring(0, 8); + public static void CleanupDir(string dir) { try @@ -138,6 +143,9 @@ public static bool WaitFor(Func condition, int timeoutMs = 10000) /// public class FileWatcherFilterAndArmingTests { + // Per-test watched-file stem: isolates this test from concurrent tests and parallel TFM hosts. + private readonly string _stem = FileWatcherTestSupport.NewStem(); + /// D1 + D2: a file compile of home + .heddle with the flag on watches /// home.heddle (not the postfix-less home of the old bug) and is armed. [Fact] @@ -146,13 +154,13 @@ public void FilterEqualsTemplateNamePlusPostfix() var dir = FileWatcherTestSupport.NewTempDir(); try { - File.WriteAllText(Path.Combine(dir, "home.heddle"), "HELLO"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + File.WriteAllText(Path.Combine(dir, _stem + ".heddle"), "HELLO"); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); var watcher = FileWatcherTestSupport.GetWatcher(template); Assert.NotNull(watcher); - Assert.Equal("home.heddle", watcher.Filter); + Assert.Equal(_stem + ".heddle", watcher.Filter); Assert.True(watcher.EnableRaisingEvents, "the watcher must be armed (EnableRaisingEvents)"); } finally @@ -177,8 +185,8 @@ public void FlagOffFileCompileInstallsNoWatcher() var dir = FileWatcherTestSupport.NewTempDir(); try { - File.WriteAllText(Path.Combine(dir, "home.heddle"), "HELLO"); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); + File.WriteAllText(Path.Combine(dir, _stem + ".heddle"), "HELLO"); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); options.EnableFileChangeCheck = false; using var template = new HeddleTemplate(options); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); diff --git a/src/Heddle.Tests/FileWatcherReloadTests.cs b/src/Heddle.Tests/FileWatcherReloadTests.cs index 396d36ec..380c3d6b 100644 --- a/src/Heddle.Tests/FileWatcherReloadTests.cs +++ b/src/Heddle.Tests/FileWatcherReloadTests.cs @@ -28,6 +28,10 @@ public class FileWatcherConcreteModel /// public class FileWatcherReloadTests { + // xunit news up the class per test: every test watches its own uniquely named file, isolating it + // from concurrent tests and from the other TFMs' test hosts running the same suite in parallel. + private readonly string _stem = FileWatcherTestSupport.NewStem(); + private static string WriteFile(string dir, string stem, string content) { var path = Path.Combine(dir, stem + ".heddle"); @@ -44,8 +48,8 @@ public void ArmedWatcherRaisesOnFileChangedAndRecompiles() var dir = FileWatcherTestSupport.NewTempDir(); try { - var path = WriteFile(dir, "home", "ONE"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + var path = WriteFile(dir, _stem, "ONE"); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("ONE", template.Generate(null)); @@ -76,8 +80,8 @@ public void SuccessiveEditsEachRenderFromScratchEquivalentFinalizedDocument() var dir = FileWatcherTestSupport.NewTempDir(); try { - WriteFile(dir, "home", "@(@ 1 + 1 )"); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); + WriteFile(dir, _stem, "@(@ 1 + 1 )"); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); options.ExpressionMode = ExpressionMode.FullCSharp; using var template = new HeddleTemplate(options); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); @@ -86,8 +90,8 @@ public void SuccessiveEditsEachRenderFromScratchEquivalentFinalizedDocument() foreach (var edit in new[] { "@(@ 2 * 3 )", "@(@ 10 - 4 )", "@(@ 7 + 1 )" }) { - WriteFile(dir, "home", edit); - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, edit); + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); using var fresh = new HeddleTemplate(edit, @@ -111,8 +115,8 @@ public void RecompiledDocumentHonorsOriginalOptions() var dir = FileWatcherTestSupport.NewTempDir(); try { - WriteFile(dir, "home", "@(@ 1 + 1 )|@()"); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); + WriteFile(dir, _stem, "@(@ 1 + 1 )|@()"); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); options.ExpressionMode = ExpressionMode.FullCSharp; options.Encoder = HtmlEncoder.Create(UnicodeRanges.All); options.RenderBudget = new RenderBudget { MaxOutputChars = 32 }; @@ -121,8 +125,8 @@ public void RecompiledDocumentHonorsOriginalOptions() FileWatcherTestSupport.Disarm(template); Assert.Equal("2|'", template.Generate("'")); // modern encoder: ' → ' (WebUtility yields ') - WriteFile(dir, "home", "@(@ 40 + 2 )|@()"); - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, "@(@ 40 + 2 )|@()"); + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("42|'", template.Generate("'")); @@ -145,15 +149,15 @@ public void ProfileDirectiveEditReloadsFromScratchEquivalent() var dir = FileWatcherTestSupport.NewTempDir(); try { - WriteFile(dir, "home", "@profile(){{text}}\n@()"); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); // OutputProfile default: Html + WriteFile(dir, _stem, "@profile(){{text}}\n@()"); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); // OutputProfile default: Html using var template = new HeddleTemplate(options); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); Assert.Equal("", template.Generate("")); // directive flips the first compile to Text - WriteFile(dir, "home", "@()"); // directive removed - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, "@()"); // directive removed + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); using (var fresh = new HeddleTemplate("@()", new CompileContext(new TemplateOptions(options)))) { @@ -161,8 +165,8 @@ public void ProfileDirectiveEditReloadsFromScratchEquivalent() } Assert.Equal("<b>", template.Generate("")); // options default (Html), not the stale Text - WriteFile(dir, "home", "@profile(){{text}}\n@()"); // directive re-added - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, "@profile(){{text}}\n@()"); // directive re-added + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("", template.Generate("")); } @@ -184,22 +188,22 @@ public void ModelDirectiveEditReloadsFromScratchEquivalent() var dir = FileWatcherTestSupport.NewTempDir(); try { - WriteFile(dir, "home", "@model(){{FileWatcherConcreteModel}}\n@(Name)"); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); // default object model + WriteFile(dir, _stem, "@model(){{FileWatcherConcreteModel}}\n@(Name)"); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); // default object model using var template = new HeddleTemplate(options); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); Assert.Equal("n1", template.Generate(new FileWatcherConcreteModel { Name = "n1" })); - WriteFile(dir, "home", "@()"); // @model removed → back to the original object model - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, "@()"); // @model removed → back to the original object model + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); // A string payload is NOT a FileWatcherConcreteModel: this render succeeds only because the // reload seeded the pre-@model original (object), like a from-scratch compile of "@()". Assert.Equal("payload", template.Generate("payload")); - WriteFile(dir, "home", "@model(){{FileWatcherConcreteModel}}\n@(Name)"); // directive re-added - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, "@model(){{FileWatcherConcreteModel}}\n@(Name)"); // directive re-added + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("n2", template.Generate(new FileWatcherConcreteModel { Name = "n2" })); #if DEBUG @@ -221,15 +225,15 @@ public void RecompiledTypedTemplateRendersWithOriginalModel() var dir = FileWatcherTestSupport.NewTempDir(); try { - WriteFile(dir, "home", "@(Name)"); - var options = FileWatcherTestSupport.WatchOptions(dir, "home"); + WriteFile(dir, _stem, "@(Name)"); + var options = FileWatcherTestSupport.WatchOptions(dir, _stem); using var template = new HeddleTemplate(options, typeof(FileWatcherConcreteModel)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); Assert.Equal("a", template.Generate(new FileWatcherConcreteModel { Name = "a" })); - WriteFile(dir, "home", "X:@(Name)"); - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + WriteFile(dir, _stem, "X:@(Name)"); + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("X:b", template.Generate(new FileWatcherConcreteModel { Name = "b" })); } @@ -248,12 +252,12 @@ public void AtomicSaveViaTempThenRenameRecompiles() var dir = FileWatcherTestSupport.NewTempDir(); try { - var target = WriteFile(dir, "home", "ONE"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + var target = WriteFile(dir, _stem, "ONE"); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("ONE", template.Generate(null)); - var temp = Path.Combine(dir, "home.heddle.tmp"); + var temp = Path.Combine(dir, _stem + ".heddle.tmp"); File.WriteAllText(temp, "TWO"); FileWatcherTestSupport.RetryIO(() => File.Delete(target)); FileWatcherTestSupport.RetryIO(() => File.Move(temp, target)); // rename lands the new content ON the watched name @@ -275,8 +279,8 @@ public void DeleteRaisesOnFileDeletedAndKeepsLastGood() var dir = FileWatcherTestSupport.NewTempDir(); try { - var path = WriteFile(dir, "home", "GOOD"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + var path = WriteFile(dir, _stem, "GOOD"); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); var lastGoodResult = template.CompileResult; @@ -286,7 +290,7 @@ public void DeleteRaisesOnFileDeletedAndKeepsLastGood() template.OnFileDeleted += (s, e) => { observedSender = s; deleted++; }; File.Delete(path); - FileWatcherTestSupport.InvokeDeleted(template, dir, "home.heddle"); + FileWatcherTestSupport.InvokeDeleted(template, dir, _stem + ".heddle"); Assert.Equal(1, deleted); Assert.Same(template, observedSender); @@ -308,8 +312,8 @@ public void EmptyOrWhitespaceSaveIsNoOpAndKeepsLastGood() var dir = FileWatcherTestSupport.NewTempDir(); try { - var path = WriteFile(dir, "home", "GOOD"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + var path = WriteFile(dir, _stem, "GOOD"); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); var lastGoodResult = template.CompileResult; @@ -320,7 +324,7 @@ public void EmptyOrWhitespaceSaveIsNoOpAndKeepsLastGood() foreach (var truncated in new[] { string.Empty, " \t\r\n " }) { File.WriteAllText(path, truncated); - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); Assert.Same(lastGoodResult, template.CompileResult); // no recompile, no failure surfaced Assert.Equal("GOOD", template.Generate(null)); } @@ -341,8 +345,8 @@ public void ScriptedEditDeleteRenameRaiseCorrectEvents() var dir = FileWatcherTestSupport.NewTempDir(); try { - var target = WriteFile(dir, "home", "ONE"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + var target = WriteFile(dir, _stem, "ONE"); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); int changed = 0, deletedCount = 0, renamed = 0; diff --git a/src/Heddle.Tests/FileWatcherSwapConcurrencyTests.cs b/src/Heddle.Tests/FileWatcherSwapConcurrencyTests.cs index 4eaac3ff..125ce64d 100644 --- a/src/Heddle.Tests/FileWatcherSwapConcurrencyTests.cs +++ b/src/Heddle.Tests/FileWatcherSwapConcurrencyTests.cs @@ -20,6 +20,9 @@ namespace Heddle.Tests /// public class FileWatcherSwapConcurrencyTests { + // Per-test watched-file stem: isolates this test from concurrent tests and parallel TFM hosts. + private readonly string _stem = FileWatcherTestSupport.NewStem(); + private static void AssertQueueDrained(HeddleTemplate template) { var queue = FileWatcherTestSupport.GetSupersededQueue(template); @@ -188,9 +191,9 @@ public void PublishAfterTeardownDisposesNewArtifactAndDoesNotResurrect() var dir = FileWatcherTestSupport.NewTempDir(); try { - var path = Path.Combine(dir, "home.heddle"); + var path = Path.Combine(dir, _stem + ".heddle"); File.WriteAllText(path, "LIVE@p1witness()"); - var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); var liveDoc = FileWatcherTestSupport.GetRuntimeDocument(template); @@ -200,7 +203,7 @@ public void PublishAfterTeardownDisposesNewArtifactAndDoesNotResurrect() DisposalWitnessExtension.Reset(); File.WriteAllText(path, "ZOMBIE@p1witness()"); - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); // late callback + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); // late callback // The fresh artifact was disposed by the store block's guard (its witness fired) … Assert.Equal(DisposalWitnessExtension.CreatedCount, DisposalWitnessExtension.DisposedCount); @@ -226,15 +229,15 @@ public void SupersededDocumentIsDisposedAfterReloadWhenIdle() var dir = FileWatcherTestSupport.NewTempDir(); try { - var path = Path.Combine(dir, "home.heddle"); + var path = Path.Combine(dir, _stem + ".heddle"); File.WriteAllText(path, "ONE@p1witness()"); - using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, "home")); + using var template = new HeddleTemplate(FileWatcherTestSupport.WatchOptions(dir, _stem)); Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); FileWatcherTestSupport.Disarm(template); DisposalWitnessExtension.Reset(); File.WriteAllText(path, "TWO@p1witness()"); - FileWatcherTestSupport.InvokeChanged(template, dir, "home.heddle"); // idle reload + FileWatcherTestSupport.InvokeChanged(template, dir, _stem + ".heddle"); // idle reload Assert.True(template.CompileResult.Success, template.CompileResult.ToString()); Assert.Equal("TWO", template.Generate(null)); diff --git a/src/Heddle/HeddleTemplate.cs b/src/Heddle/HeddleTemplate.cs index a31dabac..b19c014f 100644 --- a/src/Heddle/HeddleTemplate.cs +++ b/src/Heddle/HeddleTemplate.cs @@ -170,12 +170,18 @@ private void Teardown() { if (Interlocked.CompareExchange(ref _teardownDone, 1, 0) != 0) return; // already torn down — idempotent across Dispose / deferred exit / finalizer + // The watcher is disposed OUTSIDE _publishGate: on .NET Framework, FileSystemWatcher.Dispose + // blocks on the component lock its event dispatch holds while a callback (Reload → Compile) + // waits on _publishGate — nesting the two locks deadlocks (netfx-only; .NET Core+ dispatch is + // lock-free). Safe unnested: Dispose() set _disposeAfterComplete before Teardown, so a callback + // acquiring the gate from here on discards its fresh artifact via the store block's guard, and + // once this Dispose returns no further callback can start. + _watcher?.Dispose(); // Phase 1 D5: the gate makes teardown disposal and the store block's post-Teardown guard mutually // exclusive — a late watcher callback either observes the dispose flag and discards its fresh // artifact, or publishes fully before this disposal runs (never a leak, never a resurrection). lock (_publishGate) { - _watcher?.Dispose(); _runtimeDocument?.Dispose(); // Drain-dispose the remaining superseded docs. Teardown never runs while a render executes // (Phase 4 proof), so DrainLocked's _runners gate reads 0 here and the queue empties. @@ -565,7 +571,7 @@ private void Reload() string document = null; try { - document = _reader.ReadEntireFile(); + document = ReadForReload(); if (!string.IsNullOrWhiteSpace(document)) CompileResult = Compile(new CompileScope( new CompileContext(_watchOptions, _watchModelType) @@ -581,6 +587,25 @@ private void Reload() } } + // A watcher event is one-shot: if this read fails, no further event arrives to retry it, so a + // transient sharing violation (the saving editor, an antivirus/indexer scan of a freshly renamed + // file) would leave last-good published forever. Ride out that window briefly before giving up; + // a persistent failure still surfaces on CompileResult via Reload's catch. + private string ReadForReload() + { + for (var attempt = 0; ; attempt++) + { + try + { + return _reader.ReadEntireFile(); + } + catch (Exception e) when (attempt < 10 && e.InnerException is IOException) + { + Thread.Sleep(25); + } + } + } + public static void Configure(Assembly startupAssembly) { AssemblyHelper.Configure(startupAssembly);