Skip to content

Commit dca84a6

Browse files
authored
Merge pull request #224 from winnerspiros/copilot/fix-crash-on-start
Fix Android startup crash: Linux libbass.so packaged in place of Android arm64 binary
2 parents 21ce86a + 3dd376b commit dca84a6

26 files changed

Lines changed: 1185 additions & 122 deletions

.github/workflows/release.yml

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,48 @@ jobs:
280280
exit 1
281281
fi
282282
283+
# Architecture sanity check: a previous build packaged the Linux-glibc
284+
# libbass.so from ppy.osu.Framework.NativeLibs into lib/arm64-v8a/, which
285+
# passed the name check above but failed at runtime with
286+
# System.DllNotFoundException: bass because Android's bionic linker cannot
287+
# resolve glibc-only symbols. Both Linux and Android builds report
288+
# identically as "ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV)"
289+
# via file(1), so we rely on the presence of GLIBC_ versioned symbols
290+
# (which exist only in glibc-linked binaries) to distinguish them.
283291
echo ""
284-
echo "All required native libraries present ✓"
292+
echo "Verifying native library architectures (must be Android arm64, not Linux glibc)..."
293+
TMPDIR=$(mktemp -d)
294+
trap 'rm -rf "$TMPDIR"' EXIT
295+
BAD=0
296+
for LIB in libbass.so libbass_fx.so libbassmix.so; do
297+
unzip -p "$APK" "lib/arm64-v8a/$LIB" > "$TMPDIR/$LIB"
298+
FILE_INFO=$(file "$TMPDIR/$LIB")
299+
echo " $LIB: $FILE_INFO"
300+
# Must be a 64-bit aarch64 ELF shared object.
301+
if ! echo "$FILE_INFO" | grep -qE "ELF 64-bit.*aarch64|ELF 64-bit.*ARM aarch64"; then
302+
echo "::error::$LIB is not a 64-bit aarch64 ELF — runtime DllNotFoundException will occur."
303+
BAD=1
304+
continue
305+
fi
306+
# Reliable Linux-vs-Android distinguisher: GLIBC_ versioned symbols
307+
# (e.g. memcpy@@GLIBC_2.17) appear only in glibc-linked Linux binaries.
308+
# Android's bionic libc uses no symbol versioning.
309+
if strings "$TMPDIR/$LIB" | grep -q "^GLIBC_"; then
310+
echo "::error::$LIB references GLIBC_ versioned symbols — this is the Linux ELF from ppy.osu.Framework.NativeLibs runtimes/linux-arm64/native/, not the Android ELF from ppy.osu.Framework.Android. Android's bionic linker cannot load it; the app will crash at startup with System.DllNotFoundException: bass."
311+
BAD=1
312+
continue
313+
fi
314+
echo " ✅ $LIB is a valid Android arm64 ELF"
315+
done
316+
317+
if [ "$BAD" -ne 0 ]; then
318+
echo ""
319+
echo "::error::One or more native libraries in the APK are NOT valid Android arm64 binaries. This usually means desktop runtime .so files (e.g. from ppy.osu.Framework.NativeLibs runtimes/linux-arm64/native/) leaked into lib/arm64-v8a/ during packaging. See osu.Android.props FixRuntimePackAssetTypes target."
320+
exit 1
321+
fi
322+
323+
echo ""
324+
echo "All required native libraries present and valid ✓"
285325
286326
- name: Upload APK artifact
287327
uses: actions/upload-artifact@v7

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,15 @@ Settings → Graphics → Renderer now exposes the full set of fork-added option
182182

183183
### 🛡️ Stability improvements
184184

185-
This fork includes several crash fixes on top of upstream:
185+
This fork includes several hardening fixes on top of upstream:
186186

187-
- **Sentry crash fix** — the app no longer crashes on startup when the error reporting service can't initialise (e.g. with a placeholder DSN)
188-
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them instead of crashing
187+
- **Sentry-safe init** — the app gracefully handles a missing/placeholder Sentry DSN instead of failing on startup
188+
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them
189189
- **JNI surface safety** — proper lifecycle management with atomic swaps and timeouts to prevent race conditions between Android surface creation and destruction
190-
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming to prevent `TypeLoadException` crashes in release builds
190+
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming so release builds behave the same as debug
191+
- **Architecture-correct native libraries (v144+)**`osu.Android.props` strips desktop runtime `.so` files (`runtimes/{linux,osx,ios,maccatalyst,win,…}-*/native/`) from the Android publish set and only marks Android-RID assets as `AssetType=native`, so the proper Android arm64 BASS libraries from `ppy.osu.Framework.Android`'s AAR (`jni/arm64-v8a/`) always win over the desktop `.so` files transitively pulled in by `ppy.osu.Framework.NativeLibs`. The release workflow scans every shipped `libbass*.so` for `GLIBC_*` versioned symbols (only present in glibc-linked Linux ELFs) and fails the build if any are found, so an architecture mismatch can never reach a release.
192+
- **IPC / WebSocket hardening (v144+)** — desktop external-integrations server (env-var gated, `localhost`-only) tightened on top of upstream: `WebSocketChannel` now uses a strict `UTF8Encoding(throwOnInvalidBytes: true)` decoder so malformed payloads are rejected with `InvalidPayloadData` instead of being silently replaced with `U+FFFD`, and the message-size guard accepts payloads of exactly `max_message_size` bytes (was off-by-one); `WebSocketServer.Dispose()` now cancels the request loop and waits briefly for it to exit before tearing down the cancellation/reset-event handles to avoid an `ObjectDisposedException` race on shutdown; `OsuWebSocketProvider.Dispose()` swaps the server reference under a local, properly disposes the bounded `CancellationTokenSource` via `using`, and always disposes the `WebSocketServer` in a `finally` so listener handles can't leak across screen transitions.
193+
- **Ranked-play song-preview playback (v144+)** — restored the `Enabled`/`CardHovered``PreviewTrack.Start()/Stop()` wiring on `RankedPlayCard.SongPreviewContainer` that was lost in upstream's "playback rewrite" merge. The bind now happens in the `LoadComponentAsync` continuation (so previews never race the track's async load), with both bindables driving a single `updatePlaybackState()` callback.
191194

192195
---
193196

osu.Android.props

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,46 @@
8282
Only .so files need the AssetType override — the previous broader filter
8383
(all non-DLL, non-PDB) incorrectly reclassified signing metadata and config
8484
files, which corrupted the APK signature (INSTALL_PARSE_FAILED_NO_CERTIFICATES).
85-
Scoped to Release only to avoid interfering with Debug builds. -->
85+
Scoped to Release only to avoid interfering with Debug builds.
86+
87+
IMPORTANT: only Android-runtime .so files may be marked as native and packaged
88+
into the APK. ppy.osu.Framework transitively depends on ppy.osu.Framework.NativeLibs
89+
which ships desktop-only natives under runtimes/linux-arm64/native/, runtimes/osx/native/,
90+
runtimes/win-*/native/ etc. — including a bare libbass.so, libbass_fx.so, libbassmix.so
91+
for Linux. If any of those are marked AssetType=native, the .NET Android SDK packs them
92+
into lib/arm64-v8a/ of the APK, racing with (and replacing) the proper Android arm64
93+
libbass*.so coming from ppy.osu.Framework.Android's AAR (jni/arm64-v8a/). The Linux ELF
94+
is linked against glibc and cannot be loaded by Android's bionic dynamic linker, which
95+
surfaces at startup as System.DllNotFoundException: bass from AudioManager..ctor →
96+
ManagedBass.Bass.get_DeviceCount, immediately crashing the app. -->
8697
<Target Name="FixRuntimePackAssetTypes" AfterTargets="ResolveRuntimePackAssets;ComputeFilesToPublish;ComputeResolvedFilesToPublishList"
8798
Condition="'$(Configuration)' == 'Release'">
8899
<ItemGroup>
89-
<RuntimePackAsset Update="@(RuntimePackAsset)" Condition="'%(Extension)' == '.so'">
100+
<!-- Strip desktop/iOS runtime .so files from the Android publish set entirely.
101+
Match path components for any non-Android RID known to ship .so files.
102+
Path normalisation handles both Windows (\) and Unix (/) separators. -->
103+
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
104+
Condition="'%(Extension)' == '.so'
105+
AND ($([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/linux-'))
106+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/linux/'))
107+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/osx-'))
108+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/osx/'))
109+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/ios-'))
110+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/ios/'))
111+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/maccatalyst-'))
112+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/win-'))
113+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/win/'))
114+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/browser-'))
115+
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/freebsd-')))" />
116+
<!-- Only mark Android-RID runtime pack assets as native. RuntimePackAsset items carry
117+
an explicit RuntimeIdentifier metadata, so this filter is exact. -->
118+
<RuntimePackAsset Update="@(RuntimePackAsset)"
119+
Condition="'%(Extension)' == '.so'
120+
AND $([System.String]::Copy('%(RuntimeIdentifier)').StartsWith('android'))">
90121
<AssetType>native</AssetType>
91122
</RuntimePackAsset>
123+
<!-- Mark the surviving (Android-only after the Remove above) publish .so files as native
124+
so the Android SDK packs them into lib/<abi>/ rather than dropping them as data. -->
92125
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)" Condition="'%(Extension)' == '.so'">
93126
<AssetType>native</AssetType>
94127
</ResolvedFileToPublish>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using Newtonsoft.Json;
5+
6+
namespace osu.Desktop.IPC.Messages
7+
{
8+
public class HitCountMessage : OsuWebSocketMessage
9+
{
10+
[JsonProperty("new_hits")]
11+
public long NewHits { get; init; }
12+
}
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using Newtonsoft.Json;
5+
using osu.Framework.Extensions.TypeExtensions;
6+
7+
namespace osu.Desktop.IPC.Messages
8+
{
9+
public abstract class OsuWebSocketMessage
10+
{
11+
[JsonProperty("type")]
12+
public string Type { get; }
13+
14+
protected OsuWebSocketMessage()
15+
{
16+
Type = GetType().ReadableName();
17+
}
18+
}
19+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Threading;
7+
using osu.Desktop.IPC.Messages;
8+
using osu.Framework.Allocation;
9+
using osu.Framework.Bindables;
10+
using osu.Framework.Extensions;
11+
using osu.Framework.Graphics;
12+
using osu.Framework.Logging;
13+
using osu.Game.Configuration;
14+
using osu.Game.IPC;
15+
using osu.Game.Online.Multiplayer;
16+
using osu.Game.Rulesets.Scoring;
17+
using osu.Game.Scoring;
18+
using JsonConvert = Newtonsoft.Json.JsonConvert;
19+
20+
namespace osu.Desktop.IPC
21+
{
22+
public partial class OsuWebSocketProvider : Component
23+
{
24+
private WebSocketServer? server;
25+
private readonly Bindable<ScoreInfo> lastLocalScore = new Bindable<ScoreInfo>();
26+
27+
[BackgroundDependencyLoader]
28+
private void load(SessionStatics sessionStatics)
29+
{
30+
server = new WebSocketServer(49727);
31+
server.StartAsync().FireAndForget(onError: ex => Logger.Error(ex, "Failed to start websocket"));
32+
33+
sessionStatics.BindWith(Static.LastLocalUserScore, lastLocalScore);
34+
}
35+
36+
protected override void LoadComplete()
37+
{
38+
base.LoadComplete();
39+
40+
lastLocalScore.BindValueChanged(val =>
41+
{
42+
if (val.NewValue == null)
43+
return;
44+
45+
if (server?.IsRunning != true)
46+
return;
47+
48+
var msg = new HitCountMessage { NewHits = val.NewValue.Statistics.Where(kv => kv.Key.IsBasic() && kv.Key.IsHit()).Sum(kv => kv.Value) };
49+
broadcast(msg);
50+
});
51+
}
52+
53+
private void broadcast(OsuWebSocketMessage message)
54+
{
55+
if (server?.IsRunning != true)
56+
return;
57+
58+
string messageString = JsonConvert.SerializeObject(message);
59+
server.BroadcastAsync(messageString).FireAndForget();
60+
}
61+
62+
protected override void Dispose(bool isDisposing)
63+
{
64+
base.Dispose(isDisposing);
65+
66+
var localServer = server;
67+
server = null;
68+
69+
if (localServer == null)
70+
return;
71+
72+
try
73+
{
74+
if (localServer.IsRunning)
75+
{
76+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
77+
localServer.StopAsync(cts.Token).WaitSafely();
78+
}
79+
}
80+
finally
81+
{
82+
localServer.Dispose();
83+
}
84+
}
85+
}
86+
}

osu.Desktop/OsuGameDesktop.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Reflection;
77
using System.Runtime.Versioning;
88
using Microsoft.Win32;
9+
using osu.Desktop.IPC;
910
using osu.Desktop.MacOS;
1011
using osu.Desktop.Performance;
1112
using osu.Desktop.Security;
@@ -35,6 +36,8 @@ internal partial class OsuGameDesktop : OsuGame
3536

3637
public bool IsFirstRun { get; init; }
3738

39+
public bool EnableWebSocketServer { get; init; }
40+
3841
public OsuGameDesktop(string[]? args = null)
3942
: base(args)
4043
{
@@ -148,6 +151,9 @@ protected override void LoadComplete()
148151

149152
osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
150153
archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this);
154+
155+
if (EnableWebSocketServer)
156+
Add(new OsuWebSocketProvider());
151157
}
152158

153159
public override void SetHost(GameHost host)

osu.Desktop/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ public static void Main(string[] args)
140140
{
141141
host.Run(new OsuGameDesktop(args)
142142
{
143-
IsFirstRun = isFirstRun
143+
IsFirstRun = isFirstRun,
144+
EnableWebSocketServer = Environment.GetEnvironmentVariable("OSU_WEBSOCKET_SERVER") == "1",
144145
});
145146
}
146147
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.Net.WebSockets;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using osu.Game.IPC;
9+
10+
namespace osu.Game.Tests.IPC
11+
{
12+
public sealed class WebSocketClient : IDisposable
13+
{
14+
public event Action<string>? MessageReceived;
15+
public event Action? Closed;
16+
17+
private readonly int port;
18+
private WebSocketChannel? channel;
19+
20+
public WebSocketClient(int port)
21+
{
22+
this.port = port;
23+
}
24+
25+
public async Task Start(CancellationToken cancellationToken = default)
26+
{
27+
var webSocket = new ClientWebSocket();
28+
await webSocket.ConnectAsync(new Uri($@"ws://localhost:{port}/"), cancellationToken);
29+
channel = new WebSocketChannel(webSocket);
30+
channel.MessageReceived += msg => MessageReceived?.Invoke(msg);
31+
channel.ClosedPrematurely += () => Closed?.Invoke();
32+
channel.Start(cancellationToken);
33+
}
34+
35+
public async Task SendAsync(string message)
36+
{
37+
if (channel == null)
38+
throw new InvalidOperationException($@"Must {nameof(Start)} first.");
39+
40+
await channel.SendAsync(message);
41+
}
42+
43+
public async Task StopAsync(CancellationToken stoppingToken = default)
44+
{
45+
try
46+
{
47+
if (channel != null)
48+
await channel.StopAsync(stoppingToken).ConfigureAwait(false);
49+
}
50+
catch (OperationCanceledException)
51+
{
52+
// has to be caught manually because outer task isn't accepting `stoppingToken`.
53+
}
54+
}
55+
56+
public void Dispose()
57+
{
58+
channel?.Dispose();
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)