Skip to content

Commit 0f966a7

Browse files
android: capture JIT-thread SIGSEGV by chaining handler on top of Mono
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/b1f176a6-7fdf-42cc-86ea-a90cf728ffa2 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 36e6edd commit 0f966a7

6 files changed

Lines changed: 188 additions & 40 deletions

File tree

osu.Android/CrashDiagnostics.cs

Lines changed: 133 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ namespace osu.Android
1515
/// <summary>
1616
/// Centralised Android crash-diagnostics plumbing.
1717
///
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.
18+
/// We write everything to <b>both</b> internal app storage (<c>FilesDir</c>) and external
19+
/// app storage (<c>GetExternalFilesDir(null)</c>) when both are available. Internal is the
20+
/// reliable target for the very-early window where external storage may not yet be ready;
21+
/// external is reachable by the user via the Files app on an unrooted device and receives
22+
/// alive markers / managed-exception dumps in real time so the user does not have to wait
23+
/// for a successful next startup to mirror the data over.
2324
///
24-
/// Files (all relative to <c>FilesDir</c>):
25+
/// Files (relative to each storage dir):
2526
/// <list type="bullet">
2627
/// <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>
2728
/// <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>
@@ -37,6 +38,9 @@ internal static class CrashDiagnostics
3738

3839
private static string? internalDir;
3940
private static string? externalDir;
41+
private static string? sentinelPath;
42+
private static string? installedLogPath;
43+
private static bool sentinelWritten;
4044

4145
/// <summary>
4246
/// Installs the native crash handler against the internal-storage log path, drops the
@@ -51,22 +55,24 @@ public static void InstallNativeHandler(Context context)
5155
{
5256
resolveDirs(context);
5357

54-
string? logPath = internalDir != null ? Path.Combine(internalDir, CRASH_LOG_NAME) : null;
58+
installedLogPath = internalDir != null ? Path.Combine(internalDir, CRASH_LOG_NAME) : null;
5559

5660
// The native handler is best-effort. Wrap so a DllNotFoundException
5761
// (libosu_native.so missing from the APK) cannot itself crash us.
5862
try
5963
{
60-
OboeAudioBridge.nInstallCrashHandler(logPath);
64+
OboeAudioBridge.nInstallCrashHandler(installedLogPath);
6165

6266
// Sentinel: only written when nInstallCrashHandler returned without throwing.
6367
if (internalDir != null)
6468
{
6569
try
6670
{
71+
sentinelPath = Path.Combine(internalDir, SENTINEL_NAME);
6772
File.WriteAllText(
68-
Path.Combine(internalDir, SENTINEL_NAME),
69-
$"installed_at={DateTime.UtcNow:O}\nlog_path={logPath ?? "<none>"}\n");
73+
sentinelPath,
74+
$"installed_at={DateTime.UtcNow:O}\nlog_path={installedLogPath ?? "<none>"}\n");
75+
sentinelWritten = true;
7076
}
7177
catch (Exception e) { Debug.WriteLine($"[osu!] Could not write crash-handler sentinel: {e.Message}"); }
7278
}
@@ -87,31 +93,38 @@ public static void InstallNativeHandler(Context context)
8793
}
8894

8995
/// <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.
96+
/// Re-install the native signal handlers from a later startup phase, after the Mono
97+
/// runtime has installed its own SIGSEGV handler. This is what actually lets us catch
98+
/// JIT-thread null-deref crashes — without it, Mono's handler intercepts the fault
99+
/// first and re-raises via <c>tgkill</c> (visible in tombstones as
100+
/// <c>si_code = SI_TKILL</c>) without ever forwarding to us.
93101
/// </summary>
94-
public static void WriteAliveMarker(string phase)
102+
public static void ReinstallNativeHandler()
95103
{
96104
try
97105
{
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();
106+
OboeAudioBridge.nReinstallCrashHandler();
107+
WriteAliveMarker("CrashDiagnostics.ReinstallNativeHandler (chained on top of Mono)");
108108
}
109109
catch (Exception e)
110110
{
111-
Debug.WriteLine($"[osu!] WriteAliveMarker({phase}) failed: {e.Message}");
111+
Debug.WriteLine($"[osu!] nReinstallCrashHandler P/Invoke failed: {e.Message}");
112112
}
113113
}
114114

115+
/// <summary>
116+
/// Append a single-line "I am alive" marker to the crash log so that, when we later
117+
/// inspect a truncated/empty file after a crash, the last-written marker pinpoints
118+
/// which startup phase died. Writes to both internal and external storage so the user
119+
/// can pull the file immediately without waiting for a successful next startup to
120+
/// mirror it over.
121+
/// </summary>
122+
public static void WriteAliveMarker(string phase)
123+
{
124+
string line = $"=== ALIVE [{DateTime.UtcNow:O}] {phase} ===\n";
125+
appendToBoth(line);
126+
}
127+
115128
/// <summary>
116129
/// One-shot post-crash mirror: if an internal <c>native_crash.log</c> exists and is
117130
/// non-empty, copy it to external app storage (so the user can pull it via the Files
@@ -179,34 +192,114 @@ public static void InstallManagedExceptionHooks()
179192
writeManagedException("TaskScheduler.UnobservedTaskException", e.Exception);
180193
// Don't mark observed — the framework / sentry pipeline still wants to see it.
181194
};
195+
196+
// FirstChanceException fires for *every* managed exception, even ones that get
197+
// caught later. On non-main managed threads (e.g. the Draw thread), Mono on
198+
// Android does not always route an unhandled exception through
199+
// AppDomain.UnhandledException before aborting — so without this hook the
200+
// exception that ultimately kills the process can vanish without trace. We
201+
// record it here on every throw so the *last* recorded exception before a
202+
// SIGSEGV/SIGABRT is the candidate culprit. To avoid drowning the log in noise
203+
// we filter by exception type — only fatal-ish kinds are recorded.
204+
try
205+
{
206+
AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
207+
{
208+
if (e.Exception is NullReferenceException
209+
or AccessViolationException
210+
or StackOverflowException
211+
or TypeInitializationException
212+
or DllNotFoundException
213+
or EntryPointNotFoundException
214+
or BadImageFormatException
215+
or TypeLoadException
216+
or MissingMethodException
217+
or MissingFieldException
218+
or InvalidProgramException)
219+
{
220+
writeManagedException($"FirstChanceException ({e.Exception.GetType().Name})", e.Exception);
221+
}
222+
};
223+
}
224+
catch (Exception e)
225+
{
226+
Debug.WriteLine($"[osu!] Could not install FirstChanceException hook: {e.Message}");
227+
}
228+
}
229+
230+
/// <summary>
231+
/// Records a one-line summary of the native handler install state (sentinel exists?
232+
/// log path?) so the very first thing we see in the log on the next inspection tells
233+
/// us whether the native handler is even in place.
234+
/// </summary>
235+
public static void WriteInstallState()
236+
{
237+
try
238+
{
239+
string sentinelState;
240+
241+
if (sentinelWritten && sentinelPath != null && File.Exists(sentinelPath))
242+
sentinelState = "present";
243+
else if (sentinelWritten)
244+
sentinelState = "written-but-missing";
245+
else
246+
sentinelState = "absent";
247+
248+
appendToBoth($"=== INSTALL_STATE sentinel={sentinelState} log_path={installedLogPath ?? "<none>"} internal_dir={internalDir ?? "<none>"} external_dir={externalDir ?? "<none>"} ===\n");
249+
}
250+
catch (Exception e)
251+
{
252+
Debug.WriteLine($"[osu!] WriteInstallState failed: {e.Message}");
253+
}
182254
}
183255

184256
private static void writeManagedException(string source, Exception? ex)
185257
{
186258
try
187259
{
188-
if (internalDir == null) return;
260+
string block =
261+
"\n=========================================================\n" +
262+
"=== MANAGED EXCEPTION ===\n" +
263+
$" source = {source}\n" +
264+
$" utc_time = {DateTime.UtcNow:O}\n" +
265+
$" thread_id = {Environment.CurrentManagedThreadId}\n" +
266+
$" thread_name= {Thread.CurrentThread.Name ?? "<null>"}\n" +
267+
"\n" +
268+
(ex?.ToString() ?? "<no exception object>") + "\n" +
269+
"=== END OF MANAGED EXCEPTION ===\n\n";
270+
appendToBoth(block);
271+
}
272+
catch (Exception e)
273+
{
274+
Debug.WriteLine($"[osu!] writeManagedException failed: {e.Message}");
275+
}
276+
}
277+
278+
// Append the same payload to both internal (FilesDir) and external (GetExternalFilesDir)
279+
// crash logs. Either may legitimately be unavailable; failure of one path must not
280+
// prevent the other from being written. Each write is bounded, non-blocking, and
281+
// never throws out of this method — diagnostics must never themselves crash.
282+
private static void appendToBoth(string payload)
283+
{
284+
tryAppend(internalDir, payload);
285+
tryAppend(externalDir, payload);
286+
}
189287

190-
string path = Path.Combine(internalDir, CRASH_LOG_NAME);
288+
private static void tryAppend(string? dir, string payload)
289+
{
290+
if (dir == null) return;
291+
292+
try
293+
{
294+
string path = Path.Combine(dir, CRASH_LOG_NAME);
191295
using var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
192296
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();
297+
sw.Write(payload);
204298
sw.Flush();
205-
fs.Flush(true);
206299
}
207300
catch (Exception e)
208301
{
209-
Debug.WriteLine($"[osu!] writeManagedException failed: {e.Message}");
302+
Debug.WriteLine($"[osu!] CrashDiagnostics.tryAppend({dir}) failed: {e.Message}");
210303
}
211304
}
212305

osu.Android/Native/OboeAudioBridge.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,5 +216,6 @@ public void Dispose()
216216
[DllImport(lib_name)] internal static extern void nADPFUpdateTargetDuration(IntPtr sessionPtr, long targetDurationNanos);
217217
[DllImport(lib_name)] internal static extern void nADPFCloseSession(IntPtr sessionPtr);
218218
[DllImport(lib_name)] internal static extern void nInstallCrashHandler([MarshalAs(UnmanagedType.LPUTF8Str)] string? logPath);
219+
[DllImport(lib_name)] internal static extern void nReinstallCrashHandler();
219220
}
220221
}

osu.Android/Native/crash_handler.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,4 +1006,40 @@ void nInstallCrashHandler(const char* logPath) {
10061006
g_logPath[0] ? g_logPath : "<none, logcat only>");
10071007
}
10081008

1009+
// Re-install the signal handlers without short-circuiting on g_installed.
1010+
// Mono installs its own SIGSEGV handler later in startup (after activity
1011+
// OnCreate), which sits in front of ours and intercepts JIT null-deref
1012+
// faults — re-raising via tgkill when it cannot translate them, which
1013+
// bypasses our dump. Calling this from a later phase (e.g. GameHost.Run)
1014+
// puts our handler back on top of the chain, with Mono's saved as the
1015+
// previous handler so chaining still works.
1016+
__attribute__((visibility("default")))
1017+
void nReinstallCrashHandler() {
1018+
// Re-install alt stack (cheap; idempotent on the same buffer).
1019+
stack_t ss{};
1020+
ss.ss_sp = g_altStack;
1021+
ss.ss_size = kAltStackSize;
1022+
ss.ss_flags = 0;
1023+
sigaltstack(&ss, nullptr);
1024+
1025+
struct sigaction sa{};
1026+
sa.sa_sigaction = &crashHandler;
1027+
sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART;
1028+
sigemptyset(&sa.sa_mask);
1029+
1030+
for (size_t i = 0; i < kNumSignals; ++i) {
1031+
// Overwrite previous-handler slot with whatever is currently
1032+
// installed (typically Mono's handler at this point), so when our
1033+
// handler chains, it forwards to Mono rather than to our own
1034+
// already-saved entry.
1035+
sigaction(kSignals[i], &sa, &g_prevHandlers[i]);
1036+
}
1037+
1038+
g_installed = 1;
1039+
1040+
__android_log_print(ANDROID_LOG_INFO, CRASH_LOG_TAG,
1041+
"Crash handler re-installed (logPath=%s)",
1042+
g_logPath[0] ? g_logPath : "<none, logcat only>");
1043+
}
1044+
10091045
} // extern "C"

osu.Android/Native/crash_handler.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,13 @@ extern "C" {
99
// external files directory). Pass nullptr to log only to logcat.
1010
// Idempotent: subsequent calls after the first are no-ops.
1111
void nInstallCrashHandler(const char* logPath);
12+
13+
// Re-install the signal handlers, even if a previous install already ran.
14+
// Use this after the Mono runtime is fully up so our handler chains *on top
15+
// of* Mono's SIGSEGV handler — otherwise Mono intercepts JIT-NRE faults
16+
// first and re-raises via `tgkill` (which appears in tombstones as
17+
// `si_code = SI_TKILL`), bypassing our dump entirely. The previously-saved
18+
// "previous handler" slot is overwritten with whatever is currently
19+
// installed (typically Mono's), so chaining still works.
20+
void nReinstallCrashHandler();
1221
}

osu.Android/OsuGameActivity.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ protected override void OnCreate(Bundle? savedInstanceState)
9494
CrashDiagnostics.InstallNativeHandler(this);
9595
CrashDiagnostics.InstallManagedExceptionHooks();
9696
CrashDiagnostics.WriteAliveMarker("Activity.OnCreate entry");
97+
CrashDiagnostics.WriteInstallState();
9798
CrashDiagnostics.MirrorInternalLogToExternal();
9899

99100
base.OnCreate(savedInstanceState);

osu.Android/OsuGameAndroid.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,8 +762,16 @@ public override void SetHost(GameHost host)
762762
{
763763
CrashDiagnostics.WriteAliveMarker("OsuGameAndroid.SetHost (GameHost.Run entry)");
764764

765+
// Re-install the native crash handler now that the Mono runtime has had a chance
766+
// to install its own SIGSEGV handler. Without this, Mono sits in front of us in
767+
// the chain and intercepts JIT null-deref faults — re-raising via tgkill (visible
768+
// as si_code = SI_TKILL in tombstones) without forwarding to our dump.
769+
CrashDiagnostics.ReinstallNativeHandler();
770+
765771
base.SetHost(host);
766772

773+
CrashDiagnostics.WriteAliveMarker("OsuGameAndroid.SetHost (base.SetHost returned)");
774+
767775
if (host.Window != null)
768776
host.Window.CursorState |= CursorState.Hidden;
769777
}

0 commit comments

Comments
 (0)