Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,48 @@ jobs:
exit 1
fi

# Architecture sanity check: a previous build packaged the Linux-glibc
# libbass.so from ppy.osu.Framework.NativeLibs into lib/arm64-v8a/, which
# passed the name check above but failed at runtime with
# System.DllNotFoundException: bass because Android's bionic linker cannot
# resolve glibc-only symbols. Both Linux and Android builds report
# identically as "ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV)"
# via file(1), so we rely on the presence of GLIBC_ versioned symbols
# (which exist only in glibc-linked binaries) to distinguish them.
echo ""
echo "All required native libraries present ✓"
echo "Verifying native library architectures (must be Android arm64, not Linux glibc)..."
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
BAD=0
for LIB in libbass.so libbass_fx.so libbassmix.so; do
unzip -p "$APK" "lib/arm64-v8a/$LIB" > "$TMPDIR/$LIB"
FILE_INFO=$(file "$TMPDIR/$LIB")
echo " $LIB: $FILE_INFO"
# Must be a 64-bit aarch64 ELF shared object.
if ! echo "$FILE_INFO" | grep -qE "ELF 64-bit.*aarch64|ELF 64-bit.*ARM aarch64"; then
echo "::error::$LIB is not a 64-bit aarch64 ELF — runtime DllNotFoundException will occur."
BAD=1
continue
fi
# Reliable Linux-vs-Android distinguisher: GLIBC_ versioned symbols
# (e.g. memcpy@@GLIBC_2.17) appear only in glibc-linked Linux binaries.
# Android's bionic libc uses no symbol versioning.
if strings "$TMPDIR/$LIB" | grep -q "^GLIBC_"; then
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."
BAD=1
continue
fi
echo " ✅ $LIB is a valid Android arm64 ELF"
done

if [ "$BAD" -ne 0 ]; then
echo ""
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."
exit 1
fi

echo ""
echo "All required native libraries present and valid ✓"

- name: Upload APK artifact
uses: actions/upload-artifact@v7
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,13 @@ Settings → Graphics → Renderer now exposes the full set of fork-added option

### 🛡️ Stability improvements

This fork includes several crash fixes on top of upstream:
This fork includes several hardening fixes on top of upstream:

- **Sentry crash fix** — the app no longer crashes on startup when the error reporting service can't initialise (e.g. with a placeholder DSN)
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them instead of crashing
- **Sentry-safe init** — the app gracefully handles a missing/placeholder Sentry DSN instead of failing on startup
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them
- **JNI surface safety** — proper lifecycle management with atomic swaps and timeouts to prevent race conditions between Android surface creation and destruction
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming to prevent `TypeLoadException` crashes in release builds
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming so release builds behave the same as debug
- **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.

---

Expand Down
37 changes: 35 additions & 2 deletions osu.Android.props
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,46 @@
Only .so files need the AssetType override — the previous broader filter
(all non-DLL, non-PDB) incorrectly reclassified signing metadata and config
files, which corrupted the APK signature (INSTALL_PARSE_FAILED_NO_CERTIFICATES).
Scoped to Release only to avoid interfering with Debug builds. -->
Scoped to Release only to avoid interfering with Debug builds.

IMPORTANT: only Android-runtime .so files may be marked as native and packaged
into the APK. ppy.osu.Framework transitively depends on ppy.osu.Framework.NativeLibs
which ships desktop-only natives under runtimes/linux-arm64/native/, runtimes/osx/native/,
runtimes/win-*/native/ etc. — including a bare libbass.so, libbass_fx.so, libbassmix.so
for Linux. If any of those are marked AssetType=native, the .NET Android SDK packs them
into lib/arm64-v8a/ of the APK, racing with (and replacing) the proper Android arm64
libbass*.so coming from ppy.osu.Framework.Android's AAR (jni/arm64-v8a/). The Linux ELF
is linked against glibc and cannot be loaded by Android's bionic dynamic linker, which
surfaces at startup as System.DllNotFoundException: bass from AudioManager..ctor →
ManagedBass.Bass.get_DeviceCount, immediately crashing the app. -->
<Target Name="FixRuntimePackAssetTypes" AfterTargets="ResolveRuntimePackAssets;ComputeFilesToPublish;ComputeResolvedFilesToPublishList"
Condition="'$(Configuration)' == 'Release'">
<ItemGroup>
<RuntimePackAsset Update="@(RuntimePackAsset)" Condition="'%(Extension)' == '.so'">
<!-- Strip desktop/iOS runtime .so files from the Android publish set entirely.
Match path components for any non-Android RID known to ship .so files.
Path normalisation handles both Windows (\) and Unix (/) separators. -->
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
Condition="'%(Extension)' == '.so'
AND ($([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/linux-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/linux/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/osx-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/osx/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/ios-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/ios/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/maccatalyst-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/win-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/win/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/browser-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/freebsd-')))" />
<!-- Only mark Android-RID runtime pack assets as native. RuntimePackAsset items carry
an explicit RuntimeIdentifier metadata, so this filter is exact. -->
<RuntimePackAsset Update="@(RuntimePackAsset)"
Condition="'%(Extension)' == '.so'
AND $([System.String]::Copy('%(RuntimeIdentifier)').StartsWith('android'))">
<AssetType>native</AssetType>
</RuntimePackAsset>
<!-- Mark the surviving (Android-only after the Remove above) publish .so files as native
so the Android SDK packs them into lib/<abi>/ rather than dropping them as data. -->
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)" Condition="'%(Extension)' == '.so'">
<AssetType>native</AssetType>
</ResolvedFileToPublish>
Expand Down
13 changes: 13 additions & 0 deletions osu.Desktop/IPC/Messages/HitCountMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Newtonsoft.Json;

namespace osu.Desktop.IPC.Messages
{
public class HitCountMessage : OsuWebSocketMessage
{
[JsonProperty("new_hits")]
public long NewHits { get; init; }
}
}
19 changes: 19 additions & 0 deletions osu.Desktop/IPC/Messages/OsuWebSocketMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions;

namespace osu.Desktop.IPC.Messages
{
public abstract class OsuWebSocketMessage
{
[JsonProperty("type")]
public string Type { get; }

protected OsuWebSocketMessage()
{
Type = GetType().ReadableName();
}
}
}
75 changes: 75 additions & 0 deletions osu.Desktop/IPC/OsuWebSocketProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Linq;
using System.Threading;
using osu.Desktop.IPC.Messages;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using JsonConvert = Newtonsoft.Json.JsonConvert;

namespace osu.Desktop.IPC
{
public partial class OsuWebSocketProvider : Component
{
private WebSocketServer? server;
private readonly Bindable<ScoreInfo> lastLocalScore = new Bindable<ScoreInfo>();

[BackgroundDependencyLoader]
private void load(SessionStatics sessionStatics)
{
server = new WebSocketServer(49727);
server.StartAsync().FireAndForget(onError: ex => Logger.Error(ex, "Failed to start websocket"));

sessionStatics.BindWith(Static.LastLocalUserScore, lastLocalScore);
}

protected override void LoadComplete()
{
base.LoadComplete();

lastLocalScore.BindValueChanged(val =>
{
if (val.NewValue == null)
return;

if (server?.IsRunning != true)
return;

var msg = new HitCountMessage { NewHits = val.NewValue.Statistics.Where(kv => kv.Key.IsBasic() && kv.Key.IsHit()).Sum(kv => kv.Value) };
broadcast(msg);
});
}

private void broadcast(OsuWebSocketMessage message)
{
if (server?.IsRunning != true)
return;

string messageString = JsonConvert.SerializeObject(message);
server.BroadcastAsync(messageString).FireAndForget();
}

protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

if (server?.IsRunning == true)
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
server.StopAsync(cts.Token).WaitSafely();
server = null;
}

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispose() creates a CancellationTokenSource but never disposes it, and also never calls Dispose() on the WebSocketServer after stopping it. This can leak unmanaged handles (listener, wait handles) across screen transitions/shutdown. Use a using/try-finally for the CTS and ensure the server is disposed after stopping (even if stop fails/times out).

Suggested change
if (server?.IsRunning == true)
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
server.StopAsync(cts.Token).WaitSafely();
server = null;
}
var server = this.server;
this.server = null;
if (server == null)
return;
try
{
if (server.IsRunning)
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
server.StopAsync(cts.Token).WaitSafely();
}
}
finally
{
server.Dispose();
}

Copilot uses AI. Check for mistakes.
}
}
}
5 changes: 5 additions & 0 deletions osu.Desktop/OsuGameDesktop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

public bool IsFirstRun { get; init; }

public bool EnableWebSocketServer { get; init; }

public OsuGameDesktop(string[]? args = null)
: base(args)
{
Expand Down Expand Up @@ -148,6 +150,9 @@

osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this);

if (EnableWebSocketServer)
Add(new OsuWebSocketProvider());

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Code Quality

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Code Quality

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Code Quality

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Code Quality

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, MultiThreaded)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Windows, windows-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 155 in osu.Desktop/OsuGameDesktop.cs

View workflow job for this annotation

GitHub Actions / Test (Linux, ubuntu-latest, SingleThread)

The type or namespace name 'OsuWebSocketProvider' could not be found (are you missing a using directive or an assembly reference?)
}

public override void SetHost(GameHost host)
Expand Down
3 changes: 2 additions & 1 deletion osu.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ public static void Main(string[] args)
{
host.Run(new OsuGameDesktop(args)
{
IsFirstRun = isFirstRun
IsFirstRun = isFirstRun,
EnableWebSocketServer = Environment.GetEnvironmentVariable("OSU_WEBSOCKET_SERVER") == "1",
});
}
}
Expand Down
61 changes: 61 additions & 0 deletions osu.Game.Tests/IPC/WebSocketClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.IPC;

namespace osu.Game.Tests.IPC
{
public sealed class WebSocketClient : IDisposable
{
public event Action<string>? MessageReceived;
public event Action? Closed;

private readonly int port;
private WebSocketChannel? channel;

public WebSocketClient(int port)
{
this.port = port;
}

public async Task Start(CancellationToken cancellationToken = default)
{
var webSocket = new ClientWebSocket();
await webSocket.ConnectAsync(new Uri($@"ws://localhost:{port}/"), cancellationToken);
channel = new WebSocketChannel(webSocket);
channel.MessageReceived += msg => MessageReceived?.Invoke(msg);
channel.ClosedPrematurely += () => Closed?.Invoke();
channel.Start(cancellationToken);
}

public async Task SendAsync(string message)
{
if (channel == null)
throw new InvalidOperationException($@"Must {nameof(Start)} first.");

await channel.SendAsync(message);
}

public async Task StopAsync(CancellationToken stoppingToken = default)
{
try
{
if (channel != null)
await channel.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// has to be caught manually because outer task isn't accepting `stoppingToken`.
}
}

public void Dispose()
{
channel?.Dispose();
}
}
}
Loading
Loading