Skip to content

Commit 3031578

Browse files
Address Copilot AI review: SongPreview playback, WebSocket UTF-8/boundary/Dispose
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/a7e9baba-d7f9-4d66-bac6-a6540c230360 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent bc9b8ae commit 3031578

5 files changed

Lines changed: 62 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ This fork includes several hardening fixes on top of upstream:
189189
- **JNI surface safety** — proper lifecycle management with atomic swaps and timeouts to prevent race conditions between Android surface creation and destruction
190190
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming so release builds behave the same as debug
191191
- **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.
192194

193195
---
194196

osu.Desktop/IPC/OsuWebSocketProvider.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,23 @@ protected override void Dispose(bool isDisposing)
6363
{
6464
base.Dispose(isDisposing);
6565

66-
if (server?.IsRunning == true)
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
6781
{
68-
var cts = new CancellationTokenSource();
69-
cts.CancelAfter(TimeSpan.FromSeconds(10));
70-
server.StopAsync(cts.Token).WaitSafely();
71-
server = null;
82+
localServer.Dispose();
7283
}
7384
}
7485
}

osu.Game/IPC/WebSocketChannel.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public sealed class WebSocketChannel : IDisposable
2323
private readonly byte[] receiveBuffer = new byte[max_message_size];
2424
private int currentBufferPosition;
2525

26+
// strict UTF-8 decoder so malformed payloads throw `DecoderFallbackException` rather than being
27+
// silently replaced with U+FFFD (which would defeat the `InvalidPayloadData` close path below).
28+
private static readonly Encoding strict_utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
29+
2630
private readonly WebSocket webSocket;
2731
private Task? readWriteTask;
2832
private readonly CancellationTokenSource runningTokenSource = new CancellationTokenSource();
@@ -100,7 +104,7 @@ private async Task readWriteLoop()
100104
return;
101105
}
102106

103-
if (currentBufferPosition >= max_message_size)
107+
if (currentBufferPosition > max_message_size)
104108
{
105109
await webSocket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, $@"Exceeded maximum message size of {max_message_size} bytes.", token).ConfigureAwait(false);
106110
ClosedPrematurely?.Invoke();
@@ -113,9 +117,9 @@ private async Task readWriteLoop()
113117

114118
try
115119
{
116-
message = Encoding.UTF8.GetString(receiveBuffer, 0, currentBufferPosition);
120+
message = strict_utf8.GetString(receiveBuffer, 0, currentBufferPosition);
117121
}
118-
catch (ArgumentException)
122+
catch (DecoderFallbackException)
119123
{
120124
await webSocket.CloseOutputAsync(WebSocketCloseStatus.InvalidPayloadData, @"UTF-8 encoded strings expected.", token).ConfigureAwait(false);
121125
ClosedPrematurely?.Invoke();

osu.Game/IPC/WebSocketServer.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,34 @@ public void Dispose()
265265

266266
isDisposed = true;
267267

268+
// ensure the request loop is unblocked and stops touching `runningTokenSource`/`contextResetEvent`
269+
// before we dispose them. callers that didn't call `StopAsync()` first would otherwise race the loop
270+
// and trip `ObjectDisposedException` on shutdown.
271+
try
272+
{
273+
if (!runningTokenSource.IsCancellationRequested)
274+
runningTokenSource.Cancel();
275+
}
276+
catch (ObjectDisposedException)
277+
{
278+
}
279+
268280
// no clue why this isn't accessible without casting.
269281
// sidebar: `Stop()` unregisters addresses on Windows, but `Abort()` doesn't!
270282
// this `Dispose()` implementation calls the former.
271283
(listener as IDisposable)?.Dispose();
272284

285+
// give the request loop a brief opportunity to exit gracefully before we yank the wait handles out
286+
// from under it. this is best-effort; we don't want `Dispose()` to ever block for long.
287+
try
288+
{
289+
handleRequestTask?.Wait(TimeSpan.FromSeconds(2));
290+
}
291+
catch
292+
{
293+
// any cancellation/aggregate exceptions here are expected during shutdown.
294+
}
295+
273296
foreach (var channel in channels.Values)
274297
channel.Dispose();
275298

osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,24 @@ public void LoadPreview(APIBeatmap beatmap)
9797
{
9898
TrackRunning = { BindTarget = trackRunning }
9999
});
100+
101+
// bind start/stop to hover + enable state once the track has actually loaded,
102+
// to avoid attempting to start playback while the track is still being prepared in flaky network conditions.
103+
Enabled.BindValueChanged(_ => updatePlaybackState());
104+
CardHovered.BindValueChanged(_ => updatePlaybackState(), true);
100105
});
101106
}
102107

108+
private void updatePlaybackState()
109+
{
110+
if (previewTrack == null)
111+
return;
103112

104-
private void startPreviewIfAvailable() => previewTrack?.Start();
113+
if (Enabled.Value && CardHovered.Value)
114+
previewTrack.Start();
115+
else
116+
previewTrack.Stop();
117+
}
105118

106119
#region IBeatSyncProvider implementation
107120

0 commit comments

Comments
 (0)