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