@@ -34,6 +34,22 @@ internal static class CrashDiagnostics
3434 public const string CRASH_LOG_NAME = "native_crash.log" ;
3535 public const string SENTINEL_NAME = "crash_handler_installed.txt" ;
3636
37+ // Hard size cap on a single native_crash.log file. When reached we rotate the
38+ // file to "<name>.1" (overwriting any previous backup) and start a fresh log.
39+ // This bounds *each* of the internal and external locations to ~2× the cap
40+ // worst-case, regardless of how many crash-restart cycles the device endures.
41+ //
42+ // The cap exists to defeat the failure mode observed in the field where a
43+ // tight ANR-restart loop produced ~480 MB of native_crash.log on the user's
44+ // device storage in a few hours — every restart appended the previous
45+ // process's HangWatchdog dumps to the external log via
46+ // MirrorInternalLogToExternal, with no upper bound. 3 MiB is enough to hold
47+ // ~6 full HangWatchdog hang dumps including the per-thread /proc snapshot,
48+ // i.e. comfortably more than one process's worth of evidence after the new
49+ // HangWatchdog cap (max_dumps_per_process=20) is applied.
50+ private const long native_crash_log_max_bytes = 3L * 1024 * 1024 ;
51+ private const string crash_log_backup_suffix = ".1" ;
52+
3753 private static int initialised ;
3854 private static int managedHooksInstalled ;
3955
@@ -167,6 +183,17 @@ public static void MirrorInternalLogToExternal()
167183
168184 string externalPath = Path . Combine ( externalDir , CRASH_LOG_NAME ) ;
169185
186+ // Defeat the unbounded-growth failure mode: a tight ANR-restart
187+ // loop calls MirrorInternalLogToExternal on every startup, each
188+ // of which appends the previous process's full HangWatchdog
189+ // dump set to the external log. Without this rotation the
190+ // external file grew to hundreds of MB on the user's device
191+ // (one report: 480 MB across a single afternoon). Rotating
192+ // *before* the append guarantees the resulting file is at most
193+ // <native_crash_log_max_bytes + this_payload_size>, and a
194+ // single ".1" backup retains the previous generation.
195+ rotateIfTooLarge ( externalPath ) ;
196+
170197 try
171198 {
172199 // Append, not overwrite — keep external as the running historical log.
@@ -379,6 +406,14 @@ private static void tryAppend(string? dir, string payload)
379406 try
380407 {
381408 string path = Path . Combine ( dir , CRASH_LOG_NAME ) ;
409+
410+ // Bound the file size before opening for append. A pathological
411+ // crash-restart loop would otherwise write hundreds of MB into
412+ // this single file — the rotation cap (one historical backup,
413+ // each ≤ native_crash_log_max_bytes) keeps the worst-case at
414+ // ~2× the cap regardless of how long the loop runs.
415+ rotateIfTooLarge ( path ) ;
416+
382417 using var fs = new FileStream ( path , FileMode . Append , FileAccess . Write , FileShare . ReadWrite ) ;
383418 using var sw = new StreamWriter ( fs ) ;
384419 sw . Write ( payload ) ;
@@ -390,6 +425,44 @@ private static void tryAppend(string? dir, string payload)
390425 }
391426 }
392427
428+ // If <path> exists and is at or above the size cap, move it to
429+ // "<path>.1" (overwriting any previous backup) so the next write starts
430+ // a fresh file. Best-effort and never throws — diagnostics paths must
431+ // not introduce new failure modes.
432+ private static void rotateIfTooLarge ( string path )
433+ {
434+ try
435+ {
436+ if ( ! File . Exists ( path ) ) return ;
437+
438+ long length ;
439+ try { length = new FileInfo ( path ) . Length ; }
440+ catch { return ; }
441+
442+ if ( length < native_crash_log_max_bytes ) return ;
443+
444+ string backup = path + crash_log_backup_suffix ;
445+
446+ try { if ( File . Exists ( backup ) ) File . Delete ( backup ) ; }
447+ catch ( Exception e ) { Debug . WriteLine ( $ "[osu!] CrashDiagnostics.rotateIfTooLarge: could not delete prior backup { backup } : { e . Message } ") ; }
448+
449+ try { File . Move ( path , backup ) ; }
450+ catch ( Exception e )
451+ {
452+ // If rename fails (e.g. cross-device on some FUSE setups),
453+ // fall back to in-place truncation rather than leaving the
454+ // file unbounded.
455+ Debug . WriteLine ( $ "[osu!] CrashDiagnostics.rotateIfTooLarge: rename failed ({ e . Message } ); truncating in place") ;
456+ try { File . WriteAllText ( path , string . Empty ) ; }
457+ catch ( Exception inner ) { Debug . WriteLine ( $ "[osu!] CrashDiagnostics.rotateIfTooLarge: truncate also failed: { inner . Message } ") ; }
458+ }
459+ }
460+ catch ( Exception e )
461+ {
462+ Debug . WriteLine ( $ "[osu!] CrashDiagnostics.rotateIfTooLarge outer failure for { path } : { e . Message } ") ;
463+ }
464+ }
465+
393466 private static void resolveDirs ( Context context )
394467 {
395468 try
0 commit comments