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);