Skip to content

Commit fc5167f

Browse files
Android: bypass CPU affinity pinning in safe-mode + cold-start heartbeats
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/e00e79d9-484a-438e-b054-93f6c7412288 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 7ea7664 commit fc5167f

1 file changed

Lines changed: 142 additions & 39 deletions

File tree

osu.Android/OsuGameAndroid.cs

Lines changed: 142 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)