Skip to content

Commit 63cf1c7

Browse files
authored
Merge pull request #262 from winnerspiros/copilot/fix-black-screen-issue-acb0fbe0-a5b9-416c-857c-dd090feb0404
Fix CS1513 in LogManagement and double-dispose race on cold-start timers
2 parents 0135c89 + 5b7614a commit 63cf1c7

3 files changed

Lines changed: 266 additions & 21 deletions

File tree

osu.Android/LogManagement.cs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,161 @@ public static void NormaliseFrameworkIniExecutionMode()
238238
}
239239
}
240240

241+
// Sentinel file dropped after the one-shot Renderer-default migration has
242+
// run. Stored in the storage root next to framework.ini so a single
243+
// existence check governs whether we should respect the user's currently
244+
// persisted Renderer choice (sentinel present) or perform the one-time
245+
// Automatic→OpenGL nudge (sentinel absent).
246+
private const string renderer_migration_sentinel = "android_renderer_default_migrated.flag";
247+
248+
/// <summary>
249+
/// One-shot migration that flips the framework default <c>Renderer</c>
250+
/// choice from <c>Automatic</c> (which resolves to Vulkan on Android,
251+
/// requiring runtime SPIR-V compilation via glslang) to <c>OpenGL</c>
252+
/// (which uses the Adreno driver's native GLSL compiler — no glslang,
253+
/// no SPIR-V, and therefore no shader-compile burst on Toolbar load).
254+
///
255+
/// <para>
256+
/// Why this matters: every recent black-screen ANR fingerprint in the
257+
/// field tombstones (PIDs 27798 / 29226 / 499) shows a Veldrid worker
258+
/// stuck inside <c>glslang::TParseContext::executeInitializer</c> /
259+
/// <c>TShader::parse</c> at <c>nice=-10</c> on a big core, monopolising
260+
/// the CPU during the Toolbar texture-upload burst and starving the
261+
/// Update thread past the 10-second MotionEvent ANR deadline. Switching
262+
/// the default away from the Vulkan-via-glslang path eliminates the
263+
/// entire failure class on stock installs. Users who specifically want
264+
/// Vulkan can still select it from Settings → Graphics → Renderer; the
265+
/// migration only nudges the *default* and is recorded by an on-disk
266+
/// sentinel so subsequent launches never overwrite an explicit choice.
267+
/// </para>
268+
///
269+
/// <para>
270+
/// Best-effort and never throws — if the file is missing or the rewrite
271+
/// fails, startup proceeds with the existing value. Must be invoked from
272+
/// <c>OsuGameActivity.OnCreate</c> BEFORE the framework reads
273+
/// framework.ini, alongside the existing
274+
/// <see cref="NormaliseFrameworkIniExecutionMode"/> hook.
275+
/// </para>
276+
/// </summary>
277+
public static void NormaliseFrameworkIniRendererDefault()
278+
{
279+
try
280+
{
281+
string? root = resolveStorageRoot();
282+
if (root == null) return;
283+
284+
string sentinelPath = Path.Combine(root, renderer_migration_sentinel);
285+
if (File.Exists(sentinelPath)) return;
286+
287+
string iniPath = Path.Combine(root, "framework.ini");
288+
289+
if (!File.Exists(iniPath))
290+
{
291+
// Brand-new install: no framework.ini yet. Pre-create a minimal
292+
// file with just the Renderer line set; the framework will fill
293+
// in its other defaults on first save.
294+
try
295+
{
296+
File.WriteAllText(iniPath, "Renderer = OpenGL" + System.Environment.NewLine);
297+
tryDropSentinel(sentinelPath);
298+
}
299+
catch (Exception e)
300+
{
301+
Debug.WriteLine($"[osu!] LogManagement: could not pre-create framework.ini: {e.Message}");
302+
}
303+
return;
304+
}
305+
306+
string[] lines;
307+
308+
try
309+
{
310+
lines = File.ReadAllLines(iniPath);
311+
}
312+
catch (Exception e)
313+
{
314+
Debug.WriteLine($"[osu!] LogManagement: could not read framework.ini for renderer migration: {e.Message}");
315+
return;
316+
}
317+
318+
bool changed = false;
319+
bool seenRendererLine = false;
320+
321+
for (int i = 0; i < lines.Length; i++)
322+
{
323+
string line = lines[i];
324+
int eq = line.IndexOf('=');
325+
if (eq <= 0) continue;
326+
327+
string key = line.Substring(0, eq).Trim();
328+
string value = line.Substring(eq + 1).Trim();
329+
330+
if (!string.Equals(key, "Renderer", StringComparison.Ordinal))
331+
continue;
332+
333+
seenRendererLine = true;
334+
335+
// Only nudge the default. If the user has explicitly chosen
336+
// Vulkan / OpenGLLegacy / Direct3D11 / Metal / Deferred, leave
337+
// it alone — the migration's job is to change the *default*,
338+
// not overwrite intent.
339+
if (string.Equals(value, "Automatic", StringComparison.Ordinal))
340+
{
341+
lines[i] = "Renderer = OpenGL";
342+
changed = true;
343+
}
344+
345+
break;
346+
}
347+
348+
if (!seenRendererLine)
349+
{
350+
// No Renderer line at all — append one at the end of the file.
351+
var newLines = new string[lines.Length + 1];
352+
Array.Copy(lines, newLines, lines.Length);
353+
newLines[lines.Length] = "Renderer = OpenGL";
354+
lines = newLines;
355+
changed = true;
356+
}
357+
358+
if (changed)
359+
{
360+
try
361+
{
362+
File.WriteAllLines(iniPath, lines);
363+
Logger.Log("[osu!] Android first-launch Renderer-default migration: Automatic → OpenGL", LoggingTarget.Performance);
364+
}
365+
catch (Exception e)
366+
{
367+
Debug.WriteLine($"[osu!] LogManagement: could not rewrite framework.ini for renderer migration: {e.Message}");
368+
return;
369+
}
370+
}
371+
372+
// Drop sentinel regardless of whether we changed anything: the
373+
// migration has now had its one chance to run, and any subsequent
374+
// user choice (including a deliberate "Automatic") must be
375+
// respected.
376+
tryDropSentinel(sentinelPath);
377+
}
378+
catch (Exception e)
379+
{
380+
Debug.WriteLine($"[osu!] LogManagement: NormaliseFrameworkIniRendererDefault failed: {e.Message}");
381+
}
382+
}
383+
384+
private static void tryDropSentinel(string sentinelPath)
385+
{
386+
try
387+
{
388+
File.WriteAllText(sentinelPath, string.Empty);
389+
}
390+
catch (Exception e)
391+
{
392+
Debug.WriteLine($"[osu!] LogManagement: could not write renderer-migration sentinel: {e.Message}");
393+
}
394+
}
395+
241396
private static string? resolveStorageRoot()
242397
{
243398
try

osu.Android/OsuGameActivity.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ protected override void OnCreate(Bundle? savedInstanceState)
107107
// OnCreate so any crash from this point onward lands in `native_crash.log`.
108108
CrashDiagnostics.InstallNativeHandler(this);
109109
CrashDiagnostics.InstallManagedExceptionHooks();
110+
111+
// Mirror the PREVIOUS session's internal native_crash.log into the external
112+
// copy, then truncate the internal file BEFORE we write any markers for the
113+
// current session. Doing this earlier (it used to run after the first three
114+
// WriteAliveMarker / WriteInstallState calls) caused those three early lines
115+
// to appear duplicated on disk: they were written directly to both
116+
// internal+external, then the mirror appended the internal copy onto
117+
// external, doubling them. Field native_crash.log files confirm this
118+
// (Activity.OnCreate entry / INSTALL_STATE / StartNativeWatchdog all appear
119+
// twice with identical timestamps, the rest of the file singly). Running
120+
// the mirror first folds in last session's content cleanly and lets all
121+
// current-session markers land exactly once in each file.
122+
CrashDiagnostics.MirrorInternalLogToExternal();
123+
110124
CrashDiagnostics.WriteAliveMarker("Activity.OnCreate entry");
111125
CrashDiagnostics.WriteInstallState();
112126
// Arm the native pthread liveness watchdog as the very next thing,
@@ -119,7 +133,6 @@ protected override void OnCreate(Bundle? savedInstanceState)
119133
// monitor) is parked in __rt_sigsuspend during a stuck GC. 10s
120134
// threshold matches the Android system-server's own ANR window.
121135
CrashDiagnostics.StartNativeWatchdog(10);
122-
CrashDiagnostics.MirrorInternalLogToExternal();
123136

124137
// Crash-loop safe-mode latch. If the previous process died (ANR / native
125138
// crash / OOM kill) before reaching the post-LoadComplete clear point,
@@ -176,6 +189,16 @@ protected override void OnCreate(Bundle? savedInstanceState)
176189
LogManagement.NormaliseFrameworkIniExecutionMode();
177190
CrashDiagnostics.WriteAliveMarker("LogManagement.NormaliseFrameworkIniExecutionMode (returned)");
178191

192+
// One-shot Renderer-default migration: Automatic → OpenGL on Android.
193+
// Eliminates the Veldrid glslang/SPIR-V shader-compile burst that has
194+
// been the proximate cause of the recurring Toolbar-time MotionEvent
195+
// ANR on Adreno devices. User can still pick Vulkan from
196+
// Settings → Graphics → Renderer; the migration only nudges the
197+
// default and never re-runs (governed by an on-disk sentinel).
198+
CrashDiagnostics.WriteAliveMarker("LogManagement.NormaliseFrameworkIniRendererDefault (about to start)");
199+
LogManagement.NormaliseFrameworkIniRendererDefault();
200+
CrashDiagnostics.WriteAliveMarker("LogManagement.NormaliseFrameworkIniRendererDefault (returned)");
201+
179202
CrashDiagnostics.WriteAliveMarker("LogManagement.WipeShaderCacheOnceForVersion (about to start)");
180203
LogManagement.WipeShaderCacheOnceForVersion();
181204
CrashDiagnostics.WriteAliveMarker("LogManagement.WipeShaderCacheOnceForVersion (returned)");

osu.Android/OsuGameAndroid.cs

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ public partial class OsuGameAndroid : OsuGame
9595
private Delegate? activeMixersHandler;
9696
private object? activeMixersList;
9797

98+
// Cold-start safety nets that MUST keep firing even if the Update thread
99+
// stalls on a Veldrid glslang shader-compile burst. Held as fields so the
100+
// .NET threadpool kernel timer keeps the underlying ManagedTimerHolder
101+
// alive (a System.Threading.Timer with no live root is eligible for GC).
102+
// See LoadComplete for the rationale (Scheduler.AddDelayed runs on the
103+
// Update thread and therefore cannot be relied on to fire the very
104+
// safety nets that exist to unblock that thread).
105+
private System.Threading.Timer? coldStartTamingTimer;
106+
private System.Threading.Timer? clearStartupSentinelTimer;
107+
98108
// Set true by the deferred SelectHighestRefreshRate call in LoadComplete; gates
99109
// any earlier OnConfigurationChanged-driven SelectHighestRefreshRate() invocations
100110
// out of the cold-start swapchain bring-up window. See SelectHighestRefreshRate.
@@ -495,9 +505,19 @@ protected override void LoadComplete()
495505
// on the first CompileGlslToSpirv call, which happens mid-Toolbar-load
496506
// (i.e. after the synchronous taming pass above has already run). Without
497507
// these follow-up passes, the newly-spawned worker inherits nice=-10
498-
// from its parent and reproduces the starvation pattern. The chosen
499-
// timestamps straddle the observed "Texture upload queue is large (100/
500-
// 200/300)" events in the field runtime logs.
508+
// from its parent and reproduces the starvation pattern.
509+
//
510+
// CRITICAL: these passes MUST run on the .NET threadpool (System.Threading.Timer),
511+
// NOT on Scheduler.AddDelayed. Scheduler runs on the Update thread, which is
512+
// exactly what we're trying to unblock — if the glslang worker has already
513+
// started monopolising a big core at nice=-10 by the time the first deferred
514+
// Scheduler tick is due, the Update thread is already starved and the tick
515+
// never fires. Field tombstones (PIDs 27798/29226/499) confirm this: the +0
516+
// and +500ms taming passes logged, but +1500/+3500ms never did, while a
517+
// glslang worker remained at nice=-10 producing the 10s MotionEvent ANR.
518+
// A kernel-managed Timer fires from the threadpool regardless of game-thread
519+
// health, so the just-spawned worker is reliably caught and demoted within
520+
// one tick (250 ms) of being created.
501521
try
502522
{
503523
int coreCount = System.Environment.ProcessorCount;
@@ -513,27 +533,37 @@ protected override void LoadComplete()
513533
if (deferredLittleMask == 0) deferredLittleMask = totalMask;
514534
}
515535

516-
foreach (int delayMs in new[] { 500, 1500, 3500 })
536+
int capturedMask = deferredLittleMask;
537+
int tickCount = 0;
538+
// Tick every 250 ms, give up after ~8 s — long enough to cover the entire
539+
// observed Toolbar shader-compile burst window (mid-load through drain).
540+
const int tick_period_ms = 250;
541+
const int max_ticks = 32;
542+
543+
coldStartTamingTimer = new System.Threading.Timer(_ =>
517544
{
518-
int dm = delayMs;
519-
Scheduler.AddDelayed(() =>
545+
try
520546
{
521-
try
522-
{
523-
int demoted = AndroidNativeBridgeManager.TameBackgroundThreads(deferredLittleMask);
524-
if (demoted > 0)
525-
Logger.Log($"[osu!] Tamed {demoted} background worker thread(s) at +{dm}ms", LoggingTarget.Performance);
526-
}
527-
catch (Exception e)
528-
{
529-
Debug.WriteLine($"[osu!] Deferred TameBackgroundThreads(+{dm}ms) failed: {e.Message}");
530-
}
531-
}, delayMs);
532-
}
547+
int demoted = AndroidNativeBridgeManager.TameBackgroundThreads(capturedMask);
548+
if (demoted > 0)
549+
Logger.Log($"[osu!] Tamed {demoted} background worker thread(s) (timer tick {tickCount + 1})", LoggingTarget.Performance);
550+
}
551+
catch (Exception e)
552+
{
553+
Debug.WriteLine($"[osu!] Deferred TameBackgroundThreads (timer) failed: {e.Message}");
554+
}
555+
556+
if (System.Threading.Interlocked.Increment(ref tickCount) >= max_ticks)
557+
{
558+
var t = System.Threading.Interlocked.Exchange(ref coldStartTamingTimer, null);
559+
try { t?.Dispose(); }
560+
catch { /* ignore */ }
561+
}
562+
}, state: null, dueTime: tick_period_ms, period: tick_period_ms);
533563
}
534564
catch (Exception e)
535565
{
536-
Debug.WriteLine($"[osu!] Failed to schedule deferred TameBackgroundThreads passes: {e.Message}");
566+
Debug.WriteLine($"[osu!] Failed to schedule deferred TameBackgroundThreads timer: {e.Message}");
537567
}
538568

539569
Scheduler.AddDelayed(() =>
@@ -634,7 +664,36 @@ protected override void LoadComplete()
634664
// before the user could reasonably trigger a manual restart. If the
635665
// process dies before this fires (ANR, native crash, OOM kill), the
636666
// sentinel persists and the next launch enters safe-mode.
637-
Scheduler.AddDelayed(AndroidStartupSafeMode.ClearStartupInProgress, 10_000);
667+
//
668+
// Fired from a kernel-managed System.Threading.Timer rather than
669+
// Scheduler.AddDelayed: the same Update-thread stall that caused the
670+
// Toolbar shader-compile ANR also prevents Scheduler.AddDelayed from
671+
// firing the sentinel-clear, leaving safe-mode latched forever and
672+
// every relaunch hitting the identical wall (confirmed by all three
673+
// field tombstones — 27798 / 29226 / 499 — starting with "CPU affinity
674+
// pinning skipped (safe-mode active)"). The threadpool tick is immune
675+
// to game-thread starvation, so the sentinel reliably clears whenever
676+
// the activity-main thread (and therefore the process) survives the
677+
// deadline, breaking the perpetual-safe-mode loop.
678+
try
679+
{
680+
clearStartupSentinelTimer = new System.Threading.Timer(_ =>
681+
{
682+
try { AndroidStartupSafeMode.ClearStartupInProgress(); }
683+
catch (Exception e)
684+
{
685+
Debug.WriteLine($"[osu!] ClearStartupInProgress (timer) failed: {e.Message}");
686+
}
687+
688+
var ct = System.Threading.Interlocked.Exchange(ref clearStartupSentinelTimer, null);
689+
try { ct?.Dispose(); }
690+
catch { /* ignore */ }
691+
}, state: null, dueTime: 10_000, period: System.Threading.Timeout.Infinite);
692+
}
693+
catch (Exception e)
694+
{
695+
Debug.WriteLine($"[osu!] Failed to schedule ClearStartupInProgress timer: {e.Message}");
696+
}
638697

639698
// Cold-start heartbeat instrumentation. For the first 15 s after LoadComplete
640699
// we emit per-second ALIVE markers from BOTH the Update thread and the Draw
@@ -1465,6 +1524,14 @@ protected override void Dispose(bool isDisposing)
14651524
highPerformanceSession = null;
14661525
dexPerformanceSession?.Dispose();
14671526
dexPerformanceSession = null;
1527+
1528+
var cst = System.Threading.Interlocked.Exchange(ref coldStartTamingTimer, null);
1529+
try { cst?.Dispose(); }
1530+
catch { /* ignore */ }
1531+
1532+
var sst = System.Threading.Interlocked.Exchange(ref clearStartupSentinelTimer, null);
1533+
try { sst?.Dispose(); }
1534+
catch { /* ignore */ }
14681535
}
14691536
}
14701537

0 commit comments

Comments
 (0)