From a73e424a27a3d9cdca00fb1be179da2a9cee3f37 Mon Sep 17 00:00:00 2001 From: kuba Date: Wed, 7 Oct 2026 11:02:17 +0200 Subject: [PATCH] [macOS] Fix the Rosetta GC wedge, a CPU-flip buffer-wait deadlock and frozen host movies (#1051) * [CLI] Restart under Rosetta with a 64 MiB gen0 GC budget Every collection suspends the runtime through FlushProcessWriteBuffers, which Rosetta 2 services by parking each thread in its runtime (notify_thread_waiters). A signal that reaches a parked thread trips Rosetta's "expected saved LR to be in translated code" assertion and wedges the process; Demon's Souls hit it in about half of its 3-5 minute runs while collecting almost twice a second. The gen0 budget is only read from the environment, so the process re-executes itself once with DOTNET_GCgen0size set (same pid). With 64 MiB, Demon's Souls collects about once every 6 seconds, gen0 and gen1 only, 0.26 s of pauses in 110 s. A 512 MiB budget made every collection a blocking gen1/gen2 pass of about a second. SHARPEMU_ROSETTA_GC_BUDGET=0 opts out. * [VideoOut] Let a buffer wait ignore CPU flips the graphics queue has not reached sceVideoOutSubmitFlip reserves its flip request, may sleep for pacing, and only then queues the flip preparation on the graphics queue. A command buffer submitted before it that starts with sceAgcDcbWaitUntilSafeForRendering on the same display buffer saw the reserved request as pending and waited for a preparation queued behind itself: the graphics queue never advanced and every compute queue waiting on its labels stalled with it. Demon's Souls hung this way on the boot splash in about one run in three. A CPU flip now counts for the wait only once the graphics queue runs its preparation; GPU flips are ordered from their packet. (cherry picked from commit 8915df35abad5fe8b15cc6418f1e563785d6e008) * [Media] Expire a host movie only once its playback stops advancing Host playback follows the guest's own Bink decode and audio clock, so on a slow host a movie plays slower than real time. The watchdog fired at the movie's duration plus a minute regardless, marked the file timed out and forced EOF on the guest's reads while its decoder was still mid-stream: Demon's Souls' 171 s attract movie froze on its last host frame for good. The watchdog now re-arms while frames keep advancing and expires a movie only after a minute without progress. (cherry picked from commit daad0f097d0c6369097aeef32b55c9f2d171110a) * [CLI] Preserve the managed entry point when restarting under Rosetta --- src/SharpEmu.CLI/Program.cs | 1 + src/SharpEmu.CLI/RosettaGcBudget.cs | 100 ++++++++++++++++++ src/SharpEmu.Libs/Media/HostMovieBridge.cs | 14 +++ .../VideoOut/VideoOutExports.FlipRequests.cs | 21 +++- .../VulkanVideoPresenter.CommandStream.cs | 5 +- .../Cli/RosettaGcBudgetTests.cs | 26 +++++ .../SharpEmu.Libs.Tests.csproj | 4 + .../VideoOut/VideoOutFlipRequestTests.cs | 15 +++ 8 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/SharpEmu.CLI/RosettaGcBudget.cs create mode 100644 tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 197e9f3..2c88471 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -45,6 +45,7 @@ internal static partial class Program [STAThread] private static int Main(string[] args) { + RosettaGcBudget.ReexecIfNeeded(args); ConfigureManagedPluginResolution(); if (args.Length > 0 && args[0] == SharpEmu.Core.Diagnostics.WindowsCrashCapture.HelperArgument) diff --git a/src/SharpEmu.CLI/RosettaGcBudget.cs b/src/SharpEmu.CLI/RosettaGcBudget.cs new file mode 100644 index 0000000..6ad3af2 --- /dev/null +++ b/src/SharpEmu.CLI/RosettaGcBudget.cs @@ -0,0 +1,100 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections; +using System.Runtime.InteropServices; + +namespace SharpEmu.CLI; + +/// +/// Under Rosetta 2 every garbage collection suspends the runtime with +/// FlushProcessWriteBuffers, which reads each thread's registers through a Mach call that +/// Rosetta services by parking the thread in its runtime. A signal that reaches a parked +/// thread (guest memory tracking raises thousands per second) trips Rosetta's "expected saved +/// LR to be in translated code" assertion and wedges the process. With the default gen0 budget +/// the emulator collects about twice a second; a 64 MiB budget makes that rare. +/// +/// +/// The GC reads its gen0 budget only from the environment when the runtime starts, so the +/// process re-executes itself once with the variable set. execve keeps the process id. +/// +internal static class RosettaGcBudget +{ + private const string BudgetVariable = "DOTNET_GCgen0size"; + // 64 MiB: a larger budget turns every collection into a blocking gen1/gen2 pass of + // about a second (512 MiB measured 8 such pauses in 150 s of Demon's Souls). + private const string Budget = "0x4000000"; + private const string OptOutVariable = "SHARPEMU_ROSETTA_GC_BUDGET"; + + public static void ReexecIfNeeded(string[] args) + { + if (!OperatingSystem.IsMacOS() || + RuntimeInformation.ProcessArchitecture != Architecture.X64 || + Environment.GetEnvironmentVariable(BudgetVariable) is not null || + Environment.GetEnvironmentVariable(OptOutVariable) == "0" || + !IsTranslated()) + { + return; + } + + var path = Environment.ProcessPath; + if (string.IsNullOrEmpty(path)) + { + return; + } + + var environment = new List(); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + environment.Add($"{entry.Key}={entry.Value}"); + } + environment.Add($"{BudgetVariable}={Budget}"); + + var argv = BuildArguments(path, Environment.GetCommandLineArgs()[0], args); + var nativeArguments = ToNullTerminated(argv); + var nativeEnvironment = ToNullTerminated(environment); + // Only returns on failure; the emulator then runs with the default budget. + _ = Execve(path, nativeArguments, nativeEnvironment); + var error = Marshal.GetLastPInvokeError(); + foreach (var pointer in nativeArguments) Marshal.FreeCoTaskMem(pointer); + foreach (var pointer in nativeEnvironment) Marshal.FreeCoTaskMem(pointer); + Console.Error.WriteLine( + $"[LOADER][WARN] Could not restart with a larger GC budget under Rosetta: errno={error}"); + } + + internal static string[] BuildArguments(string executable, string entryAssembly, string[] args) => + Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase) + ? [executable, entryAssembly, .. args] + : [executable, .. args]; + + private static bool IsTranslated() + { + var value = 0; + var size = (nint)sizeof(int); + return SysctlByName("sysctl.proc_translated", ref value, ref size, 0, 0) == 0 && value == 1; + } + + private static nint[] ToNullTerminated(IReadOnlyList values) + { + var pointers = new nint[values.Count + 1]; + for (var i = 0; i < values.Count; i++) + { + pointers[i] = Marshal.StringToCoTaskMemUTF8(values[i]); + } + return pointers; + } + + [DllImport("libc", EntryPoint = "execve", SetLastError = true)] + private static extern int Execve( + [MarshalAs(UnmanagedType.LPUTF8Str)] string path, + nint[] argv, + nint[] envp); + + [DllImport("libc", EntryPoint = "sysctlbyname")] + private static extern int SysctlByName( + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + ref int value, + ref nint size, + nint newValue, + nint newSize); +} diff --git a/src/SharpEmu.Libs/Media/HostMovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs index 27d7678..9873ee7 100644 --- a/src/SharpEmu.Libs/Media/HostMovieBridge.cs +++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs @@ -44,6 +44,8 @@ internal static class HostMovieBridge private static bool _frameBufferPresented; private static MediaFramePlayback? _playback; private static Timer? _playbackWatchdog; + private static long _watchdogFrameIndex = -1; + private static readonly TimeSpan StalledPlaybackTimeout = TimeSpan.FromSeconds(60); private static long _frameSerial; private static uint _presentationWidth = MaxHostVideoWidth; private static uint _presentationHeight = MaxHostVideoHeight; @@ -326,6 +328,7 @@ internal static class HostMovieBridge private static void ArmPlaybackWatchdogLocked(string hostPath) { _playbackWatchdog?.Dispose(); + _watchdogFrameIndex = -1; var timeout = TryReadBinkInfo(hostPath, out var info) ? GetPlaybackWatchdogTimeout(info) : TimeSpan.FromSeconds(150); @@ -340,6 +343,17 @@ internal static class HostMovieBridge return; } + // Playback follows the guest's own decode and audio clock, so a slow host plays a + // movie slower than real time. Expire only a movie that stopped advancing: forcing EOF + // under a guest decoder that is still mid-stream wedges it on the last frame. + var frameIndex = _playback?.PlaybackProgress.FrameIndex ?? -1; + if (_playback is not null && frameIndex > _watchdogFrameIndex) + { + _watchdogFrameIndex = frameIndex; + _playbackWatchdog?.Change(StalledPlaybackTimeout, Timeout.InfiniteTimeSpan); + return; + } + Console.Error.WriteLine( "[LOADER][WARN] Bink2 host watchdog expired for " + Path.GetFileName(path) + "; advancing to the next movie."); diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs index 1181350..375026b 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs @@ -37,6 +37,8 @@ public static partial class VideoOutExports public int FlipMode; public long FlipArg; public bool GpuQueued; + // In command-stream order: a GPU flip from its packet, a CPU flip once its preparation runs on the graphics queue. + public bool Ordered; public long? ReadyTimestamp; public ulong EventHint; public FlipEventRegistration[]? FlipEvents; @@ -128,6 +130,7 @@ public static partial class VideoOutExports FlipMode = flipMode, FlipArg = flipArg, GpuQueued = gpuQueued, + Ordered = gpuQueued, EventHint = SceVideoOutInternalEventFlip | ((unchecked((ulong)flipArg) & 0x0000_FFFF_FFFF_FFFFUL) << 16), FlipEventCount = port.FlipEvents.Count, @@ -296,14 +299,30 @@ public static partial class VideoOutExports } } + // The graphics queue reached the preparation of a CPU flip, so later waits on its buffer are ordered after it. + internal static void MarkFlipOrdered(ulong requestId) + { + lock (_stateGate) + { + if (_flipRequests.TryGetValue(requestId, out var request)) + { + request.Ordered = true; + } + } + } + // True when no flip still waits for the presenter on the buffer; a discarded or cancelled flip no longer does. + // A CPU flip counts only once the graphics queue has reached it: sceVideoOutSubmitFlip reserves the request + // (and may sleep for pacing) before queueing its preparation, so a command buffer submitted earlier must not + // wait for it; the preparation sits behind that command buffer and the wait would never end. internal static bool IsFlipDone(int handle, int bufferIndex) { lock (_stateGate) { foreach (var request in _flipRequests.Values) { - if (request.Handle == handle && request.BufferIndex == bufferIndex && request.Outcome == FlipOutcome.Pending) + if (request.Handle == handle && request.BufferIndex == bufferIndex && request.Ordered && + request.Outcome == FlipOutcome.Pending) { return false; } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs index b4ded39..02cdf51 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs @@ -474,8 +474,11 @@ internal static unsafe partial class VulkanVideoPresenter public bool IsFlipDone(int handle, int index) => VideoOutExports.IsFlipDone(handle, index); - public void PrepareCpuFlip(int handle, int index, ulong requestId) => + public void PrepareCpuFlip(int handle, int index, ulong requestId) + { + VideoOutExports.MarkFlipOrdered(requestId); CaptureFlip(handle, index, requestId, flipMode: 0, flipArg: 0); + } public void DrawIndexed(ulong submitId, in DrawIndexedArguments arguments) { diff --git a/tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs b/tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs new file mode 100644 index 0000000..52537c1 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs @@ -0,0 +1,26 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.CLI; +using Xunit; + +namespace SharpEmu.Libs.Tests.Cli; + +public sealed class RosettaGcBudgetTests +{ + [Fact] + public void DotnetRestartKeepsTheManagedEntryAssemblyAndGuestArguments() + { + Assert.Equal(new[] { "/runtime/dotnet", "/emulator/SharpEmu.CLI.dll", "--hdr=off", "/games/My Game/eboot.bin" }, + RosettaGcBudget.BuildArguments("/runtime/dotnet", "/emulator/SharpEmu.CLI.dll", + ["--hdr=off", "/games/My Game/eboot.bin"])); + } + + [Fact] + public void AppHostRestartDoesNotPassTheManagedAssemblyAsAGuestArgument() + { + Assert.Equal(new[] { "/emulator/SharpEmu.CLI", "--hdr=off", "/games/My Game/eboot.bin" }, + RosettaGcBudget.BuildArguments("/emulator/SharpEmu.CLI", "/emulator/SharpEmu.CLI.dll", + ["--hdr=off", "/games/My Game/eboot.bin"])); + } +} diff --git a/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj b/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj index 6694cda..95d063b 100644 --- a/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj +++ b/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj @@ -24,4 +24,8 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + + diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs index 3cb5b8a..c2c9fc5 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs @@ -414,6 +414,21 @@ public sealed class VideoOutFlipRequestTests : IDisposable Assert.Null(VideoOutExports.GetFlipOutcomeForTests(request)); } + // A CPU flip reserved before its preparation reaches the graphics queue must not hold back an earlier wait. + [Fact] + public void CpuFlip_BlocksBufferWaitsOnlyOnceTheGraphicsQueueReachesIt() + { + Assert.Equal(0, VideoOutExports.TryReserveFlipRequest(_handle, -1, 0, 13, gpuQueued: false, out var request)); + Assert.True(VideoOutExports.IsFlipDone(_handle, -1)); + + VideoOutExports.MarkFlipOrdered(request); + Assert.False(VideoOutExports.IsFlipDone(_handle, -1)); + + VideoOutExports.CompleteFlip(request); + VideoOutExports.MarkFlipPresented(request); + Assert.True(VideoOutExports.IsFlipDone(_handle, -1)); + } + // Update completion counters even if the presenter has already finished the frame. [Fact] public void PresentThenComplete_StillCountsTheFlip()