Skip to content

Commit c4238b9

Browse files
authored
Merge pull request #261 from winnerspiros/copilot/fix-black-screen-crash-again
Android: tame Mono worker-thread priority to fix cold-start ANR
2 parents ec35590 + 4dc8ee0 commit c4238b9

4 files changed

Lines changed: 265 additions & 0 deletions

File tree

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,37 @@ public static int GetBigCoreMask()
131131
catch { return 0; }
132132
}
133133

134+
/// <summary>
135+
/// Demotes non-game worker threads (Mono threadpool workers, OkHttp,
136+
/// Okio, .NET threadpool, generic "Thread-N") from <c>nice=-10</c>
137+
/// down to <c>nice=0</c> and — if <paramref name="littleCoreMask"/>
138+
/// is non-zero — pins them to the given LITTLE-core subset.
139+
/// </summary>
140+
/// <remarks>
141+
/// Counter-measure for the Android cold-start black-screen /
142+
/// MotionEvent ANR observed on v177: Mono maps
143+
/// <c>ThreadPriority.Highest</c> to <c>nice=-10</c>, which is
144+
/// Android's display-compositor priority class. Field tombstones
145+
/// show Veldrid's shader-compile worker stuck in
146+
/// <c>glslang::SetupBuiltinSymbolTable</c> at that priority on a
147+
/// big core while the Draw thread is draining a 300+-item
148+
/// texture-upload queue — together starving the Android main UI
149+
/// thread of CPU bandwidth past the 10s input-dispatch deadline.
150+
///
151+
/// Game-loop threads (Update/Draw/Audio/Input), the Android main
152+
/// UI thread, and known-critical ART / Android daemons are
153+
/// explicitly left alone by the native implementation. Idempotent
154+
/// and safe to call repeatedly; returns the number of threads
155+
/// whose scheduling was actually mutated for diagnostic logging.
156+
/// Returns 0 on any failure (e.g. library not loaded).
157+
/// </remarks>
158+
[MethodImpl(MethodImplOptions.NoInlining)]
159+
public static int TameBackgroundThreads(int littleCoreMask)
160+
{
161+
try { return OboeAudioBridge.nTameBackgroundThreads(littleCoreMask); }
162+
catch { return 0; }
163+
}
164+
134165
[MethodImpl(MethodImplOptions.NoInlining)]
135166
public bool IsOboeActive() => (oboeBridge as OboeAudioBridge)?.IsActive ?? false;
136167

osu.Android/Native/OboeAudioBridge.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ public void Dispose()
211211
[DllImport(lib_name)] private static extern IntPtr nOboeGetLastErrorMessage(IntPtr ptr);
212212
[DllImport(lib_name)] internal static extern byte nSetThreadAffinity(int coreMask);
213213
[DllImport(lib_name)] internal static extern int nGetBigCoreMask();
214+
[DllImport(lib_name)] internal static extern int nTameBackgroundThreads(int littleCoreMask);
214215
[DllImport(lib_name)] internal static extern IntPtr nADPFCreateSession(long targetDurationNanos);
215216
[DllImport(lib_name)] internal static extern void nADPFReportActualDuration(IntPtr sessionPtr, long actualDurationNanos);
216217
[DllImport(lib_name)] internal static extern void nADPFUpdateTargetDuration(IntPtr sessionPtr, long targetDurationNanos);

osu.Android/Native/oboe_bridge.cpp

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
#include <sched.h>
88
#include <unistd.h>
99
#include <sys/syscall.h>
10+
#include <sys/resource.h>
11+
#include <sys/types.h>
12+
#include <dirent.h>
13+
#include <fcntl.h>
1014
#include <android/log.h>
1115
#include <cstdint>
1216
#include <cstdio>
1317
#include <cstring>
18+
#include <cstdlib>
1419
#include <vector>
1520
#include <algorithm>
1621
typedef uint8_t byte;
@@ -545,6 +550,144 @@ OSU_EXPORT int nGetBigCoreMask() {
545550
}
546551
return mask;
547552
}
553+
554+
// ============================================================
555+
// Background-thread taming (field-crash mitigation — see below)
556+
// ============================================================
557+
// Android cold-start black-screen / MotionEvent ANR (v177) was root-caused to
558+
// Veldrid's shader-compile worker running glslang::TPpContext::tokenize deep
559+
// inside glslang::SetupBuiltinSymbolTable at nice=-10 on a big core, starving
560+
// the Android main UI thread of CPU at the same moment the Draw thread is
561+
// draining a 300+-item texture-upload queue. Mono maps .NET
562+
// ThreadPriority.Highest to nice=-10 for every worker thread it spawns
563+
// (shader compile, finalizer, network, etc.), which is the display-
564+
// compositor priority class — inappropriate for CPU-heavy background work.
565+
//
566+
// This function walks /proc/self/task, reads each thread's kernel comm, and
567+
// for any comm matching the Mono-threadpool-worker naming pattern drops the
568+
// nice value to 0 and (if little_core_mask != 0) pins the thread to the
569+
// given LITTLE-core subset. Game-loop threads (Update/Draw/Audio/Input), the
570+
// Android main UI thread (tid == tgid), the calling thread, and a small
571+
// list of critical ART/system daemons are explicitly left alone.
572+
//
573+
// Safe to call repeatedly; subsequent calls are idempotent. Cross-thread
574+
// setpriority / sched_setaffinity within the same process is allowed for a
575+
// same-euid caller without CAP_SYS_NICE, so no root required.
576+
//
577+
// Returns: number of threads demoted (for diagnostic logging).
578+
static bool isCommToLeaveAlone(const char* comm) {
579+
if (!comm || !*comm) return false;
580+
581+
// Game-loop threads created by osu-framework GameThread. These MUST stay
582+
// at their elevated priority so the render/update/audio/input pipelines
583+
// aren't starved by Android's scheduler during play.
584+
static const char* const keep[] = {
585+
"Update", "Draw", "Audio", "Input",
586+
"SDLActivity", "HangWatchdog",
587+
// Known-critical ART / Android daemons; leave to platform defaults.
588+
"FinalizerDae", "FinalizerWat", "ReferenceQueu", "HeapTaskDaemo",
589+
"Signal Catche", "Jit thread po", "Profile Saver", "binder:",
590+
"perfetto", "main",
591+
// Oboe / AAudio callback threads (priority-critical for audio).
592+
"AAudio", "OboeAudio",
593+
};
594+
595+
for (const char* p : keep) {
596+
if (std::strncmp(comm, p, std::strlen(p)) == 0)
597+
return true;
598+
}
599+
600+
return false;
601+
}
602+
603+
static bool isCommToDemote(const char* comm) {
604+
if (!comm) return false;
605+
606+
// Empty comm (unnamed thread) — definitely safe to demote; these are
607+
// ad-hoc pthread_create workers that inherited nice=-10 from a parent.
608+
if (*comm == '\0') return true;
609+
610+
// Mono threadpool worker default name: "Thread-<n>". This is the thread
611+
// that was stuck in glslang::SetupBuiltinSymbolTable in the field
612+
// tombstone; it's also used for network / shader / JIT helpers.
613+
if (std::strncmp(comm, "Thread-", 7) == 0) return true;
614+
615+
// OkHttp / Okio network threads — nice=-8 observed in tombstones, no
616+
// reason to outrank the main UI thread during cold start.
617+
if (std::strncmp(comm, "OkHttp", 6) == 0) return true;
618+
if (std::strncmp(comm, "Okio", 4) == 0) return true;
619+
620+
// .NET threadpool default pattern ("pool-", ".NET Thread", "TP").
621+
if (std::strncmp(comm, "pool-", 5) == 0) return true;
622+
if (std::strncmp(comm, ".NET", 4) == 0) return true;
623+
624+
return false;
625+
}
626+
627+
OSU_EXPORT int nTameBackgroundThreads(int little_core_mask) {
628+
DIR* dir = opendir("/proc/self/task");
629+
if (!dir) return 0;
630+
631+
const pid_t self_tid = (pid_t)syscall(SYS_gettid);
632+
const pid_t tgid = getpid();
633+
634+
cpu_set_t cpuset;
635+
CPU_ZERO(&cpuset);
636+
bool haveCpuset = false;
637+
if (little_core_mask != 0) {
638+
for (int i = 0; i < 32; i++) {
639+
if ((little_core_mask >> i) & 1)
640+
CPU_SET(i, &cpuset);
641+
}
642+
haveCpuset = CPU_COUNT(&cpuset) > 0;
643+
}
644+
645+
int demoted = 0;
646+
struct dirent* ent;
647+
while ((ent = readdir(dir)) != nullptr) {
648+
if (ent->d_name[0] < '0' || ent->d_name[0] > '9') continue;
649+
650+
pid_t tid = (pid_t)std::atoi(ent->d_name);
651+
if (tid <= 0) continue;
652+
if (tid == self_tid) continue;
653+
// Never touch the Android main UI thread — Android's input dispatcher
654+
// reads from it, and any priority/affinity mutation here is exactly
655+
// the class of change that causes a 10s MotionEvent ANR.
656+
if (tid == tgid) continue;
657+
658+
char comm_path[64];
659+
std::snprintf(comm_path, sizeof(comm_path), "/proc/self/task/%d/comm", (int)tid);
660+
int fd = open(comm_path, O_RDONLY | O_CLOEXEC);
661+
if (fd < 0) continue;
662+
663+
char comm[32] = {0};
664+
ssize_t n = read(fd, comm, sizeof(comm) - 1);
665+
close(fd);
666+
if (n <= 0) continue;
667+
// Strip trailing newline that the kernel appends.
668+
if (comm[n - 1] == '\n') comm[n - 1] = '\0';
669+
670+
if (isCommToLeaveAlone(comm)) continue;
671+
if (!isCommToDemote(comm)) continue;
672+
673+
bool changed = false;
674+
675+
// Raise nice from whatever it is (often -10 for Mono ThreadPriority.Highest)
676+
// to 0. setpriority(PRIO_PROCESS, tid, 0) is a de-elevation and
677+
// therefore does not require CAP_SYS_NICE for a same-euid caller.
678+
if (setpriority(PRIO_PROCESS, tid, 0) == 0)
679+
changed = true;
680+
681+
if (haveCpuset) {
682+
if (sched_setaffinity(tid, sizeof(cpu_set_t), &cpuset) == 0)
683+
changed = true;
684+
}
685+
686+
if (changed) demoted++;
687+
}
688+
closedir(dir);
689+
return demoted;
690+
}
548691
}
549692

550693
#include <android/performance_hint.h>

osu.Android/OsuGameAndroid.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,50 @@ protected override void LoadComplete()
404404
Logger.Log($"[osu!] Failed to pin threads: {e.Message}", LoggingTarget.Performance);
405405
}
406406

407+
// Tame background worker threads (Mono threadpool / shader-compile /
408+
// OkHttp / Okio / unnamed "Thread-N" workers) out of the nice=-10
409+
// display-compositor priority class Mono maps ThreadPriority.Highest
410+
// to. Field tombstones from v177 show a Mono threadpool worker stuck
411+
// in Veldrid's glslang::SetupBuiltinSymbolTable at nice=-10 on a big
412+
// core while the Draw thread drains a 300+-item texture-upload queue
413+
// — together starving the Android main UI thread past the 10s input-
414+
// dispatch deadline and producing a MotionEvent ANR.
415+
//
416+
// The native helper walks /proc/self/task, identifies non-game
417+
// workers by kernel comm, and drops them to nice=0. If we detected a
418+
// big-core mask above, it ALSO pins those workers to the LITTLE-core
419+
// subset (inverse of the big-core mask, masked against the real CPU
420+
// count) so that shader-compile / network / finalizer work cannot
421+
// preempt the Draw thread or the Android main UI thread.
422+
//
423+
// We apply this unconditionally — i.e. also during safe-mode — because
424+
// the two latest safe-mode launches in logs.zip demonstrated that
425+
// skipping CPU affinity pinning alone does NOT avoid the hang;
426+
// background-thread priority elevation is the other half of the
427+
// starvation equation and must be addressed independently.
428+
//
429+
// First apply runs synchronously here so any already-created workers
430+
// are tamed immediately; additional apply passes are scheduled inside
431+
// the refreshRateDelayMs block below to catch workers that are spawned
432+
// later (Veldrid typically creates its shader-compile worker on first
433+
// use, i.e. right when the Toolbar starts loading).
434+
try
435+
{
436+
int coreCount = System.Environment.ProcessorCount;
437+
int totalMask = coreCount >= 32 ? -1 : (1 << Math.Min(coreCount, 31)) - 1;
438+
int littleMask = (~affinityMask) & totalMask;
439+
if (littleMask == 0)
440+
littleMask = totalMask; // fall back to "any core" if topology unknown.
441+
442+
int demoted = AndroidNativeBridgeManager.TameBackgroundThreads(littleMask);
443+
if (demoted > 0)
444+
Logger.Log($"[osu!] Tamed {demoted} background worker thread(s) to nice=0 (little-core mask=0x{littleMask:X})", LoggingTarget.Performance);
445+
}
446+
catch (Exception e)
447+
{
448+
Debug.WriteLine($"[osu!] TameBackgroundThreads (initial) failed: {e.Message}");
449+
}
450+
407451
// Sustained performance mode is applied LATER, together with the deferred
408452
// display-mode / GC-latency work below. See the Scheduler.AddDelayed block
409453
// further down (after base.LoadComplete()) that schedules the first apply
@@ -446,6 +490,52 @@ protected override void LoadComplete()
446490
// the texture-upload backpressure last time gets a wider safety margin.
447491
int refreshRateDelayMs = AndroidStartupSafeMode.IsActive ? 15_000 : 5_000;
448492

493+
// Repeat passes of background-thread taming during the Toolbar cold-start
494+
// texture-upload burst. Veldrid spawns its shader-compile worker lazily
495+
// on the first CompileGlslToSpirv call, which happens mid-Toolbar-load
496+
// (i.e. after the synchronous taming pass above has already run). Without
497+
// 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.
501+
try
502+
{
503+
int coreCount = System.Environment.ProcessorCount;
504+
int totalMask = coreCount >= 32 ? -1 : (1 << Math.Min(coreCount, 31)) - 1;
505+
int deferredLittleMask;
506+
507+
if (AndroidStartupSafeMode.IsActive)
508+
deferredLittleMask = totalMask; // safe-mode: affinity disabled, use full mask.
509+
else
510+
{
511+
int bigMask = AndroidNativeBridgeManager.GetBigCoreMask();
512+
deferredLittleMask = (~bigMask) & totalMask;
513+
if (deferredLittleMask == 0) deferredLittleMask = totalMask;
514+
}
515+
516+
foreach (int delayMs in new[] { 500, 1500, 3500 })
517+
{
518+
int dm = delayMs;
519+
Scheduler.AddDelayed(() =>
520+
{
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+
}
533+
}
534+
catch (Exception e)
535+
{
536+
Debug.WriteLine($"[osu!] Failed to schedule deferred TameBackgroundThreads passes: {e.Message}");
537+
}
538+
449539
Scheduler.AddDelayed(() =>
450540
{
451541
// Flip the gate FIRST, then run the actual query. Any subsequent

0 commit comments

Comments
 (0)