@@ -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} \n log_path={ logPath ?? "<none>" } \n ") ;
73+ sentinelPath ,
74+ $ "installed_at={ DateTime . UtcNow : O} \n log_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
0 commit comments