Skip to content

Commit 86d7931

Browse files
authored
Merge pull request #250 from winnerspiros/copilot/fix-black-screen-issue-yet-again
Android: pthread liveness watchdog + first-load mitigations for startup hang
2 parents 4b4e4d8 + 7c21399 commit 86d7931

13 files changed

Lines changed: 1308 additions & 58 deletions

osu.Android/AndroidStartupFlags.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.IO;
6+
using Android.App;
7+
using Debug = System.Diagnostics.Debug;
8+
9+
namespace osu.Android
10+
{
11+
/// <summary>
12+
/// Tiny on-disk sentinel store that lets pre-<see cref="osu.Game.OsuGameBase"/> code
13+
/// (e.g. <see cref="OsuGameActivity.OnCreate"/>) consult settings owned by
14+
/// <see cref="osu.Game.Configuration.OsuConfigManager"/>.
15+
///
16+
/// <para>
17+
/// The activity runs LONG before the config manager exists — Realm has to be
18+
/// initialised first, and the activity is the place that needs to RUN safety
19+
/// behaviours BEFORE Realm runs. The chicken-and-egg solution is a one-flag-
20+
/// per-file sentinel pattern: when the user changes a startup-safety toggle
21+
/// in-game, <see cref="OsuGameAndroid"/> writes (or deletes) a sentinel file
22+
/// whose presence reflects the new value. The activity reads the sentinel
23+
/// next launch.
24+
/// </para>
25+
///
26+
/// <para>
27+
/// Files live under <c>FilesDir</c> (internal app storage). Each flag is a
28+
/// single empty file named <c>android_startup_disable_&lt;name&gt;.flag</c>.
29+
/// Presence ⇒ "the user has explicitly disabled this safety net". Absence ⇒
30+
/// "default behaviour" (per the matching OsuSetting default in
31+
/// <see cref="osu.Game.Configuration.OsuConfigManager"/>).
32+
/// </para>
33+
///
34+
/// <para>
35+
/// All operations are best-effort and never throw out — diagnostics-grade
36+
/// reliability semantics, like <see cref="CrashDiagnostics"/>.
37+
/// </para>
38+
/// </summary>
39+
internal static class AndroidStartupFlags
40+
{
41+
public const string FLAG_CLEANUP_REALM_FIFOS_DISABLED = "android_startup_disable_realm_fifo_cleanup.flag";
42+
public const string FLAG_DEFER_NATIVE_INIT_DISABLED = "android_startup_disable_defer_native_init.flag";
43+
public const string FLAG_FRAME_SYNC_MIGRATION_ENABLED = "android_startup_enable_frame_sync_migration.flag";
44+
45+
private static string? resolveDir()
46+
{
47+
try
48+
{
49+
var ctx = Application.Context;
50+
var files = ctx?.FilesDir;
51+
if (files == null) return null;
52+
string? path = files.AbsolutePath;
53+
return string.IsNullOrEmpty(path) ? null : path;
54+
}
55+
catch (Exception e)
56+
{
57+
Debug.WriteLine($"[osu!] AndroidStartupFlags.resolveDir failed: {e.Message}");
58+
return null;
59+
}
60+
}
61+
62+
/// <summary>
63+
/// Returns true if a sentinel file with the given name exists in internal app storage.
64+
/// </summary>
65+
public static bool IsSet(string flagName)
66+
{
67+
try
68+
{
69+
string? dir = resolveDir();
70+
if (dir == null) return false;
71+
return File.Exists(Path.Combine(dir, flagName));
72+
}
73+
catch (Exception e)
74+
{
75+
Debug.WriteLine($"[osu!] AndroidStartupFlags.IsSet({flagName}) failed: {e.Message}");
76+
return false;
77+
}
78+
}
79+
80+
/// <summary>
81+
/// Create or remove the sentinel file according to <paramref name="set"/>.
82+
/// Idempotent and never throws.
83+
/// </summary>
84+
public static void Set(string flagName, bool set)
85+
{
86+
try
87+
{
88+
string? dir = resolveDir();
89+
if (dir == null) return;
90+
string path = Path.Combine(dir, flagName);
91+
if (set)
92+
{
93+
if (!File.Exists(path))
94+
File.WriteAllText(path, string.Empty);
95+
}
96+
else
97+
{
98+
if (File.Exists(path))
99+
File.Delete(path);
100+
}
101+
}
102+
catch (Exception e)
103+
{
104+
Debug.WriteLine($"[osu!] AndroidStartupFlags.Set({flagName}, {set}) failed: {e.Message}");
105+
}
106+
}
107+
}
108+
}

osu.Android/CrashDiagnostics.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,40 @@ public static void ReinstallNativeHandler()
150150
}
151151
}
152152

153+
/// <summary>
154+
/// Arm the native pthread liveness watchdog. Writes its hang dumps to the same
155+
/// internal-storage <c>native_crash.log</c> the rest of the diagnostics pipeline uses.
156+
///
157+
/// <para>
158+
/// The native watchdog is the only diagnostic that survives a Mono stop-the-world GC
159+
/// pause: it runs as a pthread that never attaches to the runtime, so Mono cannot
160+
/// suspend it during STW. This is essential for diagnosing the "every managed thread
161+
/// parked in <c>__rt_sigsuspend</c>" startup hangs we have been chasing — under that
162+
/// failure mode the managed <see cref="osu.Android.HangWatchdog"/> is itself frozen
163+
/// and produces no dump.
164+
/// </para>
165+
///
166+
/// <para>
167+
/// Idempotent on the native side; safe to call from any thread; never throws.
168+
/// Caller is expected to gate on <c>OsuSetting.AndroidNativeWatchdogEnabled</c> so
169+
/// the user can disable the diagnostic from in-game settings if it ever interferes
170+
/// with normal operation.
171+
/// </para>
172+
/// </summary>
173+
/// <param name="hangSeconds">Threshold (clamped to [3, 120] on the native side).</param>
174+
public static void StartNativeWatchdog(int hangSeconds)
175+
{
176+
try
177+
{
178+
NativeWatchdog.Start(installedLogPath, hangSeconds);
179+
WriteAliveMarker($"CrashDiagnostics.StartNativeWatchdog (threshold={hangSeconds}s)");
180+
}
181+
catch (Exception e)
182+
{
183+
Debug.WriteLine($"[osu!] StartNativeWatchdog failed: {e.Message}");
184+
}
185+
}
186+
153187
/// <summary>
154188
/// Append a single-line "I am alive" marker to the crash log so that, when we later
155189
/// inspect a truncated/empty file after a crash, the last-written marker pinpoints

osu.Android/HangWatchdog.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,15 @@ private void tick()
401401
{
402402
Interlocked.Exchange(ref LastTickUtcMs, nowUtcMs());
403403

404+
// Bump the native pthread watchdog. Cheap (one __atomic_store_n
405+
// on a 64-bit slot) and only does anything if the libosu_native.so
406+
// entry point is present; otherwise the wrapper silently no-ops.
407+
// The native watchdog is what produces hang dumps when Mono's
408+
// STW GC has frozen every managed thread — without this
409+
// heartbeat its timer-driven loop has nothing to compare against
410+
// and would dump on first tick.
411+
osu.Android.Native.NativeWatchdog.Heartbeat();
412+
404413
// gettid is cheap (single syscall) and only meaningfully
405414
// changes on the very first tick — but we re-record it on
406415
// every tick so a thread restart (e.g. ExecutionMode swap)

osu.Android/LogManagement.cs

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,14 @@ namespace osu.Android
2525
/// </para>
2626
///
2727
/// <para>
28-
/// We additionally lower <see cref="Logger.Level"/> to
29-
/// <see cref="LogLevel.Important"/> on Android. The default for release
30-
/// builds is <see cref="LogLevel.Verbose"/>, which on osu! emits one
31-
/// <c>[verbose]</c> line per shader compile, per texture upload, per SDL
32-
/// no-op, per `Loading X...` event, etc. Almost none of that helps debug a
33-
/// startup ANR — the actionable signal is exclusively in <c>[important]</c>
34-
/// and <c>[error]</c> entries (Vulkan surface-lost events, mode changes,
35-
/// realm migration, exceptions). Cutting verbose drops the typical per-launch
36-
/// log size by ~95% without losing any diagnostic value.
28+
/// Historically this class also lowered <see cref="Logger.Level"/> to
29+
/// <see cref="LogLevel.Important"/> on Android. That has been reverted —
30+
/// the framework's default verbosity is restored so osu.log captures the
31+
/// full per-thread startup narrative needed to diagnose hangs. On-disk
32+
/// log size is still bounded by <see cref="pruneLogDirectory"/> at every
33+
/// startup (oldest-first eviction down to <see cref="MAX_LOG_BYTES"/>),
34+
/// so verbose logs cannot regress the ~480 MB footprint that originally
35+
/// motivated this file.
3736
/// </para>
3837
/// </summary>
3938
internal static class LogManagement
@@ -59,18 +58,15 @@ internal static class LogManagement
5958
/// </summary>
6059
public static void Apply()
6160
{
62-
try
63-
{
64-
// Setting Logger.Level is a no-op on entries already queued, but
65-
// since we run from Activity.OnCreate before the framework host
66-
// has been constructed (and therefore before the first framework
67-
// log entry), this takes effect for the entire session.
68-
Logger.Level = LogLevel.Important;
69-
}
70-
catch (Exception e)
71-
{
72-
Debug.WriteLine($"[osu!] LogManagement: could not lower Logger.Level: {e.Message}");
73-
}
61+
// NOTE: we used to force Logger.Level = LogLevel.Important here to
62+
// shrink runtime log output during the "log explosion" debugging
63+
// window. That has been reverted at user request — the default
64+
// framework log verbosity is now restored so osu.log captures the
65+
// full per-thread startup narrative we need to diagnose hangs.
66+
// Log size is still bounded by pruneLogDirectory() below
67+
// (MAX_LOG_BYTES cap with oldest-first eviction), so re-enabling
68+
// verbose logging cannot regress the on-disk footprint that the
69+
// 480 MB report originally exposed.
7470

7571
try
7672
{

osu.Android/Native/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ add_library(osu_native SHARED
8686
oboe_bridge.cpp
8787
vulkan_bridge.cpp
8888
crash_handler.cpp
89+
native_watchdog.cpp
8990
)
9091

9192

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.Runtime.InteropServices;
6+
using Debug = System.Diagnostics.Debug;
7+
8+
namespace osu.Android.Native
9+
{
10+
/// <summary>
11+
/// Managed-side wrapper for the native pthread liveness watchdog implemented in
12+
/// <c>osu.Android/Native/native_watchdog.cpp</c>.
13+
///
14+
/// <para>
15+
/// The native watchdog exists because the managed <see cref="osu.Android.HangWatchdog"/>
16+
/// runs as a normal <c>System.Threading.Thread</c>, which Mono suspends during a
17+
/// stop-the-world GC by sending <c>SIGRTMIN+N</c>. If a Mono thread is stuck
18+
/// inside a long native call (Vulkan present-queue futex, Realm fifo open,
19+
/// AAudio init, …) the STW request never completes and every other managed
20+
/// thread — including our managed watchdog's monitor — is parked indefinitely.
21+
/// A pure pthread-only watchdog that never attaches to Mono is the only thing
22+
/// that can produce a diagnostic dump under that condition.
23+
/// </para>
24+
///
25+
/// <para>
26+
/// All entry points are best-effort: a missing native library or a
27+
/// <see cref="DllNotFoundException"/> is non-fatal and silently downgraded to
28+
/// a <see cref="Debug.WriteLine"/> call so startup is unaffected.
29+
/// </para>
30+
/// </summary>
31+
internal static class NativeWatchdog
32+
{
33+
// Same lib name used by OboeAudioBridge — single shared libosu_native.so.
34+
private const string lib_name = "osu_native";
35+
36+
/// <summary>
37+
/// Arm the native watchdog with the given log path and hang threshold.
38+
/// Idempotent: subsequent calls are no-ops on the native side.
39+
/// Never throws.
40+
/// </summary>
41+
/// <param name="logPath">Absolute path of the file to append hang dumps to (typically <c>FilesDir/native_crash.log</c>).</param>
42+
/// <param name="hangSeconds">Seconds without a heartbeat before a dump is triggered. Native side clamps to [3, 120].</param>
43+
public static void Start(string? logPath, int hangSeconds)
44+
{
45+
try
46+
{
47+
osu_native_watchdog_start(logPath, hangSeconds);
48+
}
49+
catch (DllNotFoundException e)
50+
{
51+
Debug.WriteLine($"[osu!] NativeWatchdog.Start: libosu_native.so not loaded, watchdog disabled ({e.Message})");
52+
}
53+
catch (EntryPointNotFoundException e)
54+
{
55+
// The native entry point is absent — most likely an old libosu_native.so
56+
// in the APK that does not include native_watchdog.cpp. Treat as disabled
57+
// rather than crashing the user's startup.
58+
Debug.WriteLine($"[osu!] NativeWatchdog.Start: entry point missing, watchdog disabled ({e.Message})");
59+
}
60+
catch (Exception e)
61+
{
62+
Debug.WriteLine($"[osu!] NativeWatchdog.Start unexpected failure: {e.Message}");
63+
}
64+
}
65+
66+
/// <summary>
67+
/// Bump the native heartbeat. Called from the managed
68+
/// <see cref="osu.Android.HangWatchdog"/> per-thread tick so the native watchdog
69+
/// can observe Update-thread liveness across Mono STW pauses. The underlying
70+
/// native call performs a single <c>__atomic_store_n</c> on a 64-bit slot;
71+
/// safe to call at any rate, from any thread, without locking.
72+
/// Never throws.
73+
/// </summary>
74+
public static void Heartbeat()
75+
{
76+
try
77+
{
78+
osu_native_watchdog_heartbeat();
79+
}
80+
catch (DllNotFoundException) { /* watchdog disabled — no-op */ }
81+
catch (EntryPointNotFoundException) { /* old libosu_native.so — no-op */ }
82+
catch (Exception e)
83+
{
84+
// Heartbeat is on the GameThread tick path; never let a diagnostic
85+
// failure escape into the game loop.
86+
Debug.WriteLine($"[osu!] NativeWatchdog.Heartbeat unexpected failure: {e.Message}");
87+
}
88+
}
89+
90+
[DllImport(lib_name)]
91+
private static extern void osu_native_watchdog_start([MarshalAs(UnmanagedType.LPUTF8Str)] string? logPath, int hangSeconds);
92+
93+
[DllImport(lib_name)]
94+
private static extern void osu_native_watchdog_heartbeat();
95+
}
96+
}

0 commit comments

Comments
 (0)