diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dc8676cbbe4..38d8fa04eae8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 175f5221fa9b..4f7396181fa5 100644 --- a/README.md +++ b/README.md @@ -182,12 +182,15 @@ 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. +- **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. +- **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. --- diff --git a/osu.Android.props b/osu.Android.props index 0b530e886044..d1539915e4c3 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -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. --> - + + + + native + native diff --git a/osu.Desktop/IPC/Messages/HitCountMessage.cs b/osu.Desktop/IPC/Messages/HitCountMessage.cs new file mode 100644 index 000000000000..6d9905bde4c2 --- /dev/null +++ b/osu.Desktop/IPC/Messages/HitCountMessage.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . 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; } + } +} diff --git a/osu.Desktop/IPC/Messages/OsuWebSocketMessage.cs b/osu.Desktop/IPC/Messages/OsuWebSocketMessage.cs new file mode 100644 index 000000000000..d69e6d88e3e6 --- /dev/null +++ b/osu.Desktop/IPC/Messages/OsuWebSocketMessage.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . 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(); + } + } +} diff --git a/osu.Desktop/IPC/OsuWebSocketProvider.cs b/osu.Desktop/IPC/OsuWebSocketProvider.cs new file mode 100644 index 000000000000..adbb89be7178 --- /dev/null +++ b/osu.Desktop/IPC/OsuWebSocketProvider.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . 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 lastLocalScore = new Bindable(); + + [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); + + var localServer = server; + server = null; + + if (localServer == null) + return; + + try + { + if (localServer.IsRunning) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + localServer.StopAsync(cts.Token).WaitSafely(); + } + } + finally + { + localServer.Dispose(); + } + } + } +} diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index a3ee9d67f873..ee6fddd16891 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Runtime.Versioning; using Microsoft.Win32; +using osu.Desktop.IPC; using osu.Desktop.MacOS; using osu.Desktop.Performance; using osu.Desktop.Security; @@ -35,6 +36,8 @@ internal partial class OsuGameDesktop : OsuGame public bool IsFirstRun { get; init; } + public bool EnableWebSocketServer { get; init; } + public OsuGameDesktop(string[]? args = null) : base(args) { @@ -148,6 +151,9 @@ protected override void LoadComplete() osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this); + + if (EnableWebSocketServer) + Add(new OsuWebSocketProvider()); } public override void SetHost(GameHost host) diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 612edb24706d..65b480e6b078 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -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", }); } } diff --git a/osu.Game.Tests/IPC/WebSocketClient.cs b/osu.Game.Tests/IPC/WebSocketClient.cs new file mode 100644 index 000000000000..453f6445f818 --- /dev/null +++ b/osu.Game.Tests/IPC/WebSocketClient.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . 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? 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(); + } + } +} diff --git a/osu.Game.Tests/IPC/WebSocketTest.cs b/osu.Game.Tests/IPC/WebSocketTest.cs new file mode 100644 index 000000000000..9952218e6da8 --- /dev/null +++ b/osu.Game.Tests/IPC/WebSocketTest.cs @@ -0,0 +1,274 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Game.IPC; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Tests.IPC +{ + [TestFixture] + public class WebSocketTest + { + [Test] + public async Task TestClientInitiatedDuplexCommunication() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + var duplexComplete = new ManualResetEventSlim(false); + + server.MessageReceived += (clientId, msg) => + { + if (msg != "PING") + return; + + // ReSharper disable once AccessToDisposedClosure + server.SendAsync(clientId, "PONG").FireAndForget(); + }; + client.MessageReceived += msg => + { + if (msg != "PONG") + return; + + duplexComplete.Set(); + }; + + await server.StartAsync(); + await client.Start(); + + await client.SendAsync("PING"); + Assert.That(duplexComplete.Wait(10_000)); + + await client.StopAsync(); + await server.StopAsync(); + + client.Dispose(); + server.Dispose(); + } + + [Test] + public async Task TestServerInitiatedDuplexCommunication() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + var clientConnected = new ManualResetEventSlim(); + var duplexComplete = new ManualResetEventSlim(); + + client.MessageReceived += msg => + { + if (msg != "PING") + return; + + // ReSharper disable once AccessToDisposedClosure + client.SendAsync("PONG").FireAndForget(); + }; + server.ClientConnected += _ => clientConnected.Set(); + server.MessageReceived += (_, msg) => + { + if (msg != "PONG") + return; + + duplexComplete.Set(); + }; + + await server.StartAsync(); + await client.Start(); + Assert.That(clientConnected.Wait(10_000)); + + await server.SendAsync(1, "PING"); + Assert.That(duplexComplete.Wait(10_000)); + + await client.StopAsync(); + await server.StopAsync(); + + client.Dispose(); + server.Dispose(); + } + + [Test] + public async Task TestServerBroadcast() + { + const int port = 54321; + const int client_count = 5; + + var server = new WebSocketServer(port); + var clients = new List(client_count); + var connectionCountdown = new CountdownEvent(client_count); + var receiptCountdown = new CountdownEvent(client_count); + + for (int i = 0; i < client_count; ++i) + { + var client = new WebSocketClient(port); + client.MessageReceived += msg => + { + if (msg != "HI ALL") + return; + + receiptCountdown.Signal(); + }; + clients.Add(client); + } + + server.ClientConnected += _ => connectionCountdown.Signal(); + + await server.StartAsync(); + + foreach (var client in clients) + await client.Start(); + Assert.That(connectionCountdown.Wait(10_000)); + + await server.BroadcastAsync("HI ALL"); + Assert.That(receiptCountdown.Wait(10_000)); + + foreach (var client in clients) + { + await client.StopAsync(); + client.Dispose(); + } + + await server.StopAsync(); + server.Dispose(); + } + + [Test] + public async Task TestClientSoftAborts() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + await server.StartAsync(); + await client.Start(); + + await client.StopAsync(); + client.Dispose(); + + await server.StopAsync(); + server.Dispose(); + } + + [Test] + public async Task TestClientHardAborts() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + await server.StartAsync(); + await client.Start(); + + await client.StopAsync(new CancellationToken(true)); + client.Dispose(); + + await server.StopAsync(); + server.Dispose(); + } + + [Test] + public async Task TestServerSoftAborts() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + await server.StartAsync(); + await client.Start(); + + await server.StopAsync(); + server.Dispose(); + + await client.StopAsync(); + client.Dispose(); + } + + [Test] + public async Task TestServerHardAborts() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + await server.StartAsync(); + await client.Start(); + + await server.StopAsync(new CancellationToken(true)); + server.Dispose(); + + await client.StopAsync(); + client.Dispose(); + } + + [Test] + public async Task TestClientMessageTooLong() + { + const int port = 54321; + + var server = new WebSocketServer(port); + var client = new WebSocketClient(port); + + var clientClosed = new ManualResetEventSlim(); + client.Closed += clientClosed.Set; + + await server.StartAsync(); + await client.Start(); + + await client.SendAsync(new string('0', 9999)); + Assert.That(clientClosed.Wait(10_000)); + await client.StopAsync(); + client.Dispose(); + + var client2 = new WebSocketClient(port); + + var duplexComplete = new ManualResetEventSlim(); + server.MessageReceived += (clientId, msg) => + { + if (msg != "PING") + return; + + // ReSharper disable once AccessToDisposedClosure + server.SendAsync(clientId, "PONG").FireAndForget(); + }; + client2.MessageReceived += msg => + { + if (msg != "PONG") + return; + + duplexComplete.Set(); + }; + + await client2.Start(); + await client2.SendAsync("PING"); + Assert.That(duplexComplete.Wait(10000)); + + await client2.StopAsync(); + await server.StopAsync(); + + client2.Dispose(); + server.Dispose(); + } + + [Test] + public async Task TestStartStopServerWithoutReceivingClients() + { + const int port = 54321; + + var server = new WebSocketServer(port); + await server.StartAsync(); + await server.StopAsync(); + server.Dispose(); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs index 66a05f4f474e..f8882cbff527 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs @@ -71,6 +71,22 @@ public void TestBasic() AddStep("change state to in room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom)); } + [Test] + public void TestDelayedRoomScreenPushDoesNotRunIfRoomIsLeftPrematurely() + { + AddStep("change state to in room then immediately leave room", () => + { + queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom); + MultiplayerClient.LeaveRoom(); + }); + + // the queue screen waits 2 seconds between transitioning to `InRoom` state and actually pushing the relevant screen. + // if the room goes to `null` in that time, things die very hard. + // therefore the wait here is to check that things don't die very hard. + // if they do the test will throw an exception and fail. + AddWaitStep("wait a little bit", 10); + } + private static double generateCount(double x, double mean, double stdDev, double amplitude) { return amplitude * Math.Exp(-Math.Pow(x - mean, 2) / (2 * Math.Pow(stdDev, 2))) + Random.Shared.Next(300); diff --git a/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs index 958877cc8f9e..1bdd79c10c82 100644 --- a/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs +++ b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs @@ -56,14 +56,14 @@ public void TestSingleSelectionMode() }); AddStep("single selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Single); - AddStep("click first card", () => handOfCards.Cards.First().TriggerClick()); - AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item])); + AddStep("click first card", () => handOfCards.GetCardsInDisplayOrder()[0].TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item])); - AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick()); - AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + AddStep("click second card", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick()); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[1].Item])); - AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick()); - AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + AddStep("click second card again", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick()); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[1].Item])); } [Test] @@ -76,14 +76,14 @@ public void TestMultiSelectionMode() }); AddStep("multi selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Multiple); - AddStep("click first card", () => handOfCards.Cards.First().TriggerClick()); - AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item])); + AddStep("click first card", () => handOfCards.GetCardsInDisplayOrder().First().TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder().First().Item])); - AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick()); - AddAssert("both cards selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item, handOfCards.Cards.ElementAt(1).Item])); + AddStep("click second card", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick()); + AddAssert("both cards selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item, handOfCards.GetCardsInDisplayOrder()[1].Item])); - AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick()); - AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + AddStep("click second card again", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item])); } [Test] @@ -131,20 +131,20 @@ public void TestKeyboardSelectionSingleSelection() Key key = Key.Number1 + i; AddStep($"key {i + 1}", () => InputManager.Key(key)); - AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(i1).Item])); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[i1].Item])); } AddStep("right arrow", () => InputManager.Key(Key.Right)); - AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item])); AddStep("right arrow", () => InputManager.Key(Key.Right)); - AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[1].Item])); AddStep("left arrow", () => InputManager.Key(Key.Left)); - AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item])); AddStep("left arrow", () => InputManager.Key(Key.Left)); - AddAssert("last card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(^1).Item])); + AddAssert("last card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[^1].Item])); AddStep("space", () => InputManager.Key(Key.Space)); AddAssert("play action triggered", () => playActionTriggered); @@ -166,11 +166,11 @@ public void TestKeyboardSelectionMultiSelection() Key key = Key.Number1 + i; AddStep($"key {i + 1}", () => InputManager.Key(key)); - AddAssert("card hovered", () => handOfCards.Cards.ElementAt(i1).CardHovered); + AddAssert("card hovered", () => handOfCards.GetCardsInDisplayOrder()[i1].CardHovered); - AddAssert("card not selected", () => !handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); + AddAssert("card not selected", () => !handOfCards.Selection.Contains(handOfCards.GetCardsInDisplayOrder()[i1].Card.Item)); AddStep("space", () => InputManager.Key(Key.Space)); - AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); + AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.GetCardsInDisplayOrder()[i1].Card.Item)); } } @@ -201,9 +201,9 @@ public void TestRemoveCardsWhileDragging() for (int i = 0; i < 5; i++) handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); }); - AddStep("hover card", () => InputManager.MoveMouseTo(handOfCards.Cards.First())); + AddStep("hover card", () => InputManager.MoveMouseTo(handOfCards.GetCardsInDisplayOrder()[0])); AddStep("start drag", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move card", () => InputManager.MoveMouseTo(handOfCards.Cards[3])); + AddStep("move card", () => InputManager.MoveMouseTo(handOfCards.GetCardsInDisplayOrder()[3])); AddStep("remove cards", () => { foreach (var card in handOfCards.Cards.ToArray()) diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs index 6446ec8f081c..61c5363bffb6 100644 --- a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs @@ -26,21 +26,35 @@ public override void SetUpSteps() AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); WaitForJoined(); + } + [Test] + public void TestIntroStage() + { AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + + AddStep("set round warmup phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.RoundWarmup, s => s.StarRating = 6.3f).WaitSafely()); } [Test] - public void TestIntroStage() + public void TestUnresolvedUser() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = TestUserLookupCache.UNRESOLVED_USER_ID })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set round warmup phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.RoundWarmup, s => s.StarRating = 6.3f).WaitSafely()); } [Test] public void TestDiscardCardsStage() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); AddWaitStep("wait", 3); @@ -72,6 +86,10 @@ public void TestDiscardCardsStage() [Test] public void TestAddRemoveCards() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); for (int i = 0; i < 3; i++) @@ -84,6 +102,10 @@ public void TestAddRemoveCards() [Test] public void TestRevealCards() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + var requestHandler = new BeatmapRequestHandler(); AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); @@ -104,6 +126,10 @@ public void TestRevealCards() [Test] public void TestPlayCardDirect() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); AddWaitStep("wait", 3); AddStep("play card", () => MultiplayerClient.PlayCard(hand => hand[0]).WaitSafely()); @@ -112,6 +138,10 @@ public void TestPlayCardDirect() [Test] public void TestDiscardCardsDirect() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); AddWaitStep("wait", 3); AddStep("discard cards", () => MultiplayerClient.DiscardCards(hand => hand.Take(3)).WaitSafely()); @@ -122,6 +152,10 @@ public void TestDiscardCardsDirect() [Test] public void TestPlayStage() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); AddUntilStep("wait until cards are present", () => this.ChildrenOfType().Count() == 5); @@ -153,6 +187,10 @@ public void TestPlayStage() [Test] public void TestOtherPlaysCard() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); AddWaitStep("wait", 5); AddStep("play beatmap", () => MultiplayerClient.PlayUserCard(2, hand => hand[0]).WaitSafely()); @@ -166,6 +204,10 @@ public void TestOtherPlaysCard() [Test] public void TestHealthChange() { + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); AddWaitStep("wait", 5); AddStep("change player 1 health", () => MultiplayerClient.RankedPlayChangeUserState(MultiplayerClient.LocalUser!.UserID, state => state.Life = 250_000).WaitSafely()); diff --git a/osu.Game/Database/BeatmapLookupCache.cs b/osu.Game/Database/BeatmapLookupCache.cs index 973c25ec4f89..42e8bf23efcf 100644 --- a/osu.Game/Database/BeatmapLookupCache.cs +++ b/osu.Game/Database/BeatmapLookupCache.cs @@ -12,6 +12,8 @@ namespace osu.Game.Database { public partial class BeatmapLookupCache : OnlineLookupCache { + protected override bool CacheNullValues => false; + /// /// Perform an API lookup on the specified beatmap, populating a model. /// diff --git a/osu.Game/IPC/WebSocketChannel.cs b/osu.Game/IPC/WebSocketChannel.cs new file mode 100644 index 000000000000..a56d12653513 --- /dev/null +++ b/osu.Game/IPC/WebSocketChannel.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . 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.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace osu.Game.IPC +{ + /// + /// Represents a WebSocket-based communication channel. + /// Only supports UTF-8 string-based messages, of maximum size of bytes. + /// + public sealed class WebSocketChannel : IDisposable + { + public event Action? MessageReceived; + public event Action? ClosedPrematurely; + + private const int max_message_size = 4096; // bytes + + private readonly byte[] receiveBuffer = new byte[max_message_size]; + private int currentBufferPosition; + + // strict UTF-8 decoder so malformed payloads throw `DecoderFallbackException` rather than being + // silently replaced with U+FFFD (which would defeat the `InvalidPayloadData` close path below). + private static readonly Encoding strict_utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private readonly WebSocket webSocket; + private Task? readWriteTask; + private readonly CancellationTokenSource runningTokenSource = new CancellationTokenSource(); + private bool isDisposed; + + public WebSocketChannel(WebSocket webSocket) + { + this.webSocket = webSocket; + } + + /// + /// Starts the channel. + /// + /// Use this to abort the start. + public void Start(CancellationToken cancellationToken) + { + if (readWriteTask?.Status >= TaskStatus.Running) + throw new InvalidOperationException($@"Cannot {nameof(Start)} more than once."); + + readWriteTask = Task.Run(readWriteLoop, cancellationToken); + } + + private async Task readWriteLoop() + { + var token = runningTokenSource.Token; + + while (!token.IsCancellationRequested) + { + ValueWebSocketReceiveResult result; + + try + { + result = await webSocket.ReceiveAsync(receiveBuffer.AsMemory(currentBufferPosition), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // normal when `token` is cancelled. + // at this point the websocket will have entered `Aborted` state on its own, so no further clean-up can be done. + return; + } + catch (Exception) + { + // could throw something like `WebSocketException`s from the other side hard-aborting. + ClosedPrematurely?.Invoke(); + return; + } + + currentBufferPosition += result.Count; + + if (webSocket.State > WebSocketState.Open) + { + if (webSocket.State == WebSocketState.CloseReceived) + { + try + { + // attempt to complete the close handshake nicely. + await webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, @"Received close request", token).ConfigureAwait(false); + } + catch + { + // an attempt was made, and failed. bail. + } + } + + ClosedPrematurely?.Invoke(); + return; + } + + if (result.MessageType == WebSocketMessageType.Binary) + { + // see https://github.com/dotnet/runtime/issues/81762#issuecomment-1421029475 for difference between `CloseAsync()` and `CloseOutputAsync()`. + // there is basically no incentive to use `CloseAsync()` in these error scenarios. the point is to drop the errant peer on the floor immediately. + await webSocket.CloseOutputAsync(WebSocketCloseStatus.InvalidMessageType, @"Binary messages are not supported.", token).ConfigureAwait(false); + ClosedPrematurely?.Invoke(); + return; + } + + if (currentBufferPosition > max_message_size) + { + await webSocket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, $@"Exceeded maximum message size of {max_message_size} bytes.", token).ConfigureAwait(false); + ClosedPrematurely?.Invoke(); + return; + } + + if (result.EndOfMessage) + { + string message; + + try + { + message = strict_utf8.GetString(receiveBuffer, 0, currentBufferPosition); + } + catch (DecoderFallbackException) + { + await webSocket.CloseOutputAsync(WebSocketCloseStatus.InvalidPayloadData, @"UTF-8 encoded strings expected.", token).ConfigureAwait(false); + ClosedPrematurely?.Invoke(); + return; + } + + MessageReceived?.Invoke(message); + Array.Fill(receiveBuffer, (byte)0, 0, currentBufferPosition); + currentBufferPosition = 0; + } + } + } + + public async Task SendAsync(string message) + { + if (readWriteTask == null) + throw new InvalidOperationException($@"Must {nameof(Start)} first."); + + byte[] bytes = Encoding.UTF8.GetBytes(message); + await webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Stops the channel. + /// + /// Cancel this to transition from a graceful shutdown to a forced shutdown. + public async Task StopAsync(CancellationToken stoppingToken) + { + if (isDisposed) + return; + + await runningTokenSource.CancelAsync().ConfigureAwait(false); + + if (readWriteTask != null) + await readWriteTask.WaitAsync(stoppingToken).ConfigureAwait(false); + + if (stoppingToken.IsCancellationRequested) + webSocket.Abort(); + } + + public void Dispose() + { + if (isDisposed) + return; + + isDisposed = true; + webSocket.Dispose(); + runningTokenSource.Dispose(); + } + } +} diff --git a/osu.Game/IPC/WebSocketServer.cs b/osu.Game/IPC/WebSocketServer.cs new file mode 100644 index 000000000000..01a526816f5e --- /dev/null +++ b/osu.Game/IPC/WebSocketServer.cs @@ -0,0 +1,303 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Logging; + +namespace osu.Game.IPC +{ + /// + /// Implements a WebSocket server to be used for external integrations such as streaming overlays. + /// The server can only listen on localhost, on the port given in the constructor. + /// Only UTF-8 string-based messages are supported. Binary messages are not supported. + /// String-based messages must not exceed bytes. + /// + /// + /// This implementation uses internally. + /// This is a frozen .NET API as per https://github.com/dotnet/runtime/issues/63941#issuecomment-1205259894. + /// The reason of using this API instead of ASP.NET directly via frameworks like SignalR are as follows: + /// + /// + /// This is intended to be a simple server. + /// There are no reliability guarantees, no delivery guarantees, no authorisation. + /// The operation of this server is best-effort. + /// Due to this, ASP.NET is surplus to requirements. + /// + /// Including ASP.NET wholesale would have a negative impact on binary size. + /// + /// Using ASP.NET could expose end users' PCs to having things enabled that shouldn't be enabled via little-known configuration toggles. + /// One pertinent example is the ASPNETCORE_URLS environment variable which silently changes which endpoints an ASP.NET service listens on. + /// + /// + /// ASP.NET does not generally fit into the paradigm of being part of an application. + /// The way ASP.NET apps are structured, is that they generally take over the functioning of an application. + /// Therefore, there is not necessarily a given that ASP.NET bundled inside the client will fully stop functioning even when explicitly asked. + /// + /// + /// + public sealed class WebSocketServer : IDisposable + { + /// + /// Whether the server is currently running and listening for connection requests. + /// + public bool IsRunning => handleRequestTask != null && !runningTokenSource.IsCancellationRequested; + + /// + /// Invoked when a client is connected. + /// The argument is the assigned ID of the client. + /// + public event Action? ClientConnected; + + /// + /// Invoked when a message is received. + /// The first argument is the ID of the sender; the second is the content of the received message. + /// + public event Action? MessageReceived; + + private readonly Lock syncRoot = new Lock(); + + private readonly string prefix; + private readonly Logger logger; + + private HttpListener? listener; + private readonly ManualResetEventSlim contextResetEvent = new ManualResetEventSlim(); + private Task? handleRequestTask; + + private int channelCounter; + private readonly ConcurrentDictionary channels = new ConcurrentDictionary(); + + private readonly CancellationTokenSource runningTokenSource = new CancellationTokenSource(); + private bool isDisposed; + + public WebSocketServer(int port) + { + // Restricting to only providing a port is intentional for several reasons: + // - Use of HTTP (no efforts are taken to make HTTPS work). + // - Attack surface reduction (doesn't accidentally listen on all interfaces, potentially getting hit by something external). + // Some users with setups that use a second "streaming PC" or similar will complain. They can set up proxies at their own peril. + prefix = $@"http://localhost:{port}/"; + + logger = Logger.GetLogger(@"websocket"); + } + + /// + /// Starts the server. + /// + /// Use this to cancel start-up. + public Task StartAsync(CancellationToken cancellationToken = default) => Task.Run(() => + { + lock (syncRoot) + { + if (listener != null) + throw new InvalidOperationException($@"Cannot call {nameof(StartAsync)} multiple times."); + + listener = new HttpListener(); + listener.Prefixes.Add(prefix); + listener.Start(); + handleRequestTask = Task.Run(handleRequests, cancellationToken); + logger.Add($@"Listening on {prefix}."); + } + }, cancellationToken); + + private async Task handleRequests() + { + Debug.Assert(listener != null); + + while (!runningTokenSource.IsCancellationRequested) + { + HttpListenerContext? context = null; + + // `listener.GetContextAsync()` exists but is unusable here without ugly hacks. + // as per source inspection, it is a thin wrapper over `{Begin,End}GetContext()`. + // the problem with that is that the method is *hard-blocking* and *does not accept cancellation*. + // therefore, if it's called in a processing loop like this + // that we are expecting to be able to cut short at any moment's notice to shut things down, + // it's not going to yield and will keep waiting forever. + // a `listener.Stop()` from another thread does cut the call short, but also ends up in an unclean termination. + // what "unclean termination" means here depends on the OS we're running on + // (different exceptions are observed on macOS and Windows, at least). + // therefore use the old asynchronous paradigm with manual signalling when the context is available. + contextResetEvent.Reset(); + listener.BeginGetContext(iar => + { + try + { + context = ((HttpListener)iar.AsyncState!).EndGetContext(iar); + contextResetEvent.Set(); + } + catch (HttpListenerException ex) when (ex.ErrorCode == 995) + { + // occurs on Windows when the listener is stopped. + } + }, listener); + WaitHandle.WaitAny([contextResetEvent.WaitHandle, runningTokenSource.Token.WaitHandle]); + + // either we have a context to use, or the cancellation fired. + // if it's the latter, terminate processing loop. + if (runningTokenSource.IsCancellationRequested) + return; + + Debug.Assert(context != null); + + var request = context.Request; + var response = context.Response; + + if (!request.IsWebSocketRequest) + { + logger.Add($@"Received non-websocket request from {request.RemoteEndPoint}. Requesting upgrade."); + response.StatusCode = (int)HttpStatusCode.UpgradeRequired; + response.Headers.Add(HttpRequestHeader.Upgrade, @"websocket"); + response.Close(); + continue; + } + + HttpListenerWebSocketContext wsContext; + + try + { + wsContext = await context.AcceptWebSocketAsync(null).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Add($@"Failed to accept websocket connection from {request.RemoteEndPoint}.", LogLevel.Error, ex); + continue; + } + + int channelId = Interlocked.Increment(ref channelCounter); + var wsChannel = new WebSocketChannel(wsContext.WebSocket); + channels[channelId] = wsChannel; + wsChannel.MessageReceived += msg => MessageReceived?.Invoke(channelId, msg); + wsChannel.ClosedPrematurely += () => onChannelClosed(channelId); + wsChannel.Start(runningTokenSource.Token); + logger.Add($@"Accepted websocket connection from {request.RemoteEndPoint} as client #{channelId}."); + ClientConnected?.Invoke(channelId); + } + } + + private void onChannelClosed(int channelId) + { + if (channels.TryRemove(channelId, out var channel)) + channel.Dispose(); + logger.Add($@"Connection with client #{channelId} closed."); + } + + /// + /// Sends to the specific client with the given . + /// + /// is not known. + public async Task SendAsync(int clientId, string message) + { + if (!channels.TryGetValue(clientId, out var channel)) + throw new ArgumentException($@"Client {clientId} is not known."); + + logger.Add($@"Sending to client {clientId}: {message}"); + await channel.SendAsync(message).ConfigureAwait(false); + } + + /// + /// Sends to all connected clients. + /// + public Task BroadcastAsync(string message) + { + logger.Add($@"Broadcasting to all clients: {message}"); + return Task.WhenAll(channels.Values.Select(ch => ch.SendAsync(message)).ToArray()); + } + + /// + /// Stops the server. + /// + /// Cancel this to transition from a graceful shutdown to a forced shutdown. + public Task StopAsync(CancellationToken stoppingToken = default) => Task.Run(async () => + { + if (isDisposed) + return; + + logger.Add(@"Stopping websocket server..."); + + // of note, ordering here is important - the token is supposed to be cancelled *before* the listener is stopped. + // see `readWriteTask()` and the treatment of early cancellation for answer why. + await runningTokenSource.CancelAsync().ConfigureAwait(false); + + if (handleRequestTask != null) + { + try + { + await handleRequestTask.WaitAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // has to be caught manually because outer task isn't accepting `stoppingToken`. + } + } + + try + { + listener?.Stop(); + } + catch (ObjectDisposedException) + { + // observed to intermittently fire on unices in unclear circumstances. tragic, but also irrelevant at this point. the point is to stop. + } + + try + { + await Task.WhenAll(channels.Values.Select(ch => ch.StopAsync(stoppingToken)).ToArray()).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // has to be caught manually because outer task isn't accepting `stoppingToken`. + } + + logger.Add(@"Websocket server stopped."); + }, CancellationToken.None); // we always want this task to start running. passing `stoppingToken` here would mean potentially never even scheduling it for execution. + + public void Dispose() + { + if (isDisposed) + return; + + isDisposed = true; + + // ensure the request loop is unblocked and stops touching `runningTokenSource`/`contextResetEvent` + // before we dispose them. callers that didn't call `StopAsync()` first would otherwise race the loop + // and trip `ObjectDisposedException` on shutdown. + try + { + if (!runningTokenSource.IsCancellationRequested) + runningTokenSource.Cancel(); + } + catch (ObjectDisposedException) + { + } + + // no clue why this isn't accessible without casting. + // sidebar: `Stop()` unregisters addresses on Windows, but `Abort()` doesn't! + // this `Dispose()` implementation calls the former. + (listener as IDisposable)?.Dispose(); + + // give the request loop a brief opportunity to exit gracefully before we yank the wait handles out + // from under it. this is best-effort; we don't want `Dispose()` to ever block for long. + try + { + handleRequestTask?.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // any cancellation/aggregate exceptions here are expected during shutdown. + } + + foreach (var channel in channels.Values) + channel.Dispose(); + + runningTokenSource.Dispose(); + contextResetEvent.Dispose(); + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 9679c0808f75..3fd026eb6cf9 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -310,6 +310,12 @@ private APIRankHistory rankHistory Colour = @"9c0101", }; + public static APIUser UnknownUser(int userId) => new APIUser + { + Id = userId, + Username = "Unknown user", + }; + public int OnlineID => Id; public bool Equals(APIUser other) => this.MatchesOnlineID(other); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 02e944df9f93..594f37fde700 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -27,8 +27,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue /// /// Includes support for deferring to background. /// - /// - /// This is initialised and cached in the but can be used throughout the system via DI. public partial class QueueController : Component { public readonly Bindable CurrentState = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs index d08635a444d2..73eafecc808d 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -90,6 +90,7 @@ public partial class ScreenQueue : OsuScreen private SampleChannel? waitingLoopChannel; private ScheduledDelegate? startLoopPlaybackDelegate; private DrawableSample waitingLoop = null!; + private ScheduledDelegate? pushScreenDelegate; private int? userRating; @@ -390,14 +391,14 @@ private void onSelectedPoolChanged(ValueChangedEvent e) if (e.NewValue == null) { - client.MatchmakingLeaveLobby(); + client.MatchmakingLeaveLobby().FireAndForget(); return; } client.MatchmakingJoinLobbyWithParams(new MatchmakingJoinLobbyRequest { PoolId = e.NewValue.Id - }); + }).FireAndForget(); } public override void OnEntering(ScreenTransitionEvent e) @@ -465,6 +466,9 @@ public void SetState(MatchmakingScreenState newState) startLoopPlaybackDelegate?.Cancel(); stopWaitingLoopPlayback(); + pushScreenDelegate?.Cancel(); + pushScreenDelegate = null; + switch (newState) { case MatchmakingScreenState.Idle: @@ -599,7 +603,7 @@ public void SetState(MatchmakingScreenState newState) using (BeginDelayedSequence(2000)) { - Schedule(() => + pushScreenDelegate = Schedule(() => { switch (poolType) { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs index fac46d8d84b8..c72fb710c40d 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs @@ -45,8 +45,6 @@ public partial class SongPreviewContainer : Container, IBeatSyncProvider private readonly Container overlayLayer; - private bool shouldBePlaying => Enabled.Value && CardHovered.Value; - [Resolved] private PreviewTrackManager previewTrackManager { get; set; } = null!; @@ -77,33 +75,6 @@ public SongPreviewContainer() ]; } - protected override void LoadComplete() - { - base.LoadComplete(); - - Enabled.BindValueChanged(enabled => - { - if (!enabled.NewValue) - { - previewTrack?.Stop(); - return; - } - - if (shouldBePlaying) - { - startPreviewIfAvailable(); - } - }); - - CardHovered.BindValueChanged(selected => - { - if (selected.NewValue && shouldBePlaying) - { - startPreviewIfAvailable(); - } - }); - } - private PreviewTrack? previewTrack; public void LoadPreview(APIBeatmap beatmap) @@ -127,13 +98,23 @@ public void LoadPreview(APIBeatmap beatmap) TrackRunning = { BindTarget = trackRunning } }); - if (shouldBePlaying) - startPreviewIfAvailable(); + // bind start/stop to hover + enable state once the track has actually loaded, + // to avoid attempting to start playback while the track is still being prepared in flaky network conditions. + Enabled.BindValueChanged(_ => updatePlaybackState()); + CardHovered.BindValueChanged(_ => updatePlaybackState(), true); }); } + private void updatePlaybackState() + { + if (previewTrack == null) + return; - private void startPreviewIfAvailable() => previewTrack?.Start(); + if (Enabled.Value && CardHovered.Value) + previewTrack.Start(); + else + previewTrack.Stop(); + } #region IBeatSyncProvider implementation diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs index 0ad621fbc92c..27103371c0bf 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs @@ -7,7 +7,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -29,7 +28,18 @@ public abstract partial class HandOfCards : CompositeDrawable private const float card_spacing = -15; - public IReadOnlyList Cards => cardContainer.Children; + /// + /// Cards currently present in this + /// + /// + /// Entries are not sorted by display order + /// + public IEnumerable Cards => cardLookup.Values; + + /// + /// Returns a list of the cards present in this ordered by the cards' + /// + public List GetCardsInDisplayOrder() => Cards.OrderBy(static c => c.Order).ToList(); /// /// How far a card slides upwards when hovered. @@ -66,12 +76,6 @@ protected override void Update() { base.Update(); - if (!drawOrderBacking.IsValid) - { - cardContainer.Sort(); - drawOrderBacking.Validate(); - } - if (!layoutBacking.IsValid) { updateLayout(); @@ -126,23 +130,25 @@ public void AddCard(RankedPlayCard card, Action? setupAction = null) drawable.Order = cardContainer.Max(c => c.Order) + 1; cardContainer.Add(drawable); - InvalidateLayout(drawOrder: true); + cardContainer.Sort(); + InvalidateLayout(); setupAction?.Invoke(drawable); } - public void Clear() => cardContainer.Clear(); + public void Clear() + { + foreach (var card in Cards.ToArray()) + RemoveCard(card.Item); + } public bool RemoveCard(RankedPlayCardWithPlaylistItem item) { if (!cardLookup.Remove(item.Card, out var drawable)) return false; - // child order is only updated once per frame so ordering can change between that and the card getting removed - // which can mess when doing a binary-search for the child during removal - cardContainer.Sort(); cardContainer.Remove(drawable, true); - InvalidateLayout(drawOrder: true); + InvalidateLayout(); return true; } @@ -165,11 +171,8 @@ public bool RemoveCard(RankedPlayCardWithPlaylistItem item, [MaybeNullWhen(false screenSpaceDrawQuad = drawable.ScreenSpaceDrawQuad; card = drawable.Detach(); - // child order is only updated once per frame so ordering can change between that and the card getting removed - // which can mess when doing a binary-search for the child during removal - cardContainer.Sort(); cardContainer.Remove(drawable, true); - InvalidateLayout(drawOrder: true); + InvalidateLayout(); return true; } @@ -178,7 +181,9 @@ public bool RemoveCard(RankedPlayCardWithPlaylistItem item, [MaybeNullWhen(false protected virtual void OnCardStateChanged(HandCard card, ValueChangedEvent evt) { - InvalidateLayout(drawOrder: affectsDrawOrder(evt)); + InvalidateLayout(); + if (affectsDrawOrder(evt)) + cardContainer.Sort(); // hovered state can be caused by keyboard focus, in which case we have to clean up after the other cards manually if (evt.NewValue.Hovered) @@ -198,18 +203,11 @@ private static bool affectsDrawOrder(ValueChangedEvent evt) #region Layout private readonly LayoutValue layoutBacking = new LayoutValue(Invalidation.DrawSize | Invalidation.MiscGeometry); - private readonly Cached drawOrderBacking = new Cached(); /// /// Invalidates the layout of the hand of cards, causing a relayout to occur. /// - /// If set to true, also invalidates the draw order of the cards. - protected void InvalidateLayout(bool drawOrder = false) - { - layoutBacking.Invalidate(); - if (drawOrder) - drawOrderBacking.Invalidate(); - } + protected void InvalidateLayout() => layoutBacking.Invalidate(); private void updateLayout() { @@ -217,11 +215,11 @@ private void updateLayout() return; // card container draws dragged card on top so we need to sort those separately - var cards = cardContainer.Children.OrderBy(static c => c.State.Order).ToArray(); + var cards = GetCardsInDisplayOrder(); int activeCardIndex = GetActiveCardIndex(cards); - for (int i = 0; i < cards.Length; i++) + for (int i = 0; i < cards.Count; i++) { var card = cards[i]; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs index 983c0eae9a60..9011b3c085a9 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs @@ -151,13 +151,21 @@ protected override bool OnKeyDown(KeyDownEvent e) if (e.Repeat || Contracted || Cards.Any(static c => c.CardDragged)) return false; + if (e.ShiftPressed || e.ControlPressed || e.AltPressed || e.SuperPressed) + return false; + switch (e.Key) { case >= Key.Number1 and <= Key.Number9: - focusCard(e.Key - Key.Number1); + { + int index = e.Key - Key.Number1; + if (GetCardsInDisplayOrder().ElementAtOrDefault(index) is HandCard card) + focusCard(card); return true; + } case Key.Space: + { if (SelectionMode == HandSelectionMode.Disabled) return false; @@ -170,6 +178,7 @@ protected override bool OnKeyDown(KeyDownEvent e) card.TriggerClick(); return true; + } case Key.Left: moveCardFocus(-1); @@ -185,7 +194,9 @@ protected override bool OnKeyDown(KeyDownEvent e) private void moveCardFocus(int direction) { - int currentIndex = Cards.ToList().FindIndex(c => c.HasFocus); + var cards = GetCardsInDisplayOrder(); + + int currentIndex = cards.FindIndex(c => c.HasFocus); // default behaviour is to start from either end of the cards if no card is focused currently // in single-selection mode we can however use the current selection as a fallback index if there's no focus @@ -195,20 +206,15 @@ private void moveCardFocus(int direction) int newIndex = currentIndex + direction; if (newIndex < 0) - newIndex = Cards.Count - 1; - else if (newIndex >= Cards.Count) + newIndex = cards.Count - 1; + else if (newIndex >= cards.Count) newIndex = 0; - focusCard(newIndex); + focusCard(cards[newIndex]); } - private void focusCard(int index) + private void focusCard(HandCard card) { - var card = Cards.ElementAtOrDefault(index); - - if (card == null) - return; - GetContainingFocusManager()?.ChangeFocus(card); if (SelectionMode == HandSelectionMode.Single && !card.Selected) @@ -217,7 +223,7 @@ private void focusCard(int index) private void cardDragged(PlayerHandCard card, Vector2 screenSpacePosition) { - var cards = Cards.OrderBy(static c => c.Order).ToArray(); + var cards = GetCardsInDisplayOrder(); int newIndex = cardIndexInLayout(cards, card.ScreenSpaceDrawQuad.Centre); @@ -240,9 +246,9 @@ private void cardDragged(PlayerHandCard card, Vector2 screenSpacePosition) c.Item.DisplayOrder = c.Order; } - private int cardIndexInLayout(HandCard[] cards, Vector2 screenSpacePosition) + private int cardIndexInLayout(IReadOnlyList cards, Vector2 screenSpacePosition) { - Debug.Assert(cards.Length > 0); + Debug.Assert(cards.Count > 0); var position = ToLocalSpace(screenSpacePosition) - DrawSize / 2; @@ -251,7 +257,7 @@ private int cardIndexInLayout(HandCard[] cards, Vector2 screenSpacePosition) int minIndex = 0; float minDistance = float.MaxValue; - for (int i = 0; i < cards.Length; i++) + for (int i = 0; i < cards.Count; i++) { float distance = MathF.Abs(GetCardX(i, activeIndex) - position.X); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs index 017dc30f59a1..4761704e3436 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs @@ -60,8 +60,10 @@ private async Task loadUsers() var users = await userLookupCache.GetUsersAsync(userIds).ConfigureAwait(false); - var player = users.OfType().First(it => it.Id == api.LocalUser.Value.Id); - var opponent = users.OfType().First(it => it.Id != api.LocalUser.Value.Id); + var player = users.OfType().FirstOrDefault(it => it.Id == api.LocalUser.Value.Id) + ?? api.LocalUser.Value; + var opponent = users.OfType().FirstOrDefault(it => it.Id != api.LocalUser.Value.Id) + ?? APIUser.UnknownUser(userIds.First(id => id != api.LocalUser.Value.Id)); int playerRating = roomState.Users[player.Id].Rating; int opponentRating = roomState.Users[opponent.Id].Rating; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs index bb6d4cb2701f..b087472b77b0 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs @@ -193,8 +193,8 @@ protected override void LoadComplete() int localUserId = api.LocalUser.Value.OnlineID; int opponentUserId = ((RankedPlayRoomState)client.Room!.MatchState!).Users.Keys.Single(it => it != localUserId); - localUser = users.GetUserAsync(localUserId).GetResultSafely()!; - opponentUser = users.GetUserAsync(opponentUserId).GetResultSafely()!; + localUser = users.GetUserAsync(localUserId).GetResultSafely() ?? api.LocalUser.Value; + opponentUser = users.GetUserAsync(opponentUserId).GetResultSafely() ?? APIUser.UnknownUser(opponentUserId); AddRangeInternal([ new RankedPlayCornerPiece(RankedPlayColourScheme.BLUE, Anchor.BottomLeft) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 0c31e1f0db1e..73cc12644635 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -607,7 +607,7 @@ private void onMatchEvent(MatchServerEvent ev) switch (ev) { case RollEvent rollEvent: - var user = client.Room?.Users.SingleOrDefault(u => u.UserID == rollEvent.UserID)?.User ?? new APIUser { Username = "Unknown user" }; + var user = client.Room?.Users.SingleOrDefault(u => u.UserID == rollEvent.UserID)?.User ?? APIUser.UnknownUser(rollEvent.UserID); string text = $"{user.Username} rolled {"point".ToQuantity(rollEvent.Result)} out of {rollEvent.Max}."; chat.Channel.Value?.AddNewMessages(new InfoMessage(text)); break; diff --git a/osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs b/osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs index 01bc56c1b57c..4d3485ba2168 100644 --- a/osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs +++ b/osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs @@ -89,11 +89,7 @@ private void load(OsuConfigManager config, IAPIProvider api, CancellationToken c for (int i = 0; i < lookedUpUsers.Length; i++) { - var user = lookedUpUsers[i] ?? new APIUser - { - Id = users[i].UserID, - Username = "Unknown user", - }; + var user = lookedUpUsers[i] ?? APIUser.UnknownUser(users[i].UserID); var trackedUser = UserScores[user.Id];