diff --git a/Projects/Server.Tests/Tests/Serialization/SerializationThreadWorkerHandshakeTests.cs b/Projects/Server.Tests/Tests/Serialization/SerializationThreadWorkerHandshakeTests.cs new file mode 100644 index 000000000..58b7f4982 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/SerializationThreadWorkerHandshakeTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class SerializationThreadWorkerHandshakeTests +{ + // Regression: the pause handshake cleared _pause and checked the exit flag AFTER + // signaling _stopEvent. Wake/Sleep/Exit all run on the owning thread, so an Exit() + // issued the moment a Sleep() returned could either be clobbered (worker spins + // forever) or orphaned (worker returns without servicing Exit's Sleep) — a silent + // deadlock. Churn the full lifecycle with the racy back-to-back Sleep/Exit pattern; + // the watchdog turns a reintroduced deadlock into a failure instead of a hung run. + [Fact] + public void WakeSleepExitChurn_NeverDeadlocks() + { + Exception failure = null; + var done = new ManualResetEventSlim(); + + var churn = new Thread(() => + { + try + { + for (var i = 0; i < 2000; i++) + { + var source = new SerializationChunkSource(); + var worker = new SerializationThreadWorker(0, source); + worker.AllocateHeap(); + + worker.Wake(); + worker.Sleep(); + worker.Exit(); // Immediately after Sleep returns — the racy window. + } + } + catch (Exception e) + { + failure = e; + } + finally + { + done.Set(); + } + }) + { + IsBackground = true, + Name = "Handshake Churn" + }; + + churn.Start(); + + Assert.True( + done.Wait(TimeSpan.FromMinutes(2)), + "Worker pause/exit handshake deadlocked (owner blocked in Sleep or worker spinning)." + ); + Assert.Null(failure); + } +} diff --git a/Projects/Server/Serialization/SerializationThreadWorker.cs b/Projects/Server/Serialization/SerializationThreadWorker.cs index 8fe78911d..d936213c4 100644 --- a/Projects/Server/Serialization/SerializationThreadWorker.cs +++ b/Projects/Server/Serialization/SerializationThreadWorker.cs @@ -270,10 +270,18 @@ public class SerializationThreadWorker writer.Close(); - worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished - worker._pause = false; + // Wake/Sleep/Exit all run on the owning thread, so the moment _stopEvent is set + // the owner may start another pause cycle (Exit does exactly that). Clear _pause + // and sample the exit condition BEFORE signaling: clearing after the signal can + // clobber the next cycle's pause request (this thread then spins forever waiting + // for a pause that never reads true), and deciding to exit after the signal can + // return without servicing that cycle's Sleep (the owner then blocks forever). + var exiting = Core.Closing || worker._exit; + Volatile.Write(ref worker._pause, false); - if (Core.Closing || worker._exit) + worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished + + if (exiting) { return; }