From a688326784e983a3183b70fafa63bac9142c8324 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 03:00:35 +0900 Subject: [PATCH] Dispose stale shared Roslyn worker handles before restart Release the previously cached Process before starting its replacement so an idle worker exit cannot leak an operating-system handle. Add an injectable process starter and a process-free regression test that observes disposal without introducing Unity Test Runner blocking or background work. --- .../SharedRoslynCompilerWorkerHostTests.cs | 35 +++++++++++++++++++ .../SharedRoslynCompilerWorkerSession.cs | 19 +++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index 0eef762da5..e8355b815e 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -44,6 +44,41 @@ public void Shutdown_WhenWorkerWasNeverStarted_ShouldRemainIdempotent() Assert.DoesNotThrow(() => session.Shutdown(unusedWorkerDirectoryPath)); } + /// + /// Verifies replacing the cached worker releases the previously owned process handle. + /// + [Test] + public void StartProcessLocked_WhenReplacingCachedProcess_ShouldDisposePreviousHandle() + { + SharedRoslynCompilerWorkerSession session = new(); + Process previousProcess = new(); + bool previousProcessDisposed = false; + int startAttempt = 0; + previousProcess.Disposed += (sender, args) => previousProcessDisposed = true; + session.SwapProcessStarterForTests(startInfo => + { + startAttempt++; + return startAttempt == 1 ? previousProcess : null; + }); + ProcessStartInfo ignoredStartInfo = new(); + + try + { + bool firstStartSucceeded = session.ExecuteLocked( + () => session.StartProcessLocked(ignoredStartInfo)); + bool secondStartSucceeded = session.ExecuteLocked( + () => session.StartProcessLocked(ignoredStartInfo)); + + Assert.That(firstStartSucceeded, Is.True); + Assert.That(secondStartSucceeded, Is.False); + Assert.That(previousProcessDisposed, Is.True); + } + finally + { + previousProcess.Dispose(); + } + } + /// /// Verifies a pending compiler stream does not keep the bounded drain waiting. /// diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs index 0d9e4e6966..88a28b942e 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs @@ -16,6 +16,7 @@ internal sealed class SharedRoslynCompilerWorkerSession { private readonly object _syncRoot = new(); private Action _deleteWorkerDirectory = path => Directory.Delete(path, true); + private Func _startProcess = ProcessStartHelper.TryStart; private Action _sendCompileRequest = SendCompileRequestCore; private Func _compileWorkerAssemblyForTests; @@ -41,7 +42,13 @@ internal bool HasLiveProcessLocked() internal bool StartProcessLocked(ProcessStartInfo startInfo) { AssertLockHeld(); - _workerProcess = ProcessStartHelper.TryStart(startInfo); + // EnsureWorkerReady reaches this method only after the same lock observed no live process, + // so replacement releases the stale handle without retrying graceful shutdown. + Process previousProcess = _workerProcess; + _workerProcess = null; + previousProcess?.Dispose(); + + _workerProcess = _startProcess(startInfo); return _workerProcess != null; } @@ -149,6 +156,16 @@ internal Action SwapWorkerDirectoryDeleterForTests(Action delete return previous; } + internal Func SwapProcessStarterForTests( + Func starter) + { + Debug.Assert(starter != null, "starter must not be null"); + + Func previous = _startProcess; + _startProcess = starter; + return previous; + } + internal Action SwapCompileRequestSenderForTests(Action sender) { Debug.Assert(sender != null, "sender must not be null");