Skip to content

Commit ec17910

Browse files
Copilotwinnerspiros
andcommitted
Fix Android crashes: thread safety in updateOrientation, deferred native type loading in bridge manager, robust Dispose
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/8f3ef8e7-b72e-406b-889b-266873678699 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 62acf4a commit ec17910

2 files changed

Lines changed: 97 additions & 55 deletions

File tree

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 81 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// See the LICENCE file in the repository root for full licence text.
33

44
using System;
5+
using System.Runtime.CompilerServices;
56
using osu.Android.Native;
67
using osu.Framework.Threading;
78
using Debug = System.Diagnostics.Debug;
@@ -10,40 +11,48 @@ namespace osu.Android
1011
{
1112
/// <summary>
1213
/// Encapsulates all native bridge lifecycle management (Oboe audio, Vulkan probe).
13-
/// Kept in a SEPARATE class so that <see cref="OboeAudioBridge"/> and <see cref="VulkanProbe"/>
14-
/// types are only loaded by the runtime when this class is first accessed — NOT during
15-
/// <see cref="OsuGameAndroid"/> class initialization, which happens before the framework
16-
/// is ready and before native libraries are expected to be available.
14+
/// Field types are declared as <c>object?</c> and all access is through
15+
/// <c>[MethodImpl(NoInlining)]</c> helpers so that <see cref="OboeAudioBridge"/> and
16+
/// <see cref="VulkanProbe"/> are only resolved by the runtime when their specific
17+
/// feature is enabled — not when this class is loaded. This prevents Samsung-device
18+
/// crashes caused by <c>NativeLibrary.TryLoad</c> being called during class
19+
/// initialisation before the framework is ready.
1720
/// </summary>
1821
internal sealed class AndroidNativeBridgeManager : IDisposable
1922
{
20-
private OboeAudioBridge? oboeBridge;
21-
private VulkanProbe? vulkanProbe;
23+
/// <summary>Boxed <see cref="OboeAudioBridge"/> — keeps the type out of class init.</summary>
24+
private object? oboeBridge;
25+
26+
/// <summary>Boxed <see cref="VulkanProbe"/> — keeps the type out of class init.</summary>
27+
private object? vulkanProbe;
28+
2229
private volatile bool disposed;
2330

31+
// ── Oboe ───────────────────────────────────────────────────────────
32+
33+
[MethodImpl(MethodImplOptions.NoInlining)]
2434
public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasured)
2535
{
2636
if (oboeBridge != null) return;
2737

2838
try
2939
{
30-
oboeBridge = OboeAudioBridge.Create();
40+
var bridge = OboeAudioBridge.Create();
3141

32-
if (oboeBridge != null)
42+
if (bridge != null)
3343
{
34-
bool started = oboeBridge.Start();
44+
oboeBridge = bridge;
45+
bool started = bridge.Start();
3546

3647
if (started)
3748
{
38-
logOboeInfo();
49+
logOboeInfo(bridge);
3950

40-
// Latency is measured asynchronously by the audio callback.
41-
// Schedule a check after a short warm-up period to get a stable reading.
4251
scheduler.AddDelayed(() =>
4352
{
44-
if (oboeBridge == null) return;
53+
if (oboeBridge is not OboeAudioBridge b) return;
4554

46-
double latency = oboeBridge.GetOutputLatencyMs();
55+
double latency = b.GetOutputLatencyMs();
4756
Debug.WriteLine($"[osu!] Oboe measured latency after warm-up: {latency:F1}ms");
4857

4958
if (latency > 0)
@@ -62,86 +71,110 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
6271
}
6372
}
6473

74+
[MethodImpl(MethodImplOptions.NoInlining)]
6575
public void StopOboeBridge()
6676
{
67-
oboeBridge?.Dispose();
77+
(oboeBridge as OboeAudioBridge)?.Dispose();
6878
oboeBridge = null;
6979
Debug.WriteLine("[osu!] Oboe bridge stopped by user setting");
7080
}
7181

82+
[MethodImpl(MethodImplOptions.NoInlining)]
83+
public double GetMeasuredAudioLatencyMs()
84+
{
85+
return (oboeBridge as OboeAudioBridge)?.GetOutputLatencyMs() ?? -1;
86+
}
87+
88+
// ── Vulkan ─────────────────────────────────────────────────────────
89+
90+
[MethodImpl(MethodImplOptions.NoInlining)]
7291
public void StartVulkanProbe()
7392
{
7493
if (vulkanProbe != null) return;
7594

7695
try
7796
{
78-
vulkanProbe = VulkanProbe.Create();
97+
var probe = VulkanProbe.Create();
7998

80-
if (vulkanProbe != null)
81-
logVulkanInfo();
99+
if (probe != null)
100+
{
101+
vulkanProbe = probe;
102+
logVulkanInfo(probe);
103+
}
82104
}
83105
catch (Exception e)
84106
{
85107
Debug.WriteLine($"[osu!] Vulkan probe init failed: {e.Message}");
86108
}
87109
}
88110

111+
[MethodImpl(MethodImplOptions.NoInlining)]
89112
public void StopVulkanProbe()
90113
{
91-
vulkanProbe?.Dispose();
114+
(vulkanProbe as VulkanProbe)?.Dispose();
92115
vulkanProbe = null;
93116
Debug.WriteLine("[osu!] Vulkan probe stopped by user setting");
94117
}
95118

96-
/// <summary>
97-
/// Returns the measured audio output latency in milliseconds via the Oboe bridge,
98-
/// or -1 if unavailable.
99-
/// </summary>
100-
public double GetMeasuredAudioLatencyMs()
101-
{
102-
return oboeBridge?.GetOutputLatencyMs() ?? -1;
103-
}
119+
// ── Logging ────────────────────────────────────────────────────────
104120

105-
private void logVulkanInfo()
121+
[MethodImpl(MethodImplOptions.NoInlining)]
122+
private static void logVulkanInfo(VulkanProbe probe)
106123
{
107-
if (vulkanProbe == null) return;
108-
109-
int ver = vulkanProbe.ApiVersion;
124+
int ver = probe.ApiVersion;
110125
int major = (ver >> 22) & 0x3FF;
111126
int minor = (ver >> 12) & 0x3FF;
112127
int patch = ver & 0xFFF;
113128

114-
Debug.WriteLine($"[osu!] Vulkan GPU: available={vulkanProbe.IsAvailable}, "
129+
Debug.WriteLine($"[osu!] Vulkan GPU: available={probe.IsAvailable}, "
115130
+ $"API={major}.{minor}.{patch}, "
116-
+ $"swapchain={vulkanProbe.SupportsSwapchain}, "
117-
+ $"mailbox={vulkanProbe.SupportsMailboxPresentMode}, "
118-
+ $"VRAM={vulkanProbe.DeviceLocalMemoryMB}MB, "
119-
+ $"queueFamilies={vulkanProbe.QueueFamilyCount}, "
120-
+ $"dedicatedCompute={vulkanProbe.HasDedicatedComputeQueue}, "
121-
+ $"dedicatedTransfer={vulkanProbe.HasDedicatedTransferQueue}");
131+
+ $"swapchain={probe.SupportsSwapchain}, "
132+
+ $"mailbox={probe.SupportsMailboxPresentMode}, "
133+
+ $"VRAM={probe.DeviceLocalMemoryMB}MB, "
134+
+ $"queueFamilies={probe.QueueFamilyCount}, "
135+
+ $"dedicatedCompute={probe.HasDedicatedComputeQueue}, "
136+
+ $"dedicatedTransfer={probe.HasDedicatedTransferQueue}");
122137
}
123138

124-
private void logOboeInfo()
139+
[MethodImpl(MethodImplOptions.NoInlining)]
140+
private static void logOboeInfo(OboeAudioBridge bridge)
125141
{
126-
if (oboeBridge == null) return;
127-
128-
Debug.WriteLine($"[osu!] Oboe audio: active={oboeBridge.IsActive}, "
129-
+ $"api={(oboeBridge.IsAAudio ? "AAudio" : "OpenSLES")}, "
130-
+ $"sampleRate={oboeBridge.SampleRate}Hz, "
131-
+ $"burst={oboeBridge.FramesPerBurst}frames, "
132-
+ $"bufferSize={oboeBridge.BufferSizeInFrames}frames");
142+
Debug.WriteLine($"[osu!] Oboe audio: active={bridge.IsActive}, "
143+
+ $"api={(bridge.IsAAudio ? "AAudio" : "OpenSLES")}, "
144+
+ $"sampleRate={bridge.SampleRate}Hz, "
145+
+ $"burst={bridge.FramesPerBurst}frames, "
146+
+ $"bufferSize={bridge.BufferSizeInFrames}frames");
133147
}
134148

149+
// ── Cleanup ────────────────────────────────────────────────────────
150+
151+
[MethodImpl(MethodImplOptions.NoInlining)]
135152
public void Dispose()
136153
{
137154
if (disposed) return;
138155

139156
disposed = true;
140157

141-
oboeBridge?.Dispose();
158+
try
159+
{
160+
(oboeBridge as OboeAudioBridge)?.Dispose();
161+
}
162+
catch (Exception e)
163+
{
164+
Debug.WriteLine($"[osu!] Oboe dispose failed: {e.Message}");
165+
}
166+
142167
oboeBridge = null;
143168

144-
vulkanProbe?.Dispose();
169+
try
170+
{
171+
(vulkanProbe as VulkanProbe)?.Dispose();
172+
}
173+
catch (Exception e)
174+
{
175+
Debug.WriteLine($"[osu!] Vulkan dispose failed: {e.Message}");
176+
}
177+
145178
vulkanProbe = null;
146179
}
147180
}

osu.Android/OsuGameAndroid.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,18 @@ protected override void ScreenChanged(IOsuScreen? current, IOsuScreen? newScreen
304304

305305
private void updateOrientation()
306306
{
307+
// Read framework state on the update thread (the calling thread).
308+
// ScreenStack may not be initialised yet during early LoadComplete callbacks.
309+
if (ScreenStack?.CurrentScreen is not IOsuScreen currentScreen)
310+
return;
311+
312+
var orientation = MobileUtils.GetOrientation(this, currentScreen, gameActivity.IsTablet);
313+
314+
// Only the Android UI property assignment is dispatched to the main thread.
307315
gameActivity.RunOnUiThread(() =>
308316
{
309317
try
310318
{
311-
if (ScreenStack.CurrentScreen is not IOsuScreen currentScreen)
312-
return;
313-
314-
var orientation = MobileUtils.GetOrientation(this, currentScreen, gameActivity.IsTablet);
315-
316319
switch (orientation)
317320
{
318321
case MobileUtils.Orientation.Locked:
@@ -349,8 +352,14 @@ public override void SetHost(GameHost host)
349352

350353
protected override void Dispose(bool isDisposing)
351354
{
352-
base.Dispose(isDisposing);
353-
disposeNativeBridges();
355+
try
356+
{
357+
base.Dispose(isDisposing);
358+
}
359+
finally
360+
{
361+
disposeNativeBridges();
362+
}
354363
}
355364

356365
private class AndroidBatteryInfo : BatteryInfo

0 commit comments

Comments
 (0)