[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
This commit is contained in:
kuba 2026-10-07 11:02:17 +02:00 committed by GitHub
parent a0fd9dcb83
commit a73e424a27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 184 additions and 2 deletions

View File

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

View File

@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<string>();
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<string> 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);
}

View File

@ -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.");

View File

@ -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;
}

View File

@ -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)
{

View File

@ -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"]));
}
}

View File

@ -24,4 +24,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Compile Include="../../src/SharpEmu.CLI/RosettaGcBudget.cs" Link="Cli/RosettaGcBudget.cs" />
</ItemGroup>
</Project>

View File

@ -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()