@@ -290,25 +290,59 @@ private void load(FrameworkConfigManager frameworkConfig)
290290
291291 protected override void LoadComplete ( )
292292 {
293- // Use sysfs-based CPU topology for accurate big-core detection across all SoC vendors.
294- // Falls back to generic upper-half heuristic if native library unavailable.
295- int affinityMask = AndroidNativeBridgeManager . GetBigCoreMask ( ) ;
296-
297- if ( affinityMask == 0 )
293+ // Crash-loop safe-mode: bypass CPU big-core affinity pinning entirely.
294+ //
295+ // Pinning Update + Draw + Input to a 5-core subset (mask 0xF8 on SD8G2) is the
296+ // ONLY unconditional Android-specific synchronous mutation we still perform
297+ // during the cold-start window — every other customisation (SustainedPerformanceMode,
298+ // RequestUnbufferedDispatch, refresh-rate selection, Oboe / Vulkan-probe init,
299+ // performance-mode GC-latency flip) is already deferred behind the
300+ // refreshRateDelayMs scheduler below. Field logs.zip on v2026.423.176 show both
301+ // a normal launch and a safe-mode launch dying silently mid-Toolbar load
302+ // (~3 s after SetHost) before any deferred work has a chance to run, with no
303+ // native_crash entry, no managed exception, and the 10 s native watchdog never
304+ // firing — the fingerprint of an external SIGKILL (input-ANR or LMK). With
305+ // every other mutation already deferred, affinity pinning is the last
306+ // candidate. Pinning to a fixed CPU subset while Mono GC / finalizer / JIT
307+ // threads run on default affinity (all cores) creates contention on the same
308+ // big-cluster cores during the texture-upload burst; combined with the kernel
309+ // load-balancer pulling the unpinned Android Main UI thread off the LITTLE
310+ // cluster (because the big cluster looks "active" but is actually saturated),
311+ // touch-event ACK can miss the 5 s input-dispatch deadline. Skipping the
312+ // pinning in safe-mode gives the next launch a true vanilla cold-start path:
313+ // if it survives, we have isolated the cause; if it does not, we have ruled
314+ // out CPU pinning and the next iteration can target the next suspect with
315+ // the heartbeat data captured below.
316+ int affinityMask ;
317+
318+ if ( AndroidStartupSafeMode . IsActive )
298319 {
299- int coreCount = System . Environment . ProcessorCount ;
300- int bigCoreStart = Math . Max ( coreCount / 2 , 1 ) ;
301-
302- for ( int i = bigCoreStart ; i < Math . Min ( coreCount , 32 ) ; i ++ )
303- affinityMask |= 1 << i ;
320+ affinityMask = 0 ;
321+ CrashDiagnostics . WriteAliveMarker ( "LoadComplete: skipping CPU affinity pinning (safe-mode)" ) ;
322+ Logger . Log ( "[osu!] CPU affinity pinning skipped (safe-mode active)" , LoggingTarget . Performance ) ;
323+ }
324+ else
325+ {
326+ // Use sysfs-based CPU topology for accurate big-core detection across all SoC vendors.
327+ // Falls back to generic upper-half heuristic if native library unavailable.
328+ affinityMask = AndroidNativeBridgeManager . GetBigCoreMask ( ) ;
304329
305330 if ( affinityMask == 0 )
306- affinityMask = ( 1 << Math . Min ( coreCount , 31 ) ) - 1 ;
331+ {
332+ int coreCount = System . Environment . ProcessorCount ;
333+ int bigCoreStart = Math . Max ( coreCount / 2 , 1 ) ;
334+
335+ for ( int i = bigCoreStart ; i < Math . Min ( coreCount , 32 ) ; i ++ )
336+ affinityMask |= 1 << i ;
337+
338+ if ( affinityMask == 0 )
339+ affinityMask = ( 1 << Math . Min ( coreCount , 31 ) ) - 1 ;
340+ }
307341 }
308342
309343 try
310344 {
311- if ( OboeAudioBridge . nSetThreadAffinity ( affinityMask ) != 0 )
345+ if ( affinityMask != 0 && OboeAudioBridge . nSetThreadAffinity ( affinityMask ) != 0 )
312346 Logger . Log ( $ "[osu!] Update thread pinned to big cores (mask=0x{ affinityMask : X} )", LoggingTarget . Performance ) ;
313347
314348 // Intentionally NOT calling Process.SetThreadPriority(UrgentDisplay) here.
@@ -329,38 +363,41 @@ protected override void LoadComplete()
329363
330364 int mask = affinityMask ;
331365
332- Scheduler . Add ( ( ) =>
366+ if ( mask != 0 )
333367 {
334- try
368+ Scheduler . Add ( ( ) =>
335369 {
336- Host ? . DrawThread ? . Scheduler . Add ( ( ) =>
370+ try
337371 {
338- try
372+ Host ? . DrawThread ? . Scheduler . Add ( ( ) =>
339373 {
340- if ( OboeAudioBridge . nSetThreadAffinity ( mask ) != 0 ) Logger . Log ( "[osu!] Render thread pinned to big cores" , LoggingTarget . Performance ) ;
341- }
342- catch { }
343- } ) ;
344-
345- Host ? . InputThread ? . Scheduler . Add ( ( ) =>
346- {
347- try
374+ try
375+ {
376+ if ( OboeAudioBridge . nSetThreadAffinity ( mask ) != 0 ) Logger . Log ( "[osu!] Render thread pinned to big cores" , LoggingTarget . Performance ) ;
377+ }
378+ catch { }
379+ } ) ;
380+
381+ Host ? . InputThread ? . Scheduler . Add ( ( ) =>
348382 {
349- if ( OboeAudioBridge . nSetThreadAffinity ( mask ) != 0 ) Logger . Log ( "[osu!] Input thread pinned to big cores" , LoggingTarget . Performance ) ;
350- }
351- catch { }
352- } ) ;
353- }
354- catch ( Exception e )
355- {
356- // The enclosing try/catch only covers the Scheduler.Add call — not the
357- // lambda body, which runs later on the update thread. Guard here so an
358- // NRE from Host.DrawThread/Host.InputThread being null (or a Host
359- // teardown race during startup) can't escape as an unhandled update-
360- // thread exception and kill the framework.
361- Debug . WriteLine ( $ "[osu!] Failed to enqueue thread-affinity pinning for render/input threads: { e . Message } ") ;
362- }
363- } ) ;
383+ try
384+ {
385+ if ( OboeAudioBridge . nSetThreadAffinity ( mask ) != 0 ) Logger . Log ( "[osu!] Input thread pinned to big cores" , LoggingTarget . Performance ) ;
386+ }
387+ catch { }
388+ } ) ;
389+ }
390+ catch ( Exception e )
391+ {
392+ // The enclosing try/catch only covers the Scheduler.Add call — not the
393+ // lambda body, which runs later on the update thread. Guard here so an
394+ // NRE from Host.DrawThread/Host.InputThread being null (or a Host
395+ // teardown race during startup) can't escape as an unhandled update-
396+ // thread exception and kill the framework.
397+ Debug . WriteLine ( $ "[osu!] Failed to enqueue thread-affinity pinning for render/input threads: { e . Message } ") ;
398+ }
399+ } ) ;
400+ }
364401 }
365402 catch ( Exception e )
366403 {
@@ -509,6 +546,22 @@ protected override void LoadComplete()
509546 // sentinel persists and the next launch enters safe-mode.
510547 Scheduler . AddDelayed ( AndroidStartupSafeMode . ClearStartupInProgress , 10_000 ) ;
511548
549+ // Cold-start heartbeat instrumentation. For the first 15 s after LoadComplete
550+ // we emit per-second ALIVE markers from BOTH the Update thread and the Draw
551+ // thread into native_crash.log, tagged with the originating thread name.
552+ // This closes the diagnostic gap between the last "SetHost returning" marker
553+ // (~22 s mark in field logs) and the planned 10 s ClearStartupInProgress
554+ // marker that has so far never fired because the process is killed before
555+ // it does. With per-second per-thread heartbeats, the next post-mortem can
556+ // see exactly which thread (Update, Draw, both, or neither) was still alive
557+ // at the moment the OS reaped the process — a critical signal for telling
558+ // apart input-ANR (Main UI thread blocked but game-loop alive), Vulkan/swap-
559+ // chain stall (Update alive but Draw frozen), Mono GC STW (both frozen
560+ // simultaneously) and external SIGKILL/LMK (last heartbeat exactly at kill
561+ // time). Markers are written via the same lock-protected appendToBoth path
562+ // already used by WriteAliveMarker, so they are safe from any thread.
563+ scheduleColdStartHeartbeats ( ) ;
564+
512565 // When the user selects a different refresh rate from the settings dropdown, apply it.
513566 SelectedDisplayRefreshRate . BindValueChanged ( e =>
514567 {
@@ -620,6 +673,56 @@ protected override void LoadComplete()
620673 // full rationale.
621674 }
622675
676+ /// <summary>
677+ /// Emits per-second "ALIVE" breadcrumbs from both the Update and Draw threads
678+ /// into <c>native_crash.log</c> for the first 15 seconds after LoadComplete,
679+ /// then stops. See the call site in <see cref="LoadComplete"/> for the full
680+ /// rationale; this method only handles the scheduling plumbing.
681+ /// </summary>
682+ private void scheduleColdStartHeartbeats ( )
683+ {
684+ const int total_ticks = 15 ;
685+
686+ try
687+ {
688+ for ( int i = 1 ; i <= total_ticks ; i ++ )
689+ {
690+ // Capture the loop variable into a local so each scheduled lambda
691+ // closes over its own `tick` value, not the shared `i` reference
692+ // (otherwise every lambda would log the post-loop value of `i`).
693+ int tick = i ;
694+
695+ // Update thread heartbeat (fires on the framework's update scheduler).
696+ Scheduler . AddDelayed ( ( ) =>
697+ {
698+ try { CrashDiagnostics . WriteAliveMarker ( $ "cold-start heartbeat update thread tick={ tick } /{ total_ticks } ") ; }
699+ catch { /* best-effort diagnostic; never throw */ }
700+ } , tick * 1_000 ) ;
701+
702+ // Draw thread heartbeat. We must hop via the update scheduler first
703+ // because Host.DrawThread.Scheduler does not expose AddDelayed on
704+ // the public surface — we enqueue an immediate Draw-thread action
705+ // from a delayed Update-thread tick to achieve the same effect.
706+ Scheduler . AddDelayed ( ( ) =>
707+ {
708+ try
709+ {
710+ Host ? . DrawThread ? . Scheduler . Add ( ( ) =>
711+ {
712+ try { CrashDiagnostics . WriteAliveMarker ( $ "cold-start heartbeat draw thread tick={ tick } /{ total_ticks } ") ; }
713+ catch { }
714+ } ) ;
715+ }
716+ catch { }
717+ } , tick * 1_000 ) ;
718+ }
719+ }
720+ catch ( Exception e )
721+ {
722+ Debug . WriteLine ( $ "[osu!] Failed to schedule cold-start heartbeats: { e . Message } ") ;
723+ }
724+ }
725+
623726 private void applyPerformanceOptimizations ( bool enabled )
624727 {
625728 gameActivity . RunOnUiThread ( ( ) =>
0 commit comments