@@ -359,13 +359,32 @@ await Task.WhenAll(uris.Select(async uri =>
359359 } ) ;
360360
361361 private readonly System . Threading . ManualResetEventSlim surfaceEvent = new System . Threading . ManualResetEventSlim ( false ) ;
362+
363+ // Hold both the JNI global ref AND the managed Surface peer alive against the
364+ // SurfaceView lifecycle. The global ref alone is NOT enough — .NET-for-Android
365+ // tracks managed peers separately, and once the local `Surface` returned by
366+ // `holder.Surface` becomes GC-eligible (i.e. once SurfaceCreated returns), the
367+ // peer's finaliser will release the underlying Java Surface even though we still
368+ // hold a global ref to its handle. The next time the SDL thread tries to use that
369+ // handle through JNI we crash with SIGSEGV inside libart.so on the SDLActivity
370+ // thread (see native_crash.log). Storing the wrapper in a managed field roots the
371+ // peer for the SurfaceView's entire lifetime.
372+ //
373+ // SurfaceCreated and SurfaceDestroyed are serialised against each other via
374+ // `surfaceLock` so the SDL/Veldrid backend can never observe a half-torn-down
375+ // state (e.g. global ref present but managed peer already released, or vice
376+ // versa). The handle reader uses Volatile.Read for an unlocked fast path on hot
377+ // call sites and a locked slow path is unnecessary because all writes happen
378+ // under the lock and Interlocked.Exchange / Volatile.Write are release barriers.
379+ private readonly object surfaceLock = new object ( ) ;
380+ private global ::Android . Views . Surface ? heldSurface ;
362381 private IntPtr surfaceGlobalRef ;
363382
364383 public IntPtr GetSurfaceGlobalRef ( )
365384 {
366385 if ( ! surfaceEvent . Wait ( 5000 ) )
367386 Debug . WriteLine ( "[osu!] Warning: Wait for surface timed out" ) ;
368- return surfaceGlobalRef ;
387+ return System . Threading . Volatile . Read ( ref surfaceGlobalRef ) ;
369388 }
370389
371390 public SurfaceView ? GetSurface ( ) => findSurfaceView ( Window ? . DecorView ) ;
@@ -387,19 +406,33 @@ public IntPtr GetSurfaceGlobalRef()
387406 public void SurfaceCreated ( ISurfaceHolder holder )
388407 {
389408 var surface = holder . Surface ;
390- if ( surface != null && surface . IsValid )
391- {
392- IntPtr handle = surface . Handle ;
393- if ( handle == IntPtr . Zero ) return ;
409+ if ( surface == null || ! surface . IsValid )
410+ return ;
411+
412+ IntPtr handle = surface . Handle ;
413+ if ( handle == IntPtr . Zero )
414+ return ;
394415
395- IntPtr newRef = global ::Android . Runtime . JNIEnv . NewGlobalRef ( handle ) ;
416+ IntPtr newRef = global ::Android . Runtime . JNIEnv . NewGlobalRef ( handle ) ;
417+
418+ lock ( surfaceLock )
419+ {
420+ // Establish the new managed root BEFORE publishing the new global ref so
421+ // that any reader that observes the new ref already has its managed peer
422+ // pinned. Then atomically swap in the new ref and release the previous one.
423+ var oldHeld = heldSurface ;
424+ heldSurface = surface ;
396425
397- // Atomically swap the old reference to prevent race with SurfaceDestroyed.
398426 IntPtr oldRef = System . Threading . Interlocked . Exchange ( ref surfaceGlobalRef , newRef ) ;
399427
400428 if ( oldRef != IntPtr . Zero )
401429 global ::Android . Runtime . JNIEnv . DeleteGlobalRef ( oldRef ) ;
402430
431+ // Drop the previous managed root only AFTER its global ref is gone, so
432+ // there is no window where consumers can hold a stale global ref pointing
433+ // into a Java peer whose .NET wrapper has been disposed.
434+ oldHeld ? . Dispose ( ) ;
435+
403436 Debug . WriteLine ( "[osu!] Native surface JNI global reference created (waiting for SurfaceChanged for signal)" ) ;
404437 }
405438 }
@@ -420,12 +453,28 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma
420453
421454 public void SurfaceDestroyed ( ISurfaceHolder holder )
422455 {
423- IntPtr oldRef = System . Threading . Interlocked . Exchange ( ref surfaceGlobalRef , IntPtr . Zero ) ;
456+ // Block any concurrent SurfaceCreated so the SDL/Veldrid thread can never
457+ // observe a partial state where the global ref has been freed but the
458+ // managed peer is still alive (or the inverse).
459+ lock ( surfaceLock )
460+ {
461+ // Reset the readiness signal first so any waiter blocks until a new
462+ // surface is published, rather than racing with the teardown below.
463+ surfaceEvent . Reset ( ) ;
464+
465+ // Release the global ref BEFORE dropping the managed root, never the
466+ // other way around: once the .NET wrapper is disposed the underlying
467+ // Java Surface may be released, and any subsequent JNI use of an
468+ // outstanding global ref to that handle would segfault. Order here
469+ // mirrors the inverse of SurfaceCreated.
470+ IntPtr oldRef = System . Threading . Interlocked . Exchange ( ref surfaceGlobalRef , IntPtr . Zero ) ;
424471
425- if ( oldRef != IntPtr . Zero )
426- global ::Android . Runtime . JNIEnv . DeleteGlobalRef ( oldRef ) ;
472+ if ( oldRef != IntPtr . Zero )
473+ global ::Android . Runtime . JNIEnv . DeleteGlobalRef ( oldRef ) ;
427474
428- surfaceEvent . Reset ( ) ;
475+ heldSurface ? . Dispose ( ) ;
476+ heldSurface = null ;
477+ }
429478 }
430479
431480 public override void OnConfigurationChanged ( Configuration newConfig )
0 commit comments