Skip to content

Commit 85b2ba5

Browse files
authored
Merge pull request #234 from winnerspiros/copilot/fix-apk-crash-issue-another-one
Capture early Android startup crashes via Application-level handler, internal-storage log, and alive markers
2 parents 9b71534 + 8b30dd2 commit 85b2ba5

5 files changed

Lines changed: 292 additions & 33 deletions

File tree

osu.Android/CrashDiagnostics.cs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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 System.Threading;
7+
using System.Threading.Tasks;
8+
using Android.App;
9+
using Android.Content;
10+
using Debug = System.Diagnostics.Debug;
11+
using osu.Android.Native;
12+
13+
namespace osu.Android
14+
{
15+
/// <summary>
16+
/// Centralised Android crash-diagnostics plumbing.
17+
///
18+
/// We write everything to <b>internal</b> app storage (<c>FilesDir</c>) because external
19+
/// storage is FUSE-backed, scoped-storage-restricted, and may not be ready at the
20+
/// instant a very-early crash hits. On the next normal startup we mirror the internal
21+
/// crash log to <c>GetExternalFilesDir(null)</c> so the user can grab it via the Files
22+
/// app on an unrooted device, then truncate the internal copy.
23+
///
24+
/// Files (all relative to <c>FilesDir</c>):
25+
/// <list type="bullet">
26+
/// <item><c>native_crash.log</c> — append target for both the native handler and the managed last-chance hooks; also receives "I am alive" startup markers.</item>
27+
/// <item><c>crash_handler_installed.txt</c> — sentinel dropped immediately after <c>nInstallCrashHandler</c> returns. Lets us distinguish "handler never installed (P/Invoke failed → libosu_native.so missing)" from "handler installed but signal bypassed it".</item>
28+
/// </list>
29+
/// </summary>
30+
internal static class CrashDiagnostics
31+
{
32+
public const string CRASH_LOG_NAME = "native_crash.log";
33+
public const string SENTINEL_NAME = "crash_handler_installed.txt";
34+
35+
private static int initialised;
36+
private static int managedHooksInstalled;
37+
38+
private static string? internalDir;
39+
private static string? externalDir;
40+
41+
/// <summary>
42+
/// Installs the native crash handler against the internal-storage log path, drops the
43+
/// sentinel, and writes the first "I am alive" marker. Idempotent — safe to call from
44+
/// both <see cref="Application.OnCreate"/> and <see cref="Activity.OnCreate(Bundle)"/>;
45+
/// the underlying handler dedupes via its own <c>g_installed</c> flag.
46+
/// </summary>
47+
/// <param name="context">Any <see cref="Context"/> — typically the Application or Activity.</param>
48+
public static void InstallNativeHandler(Context context)
49+
{
50+
try
51+
{
52+
resolveDirs(context);
53+
54+
string? logPath = internalDir != null ? Path.Combine(internalDir, CRASH_LOG_NAME) : null;
55+
56+
// The native handler is best-effort. Wrap so a DllNotFoundException
57+
// (libosu_native.so missing from the APK) cannot itself crash us.
58+
try
59+
{
60+
OboeAudioBridge.nInstallCrashHandler(logPath);
61+
62+
// Sentinel: only written when nInstallCrashHandler returned without throwing.
63+
if (internalDir != null)
64+
{
65+
try
66+
{
67+
File.WriteAllText(
68+
Path.Combine(internalDir, SENTINEL_NAME),
69+
$"installed_at={DateTime.UtcNow:O}\nlog_path={logPath ?? "<none>"}\n");
70+
}
71+
catch (Exception e) { Debug.WriteLine($"[osu!] Could not write crash-handler sentinel: {e.Message}"); }
72+
}
73+
}
74+
catch (Exception e)
75+
{
76+
// Most likely DllNotFoundException. Already handled defensively elsewhere
77+
// — log to Debug and carry on so startup is unaffected.
78+
Debug.WriteLine($"[osu!] nInstallCrashHandler P/Invoke failed: {e.Message}");
79+
}
80+
}
81+
catch (Exception e)
82+
{
83+
Debug.WriteLine($"[osu!] CrashDiagnostics.InstallNativeHandler outer failure: {e.Message}");
84+
}
85+
86+
Interlocked.Exchange(ref initialised, 1);
87+
}
88+
89+
/// <summary>
90+
/// Append a single-line "I am alive" marker to the internal crash log so that, when
91+
/// we later inspect a truncated/empty file after a crash, the last-written marker
92+
/// pinpoints which startup phase died.
93+
/// </summary>
94+
public static void WriteAliveMarker(string phase)
95+
{
96+
try
97+
{
98+
if (internalDir == null) return;
99+
100+
string path = Path.Combine(internalDir, CRASH_LOG_NAME);
101+
string line = $"=== ALIVE [{DateTime.UtcNow:O}] {phase} ===\n";
102+
103+
// Append using a bounded write — never throw, never block.
104+
using var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
105+
using var sw = new StreamWriter(fs);
106+
sw.Write(line);
107+
sw.Flush();
108+
}
109+
catch (Exception e)
110+
{
111+
Debug.WriteLine($"[osu!] WriteAliveMarker({phase}) failed: {e.Message}");
112+
}
113+
}
114+
115+
/// <summary>
116+
/// One-shot post-crash mirror: if an internal <c>native_crash.log</c> exists and is
117+
/// non-empty, copy it to external app storage (so the user can pull it via the Files
118+
/// app on an unrooted device) and truncate the internal copy so subsequent runs only
119+
/// surface fresh crashes.
120+
/// </summary>
121+
public static void MirrorInternalLogToExternal()
122+
{
123+
try
124+
{
125+
if (internalDir == null || externalDir == null) return;
126+
127+
string internalPath = Path.Combine(internalDir, CRASH_LOG_NAME);
128+
if (!File.Exists(internalPath)) return;
129+
130+
var info = new FileInfo(internalPath);
131+
if (info.Length == 0) return;
132+
133+
string externalPath = Path.Combine(externalDir, CRASH_LOG_NAME);
134+
135+
try
136+
{
137+
// Append, not overwrite — keep external as the running historical log.
138+
using (var src = new FileStream(internalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
139+
using (var dst = new FileStream(externalPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
140+
{
141+
src.CopyTo(dst);
142+
dst.Flush();
143+
}
144+
}
145+
catch (Exception e)
146+
{
147+
Debug.WriteLine($"[osu!] Could not mirror internal crash log to external storage: {e.Message}");
148+
return;
149+
}
150+
151+
// Truncate internal so next-startup markers start fresh.
152+
try { File.WriteAllText(internalPath, string.Empty); }
153+
catch (Exception e) { Debug.WriteLine($"[osu!] Could not truncate internal crash log: {e.Message}"); }
154+
}
155+
catch (Exception e)
156+
{
157+
Debug.WriteLine($"[osu!] MirrorInternalLogToExternal failed: {e.Message}");
158+
}
159+
}
160+
161+
/// <summary>
162+
/// Hook the .NET last-chance exception paths. Mono's default unhandled-exception
163+
/// behaviour prints to logcat and aborts; on user devices that printout is lost.
164+
/// Catching it ourselves and writing to disk gives us the full managed stack —
165+
/// which is what we actually need for the uptime-5s SDLThread crash class.
166+
/// </summary>
167+
public static void InstallManagedExceptionHooks()
168+
{
169+
if (Interlocked.Exchange(ref managedHooksInstalled, 1) != 0)
170+
return;
171+
172+
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
173+
{
174+
writeManagedException("AppDomain.UnhandledException", e.ExceptionObject as Exception);
175+
};
176+
177+
TaskScheduler.UnobservedTaskException += (_, e) =>
178+
{
179+
writeManagedException("TaskScheduler.UnobservedTaskException", e.Exception);
180+
// Don't mark observed — the framework / sentry pipeline still wants to see it.
181+
};
182+
}
183+
184+
private static void writeManagedException(string source, Exception? ex)
185+
{
186+
try
187+
{
188+
if (internalDir == null) return;
189+
190+
string path = Path.Combine(internalDir, CRASH_LOG_NAME);
191+
using var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
192+
using var sw = new StreamWriter(fs);
193+
194+
sw.WriteLine();
195+
sw.WriteLine("=========================================================");
196+
sw.WriteLine("=== MANAGED UNHANDLED EXCEPTION ===");
197+
sw.WriteLine($" source = {source}");
198+
sw.WriteLine($" utc_time = {DateTime.UtcNow:O}");
199+
sw.WriteLine($" thread_id = {Environment.CurrentManagedThreadId}");
200+
sw.WriteLine();
201+
sw.WriteLine(ex?.ToString() ?? "<no exception object>");
202+
sw.WriteLine("=== END OF MANAGED EXCEPTION ===");
203+
sw.WriteLine();
204+
sw.Flush();
205+
fs.Flush(true);
206+
}
207+
catch (Exception e)
208+
{
209+
Debug.WriteLine($"[osu!] writeManagedException failed: {e.Message}");
210+
}
211+
}
212+
213+
private static void resolveDirs(Context context)
214+
{
215+
try
216+
{
217+
if (internalDir == null)
218+
{
219+
var f = context.FilesDir;
220+
if (f != null && !string.IsNullOrEmpty(f.AbsolutePath))
221+
internalDir = f.AbsolutePath;
222+
}
223+
}
224+
catch (Exception e) { Debug.WriteLine($"[osu!] Could not resolve internal FilesDir: {e.Message}"); }
225+
226+
try
227+
{
228+
if (externalDir == null)
229+
{
230+
var e = context.GetExternalFilesDir(null);
231+
if (e != null && !string.IsNullOrEmpty(e.AbsolutePath))
232+
externalDir = e.AbsolutePath;
233+
}
234+
}
235+
catch (Exception ex) { Debug.WriteLine($"[osu!] Could not resolve external files dir: {ex.Message}"); }
236+
}
237+
}
238+
}

osu.Android/Native/crash_handler.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,7 @@ static void crashHandler(int sig, siginfo_t* info, void* ucontext) {
935935
if (st.frame == 0) writeStr(fd, " <empty>\n");
936936

937937
writeStr(fd, "=========================================================\n");
938+
writeStr(fd, "=== END OF CRASH DUMP ===\n");
938939

939940
if (fd >= 0) {
940941
fsync(fd);

osu.Android/OsuApplication.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 Android.App;
5+
using Android.Runtime;
6+
7+
namespace osu.Android
8+
{
9+
/// <summary>
10+
/// Custom <see cref="Application"/> subclass that runs before any <see cref="Activity"/>
11+
/// is created. Used to install the native crash handler at the absolute earliest point
12+
/// in the process lifecycle so that crashes occurring during early library load,
13+
/// JNI_OnLoad, or static .NET assembly load are captured to <c>native_crash.log</c>
14+
/// instead of leaving only a 2-frame Android tombstone.
15+
/// </summary>
16+
[Application]
17+
public class OsuApplication : Application
18+
{
19+
public OsuApplication(System.IntPtr handle, JniHandleOwnership transfer)
20+
: base(handle, transfer)
21+
{
22+
}
23+
24+
public override void OnCreate()
25+
{
26+
// Install the native crash handler FIRST — before base.OnCreate runs the
27+
// .NET runtime's own initialisation that may pull in reflection-heavy
28+
// assemblies and crash. The native handler doesn't depend on the .NET
29+
// runtime being fully initialised.
30+
CrashDiagnostics.InstallNativeHandler(this);
31+
CrashDiagnostics.InstallManagedExceptionHooks();
32+
CrashDiagnostics.WriteAliveMarker("Application.OnCreate entry");
33+
34+
base.OnCreate();
35+
}
36+
}
37+
}

osu.Android/OsuGameActivity.cs

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
using System;
1717
using Uri = Android.Net.Uri;
1818
using osu.Android.Input;
19-
using osu.Android.Native;
2019
using osu.Framework.Android;
2120
using osu.Game.Database;
2221
using osu.Framework.Logging;
@@ -84,39 +83,19 @@ public OsuGameActivity()
8483

8584
protected override void OnCreate(Bundle? savedInstanceState)
8685
{
87-
base.OnCreate(savedInstanceState);
88-
89-
// Install the native crash handler as early as we possibly can — before
90-
// anything else in our managed code touches native libraries. Any crash
91-
// after this point (SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT, on any thread)
92-
// will append a symbolicated backtrace to <external-files-dir>/native_crash.log
93-
// *and* mirror it to logcat (`osu!crash`), and then chain to the system
94-
// tombstone handler. This is the only way to obtain a usable crash report
95-
// on unrooted devices where the user cannot run `adb logcat` and the
96-
// built-in tombstone shown in App Info is truncated to two unsymbolicated
97-
// frames. Wrapped in try/catch so a failure here can never itself
98-
// contribute to startup crashes — worst case we simply have no extra
99-
// diagnostic, which is the status quo.
100-
try
101-
{
102-
string? crashLogPath = null;
103-
104-
try
105-
{
106-
var dir = GetExternalFilesDir(null);
107-
if (dir != null && !string.IsNullOrEmpty(dir.AbsolutePath))
108-
crashLogPath = System.IO.Path.Combine(dir.AbsolutePath, "native_crash.log");
109-
}
110-
catch (Exception e) { Debug.WriteLine($"[osu!] Could not resolve external files dir for crash log: {e.Message}"); }
86+
// Crash diagnostics first. The native handler write target is internal storage
87+
// (FilesDir/native_crash.log); a one-shot mirror copies it to external storage
88+
// here on the *next* normal startup so the user can pull it without root.
89+
// OsuApplication.OnCreate already installed both the native handler and the
90+
// managed exception hooks — these calls are idempotent safety nets that cover
91+
// the (vanishingly unlikely) case where the activity is created without our
92+
// Application subclass having run first.
93+
CrashDiagnostics.InstallNativeHandler(this);
94+
CrashDiagnostics.InstallManagedExceptionHooks();
95+
CrashDiagnostics.WriteAliveMarker("Activity.OnCreate entry");
96+
CrashDiagnostics.MirrorInternalLogToExternal();
11197

112-
OboeAudioBridge.nInstallCrashHandler(crashLogPath);
113-
}
114-
catch (Exception e)
115-
{
116-
// Most likely cause: native library not loaded yet (DllNotFoundException).
117-
// That is fine — the crash handler is best-effort diagnostics.
118-
Debug.WriteLine($"[osu!] Failed to install native crash handler: {e.Message}");
119-
}
98+
base.OnCreate(savedInstanceState);
12099

121100
// Wrap Platform.Init defensively: MAUI Essentials pulls in workload-version-sensitive
122101
// initialisation code, and a mismatch between the build-time workload and the device's
@@ -201,6 +180,8 @@ protected override void OnCreate(Bundle? savedInstanceState)
201180
try { Assembly.Load(asm); }
202181
catch (Exception e) { Debug.WriteLine($"[osu!] Failed to load ruleset assembly {asm}: {e.Message}"); }
203182
}
183+
184+
CrashDiagnostics.WriteAliveMarker("Activity.OnCreate exit");
204185
}
205186

206187
protected override void OnNewIntent(Intent? intent) => handleIntent(intent);

osu.Android/OsuGameAndroid.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,8 @@ private void updateOrientation()
760760

761761
public override void SetHost(GameHost host)
762762
{
763+
CrashDiagnostics.WriteAliveMarker("OsuGameAndroid.SetHost (GameHost.Run entry)");
764+
763765
base.SetHost(host);
764766

765767
if (host.Window != null)

0 commit comments

Comments
 (0)