@@ -37,9 +37,25 @@ internal static class CrashDiagnostics
3737 private static int initialised ;
3838 private static int managedHooksInstalled ;
3939
40+ // Global cap on FirstChanceException dumps written per process. A hot-path
41+ // throw loop (e.g. Veldrid "surface lost" thrown every Draw frame while the
42+ // Android Vulkan surface is unavailable during a slow startup) can otherwise
43+ // produce hundreds of full-stack dumps, each one a synchronous file write
44+ // on the throwing thread — which itself stalls the Draw thread and worsens
45+ // the very condition causing the throws.
46+ private const int first_chance_global_cap = 50 ;
47+
48+ // Per-unique-stack cap. Higher (10) for true fatal kinds caught via
49+ // FirstChanceException-fallback or AppDomain.UnhandledException; lower (3)
50+ // for first-chance noise where seeing the first few occurrences is enough
51+ // to diagnose and the rest are pure log bloat.
52+ private const int per_key_cap_default = 10 ;
53+ private const int per_key_cap_first_chance = 3 ;
54+
4055 private static string ? internalDir ;
4156 private static string ? externalDir ;
4257 private static readonly ConcurrentDictionary < string , int > exceptionCounts = new ConcurrentDictionary < string , int > ( ) ;
58+ private static int firstChanceWriteCount ;
4359 private static string ? sentinelPath ;
4460 private static string ? installedLogPath ;
4561 private static bool sentinelWritten ;
@@ -290,8 +306,18 @@ private static void writeManagedException(string source, Exception? ex)
290306 if ( ex is EntryPointNotFoundException && ex . Message . Contains ( "CFStringCreateWithCharacters" ) )
291307 return ;
292308
309+ bool isFirstChance = source . StartsWith ( "FirstChanceException" , StringComparison . Ordinal ) ;
310+
311+ // Global cap on first-chance dumps: a hot-path throw loop on the Draw
312+ // thread can otherwise produce unbounded synchronous file writes, which
313+ // themselves stall the Draw thread and worsen the surface-acquisition
314+ // problem that caused the throws.
315+ if ( isFirstChance && Interlocked . Increment ( ref firstChanceWriteCount ) > first_chance_global_cap )
316+ return ;
317+
318+ int perKeyCap = isFirstChance ? per_key_cap_first_chance : per_key_cap_default ;
293319 string key = $ "{ source } _{ ex ? . GetType ( ) . Name } _{ ex ? . StackTrace ? . GetHashCode ( ) ?? 0 } ";
294- if ( exceptionCounts . AddOrUpdate ( key , 1 , ( _ , count ) => count + 1 ) > 10 )
320+ if ( exceptionCounts . AddOrUpdate ( key , 1 , ( _ , count ) => count + 1 ) > perKeyCap )
295321 return ;
296322
297323 try
@@ -306,7 +332,17 @@ private static void writeManagedException(string source, Exception? ex)
306332 "\n " +
307333 ( ex ? . ToString ( ) ?? "<no exception object>" ) + "\n " +
308334 "=== END OF MANAGED EXCEPTION ===\n \n " ;
309- appendToBoth ( block ) ;
335+
336+ // For FirstChanceException we deliberately skip the external/FUSE
337+ // write — those writes are tens of milliseconds each and run on the
338+ // throwing thread (often the Draw thread). MirrorInternalLogToExternal
339+ // copies the internal log to external on the next startup, which is
340+ // sufficient for user-facing diagnostics without risking a Draw-thread
341+ // stall in the live process.
342+ if ( isFirstChance )
343+ tryAppend ( internalDir , block ) ;
344+ else
345+ appendToBoth ( block ) ;
310346 }
311347 catch ( Exception e )
312348 {
0 commit comments