Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update notification text when import is paused due to gameplay #30402

Merged
merged 6 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
20 changes: 8 additions & 12 deletions osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,13 @@ public void TestSceneMissingBeatmapWithOnlineAvailable()

AddStep("import score", () =>
{
using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"))
{
var importTask = new ImportTask(resourceStream, "replay.osr");
var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr");
var importTask = new ImportTask(resourceStream, "replay.osr");

Game.ScoreManager.Import(new[] { importTask });
}
Game.ScoreManager.Import(new[] { importTask });
});

AddUntilStep("Replay missing notification show", () => Game.Notifications.ChildrenOfType<MissingBeatmapNotification>().Any());
AddUntilStep("Replay missing notification shown", () => Game.Notifications.ChildrenOfType<MissingBeatmapNotification>().Any());
}

[Test]
Expand All @@ -58,15 +56,13 @@ public void TestSceneMissingBeatmapWithOnlineUnavailable()

AddStep("import score", () =>
{
using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"))
{
var importTask = new ImportTask(resourceStream, "replay.osr");
var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr");
var importTask = new ImportTask(resourceStream, "replay.osr");

Game.ScoreManager.Import(new[] { importTask });
}
Game.ScoreManager.Import(new[] { importTask });
});

AddUntilStep("Replay missing notification not show", () => !Game.Notifications.ChildrenOfType<MissingBeatmapNotification>().Any());
AddUntilStep("Replay missing notification not shown", () => !Game.Notifications.ChildrenOfType<MissingBeatmapNotification>().Any());
}

private void setupBeatmapResponse(APIBeatmap b)
Expand Down
31 changes: 21 additions & 10 deletions osu.Game/Database/RealmArchiveModelImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,27 @@ public async Task<IEnumerable<Live<TModel>>> Import(ProgressNotification notific
}

notification.Progress = 0;
notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising...";

int current = 0;

var imported = new List<Live<TModel>>();

parameters.Batch |= tasks.Length >= minimum_items_considered_batch_import;

await Task.WhenAll(tasks.Select(async task =>
notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising...";
notification.State = ProgressNotificationState.Active;

await pauseIfNecessaryAsync(parameters, notification, notification.CancellationToken).ConfigureAwait(false);

await Parallel.ForEachAsync(tasks, notification.CancellationToken, async (task, cancellation) =>
{
if (notification.CancellationToken.IsCancellationRequested)
return;
cancellation.ThrowIfCancellationRequested();
Copy link
Contributor

Choose a reason for hiding this comment

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

Are you sure about this? Won't this be logged (to the user also) as an unobserved exception? I mean... see the try-catch below which even swallows the OperationCanceledException

Copy link
Contributor

Choose a reason for hiding this comment

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

The pauseIfNecessaryAsync method also throws and will likely do the same thing too outside of the ForEachAsync here (maybe a reproduction for that is enter gameplay, start import from stable, then cancel the notification?).

Copy link
Member Author

@peppy peppy Nov 14, 2024

Choose a reason for hiding this comment

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

It definitely doesn't come up as an unobserved else I would have noticed.

You can test using:

diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs
index c4eb93b754..addba3f338 100644
--- a/osu.Game/Database/RealmArchiveModelImporter.cs
+++ b/osu.Game/Database/RealmArchiveModelImporter.cs
@@ -119,6 +119,9 @@ public async Task<IEnumerable<Live<TModel>>> Import(ProgressNotification notific
 
             await Parallel.ForEachAsync(tasks, notification.CancellationToken, async (task, cancellation) =>
             {
+                while (!notification.CancellationToken.IsCancellationRequested)
+                    Thread.Sleep(1000);
+
                 cancellation.ThrowIfCancellationRequested();
 
                 try
@@ -139,6 +142,7 @@ await Parallel.ForEachAsync(tasks, notification.CancellationToken, async (task,
                 }
                 catch (OperationCanceledException)
                 {
+                    throw;
                 }
                 catch (Exception e)
                 {
@@ -531,7 +535,8 @@ protected virtual void PostImport(TModel model, Realm realm, ImportParameters pa
         /// <param name="model">The new model proposed for import.</param>
         /// <param name="realm">The current realm context.</param>
         /// <returns>An existing model which matches the criteria to skip importing, else null.</returns>
-        protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All<TModel>().OrderBy(b => b.DeletePending).FirstOrDefault(b => b.Hash == model.Hash);
+        protected TModel? CheckForExisting(TModel model, Realm realm) =>
+            string.IsNullOrEmpty(model.Hash) ? null : realm.All<TModel>().OrderBy(b => b.DeletePending).FirstOrDefault(b => b.Hash == model.Hash);
 
         /// <summary>
         /// Whether import can be skipped after finding an existing import early in the process.

Debugging through I see a TaskCanceledException but it gracefully handles somewhere (debugger doesn't let me see). But maybe there's a scenario where that's not the case? I couldn't find one.

Using ThrowIfCancellationRequested is recommended way to handle this, so I'd be inclined to add a catch somewhere if it's required, rather than change the calls.

Copy link
Contributor

@smoogipoo smoogipoo Nov 18, 2024

Choose a reason for hiding this comment

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

It is causing an issue with your diff. When you cancel the notification the download/download buttons (in beatmap listing) remain active in an "importing" state, with no way to restore.

request.Success += filename =>
{
Task.Factory.StartNew(async () =>
{
bool importSuccessful;
if (originalModel != null)
importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel).ConfigureAwait(false)) != null;
else
importSuccessful = (await importer.Import(notification, new[] { new ImportTask(filename) }).ConfigureAwait(false)).Any();
// for now a failed import will be marked as a failed download for simplicity.
if (!importSuccessful)
DownloadFailed?.Invoke(request);
CurrentDownloads.Remove(request);
}, TaskCreationOptions.LongRunning);
};

I'm seeing nothing special about what's happening here, it just looks like OperationCanceledExceptions are never unobserved / don't reach that handler. Throwing InvalidOperationException works, throwing OperationCanceledException doesn't 😅

Also, it's actually even caused by just passing into the Parallel.ForEachAsync() itself without throwing from inside the callback delegate...

Copy link
Member Author

Choose a reason for hiding this comment

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

I've moved all the "clean up" actions in this flow to use try-finally for safety. Seems to work as expected now.


try
{
var model = await Import(task, parameters, notification.CancellationToken).ConfigureAwait(false);
await pauseIfNecessaryAsync(parameters, notification, cancellation).ConfigureAwait(false);

var model = await Import(task, parameters, cancellation).ConfigureAwait(false);

lock (imported)
{
Expand All @@ -139,7 +144,7 @@ await Task.WhenAll(tasks.Select(async task =>
{
Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database);
}
})).ConfigureAwait(false);
}).ConfigureAwait(false);

if (imported.Count == 0)
{
Expand Down Expand Up @@ -286,8 +291,6 @@ public async Task<ExternalEditOperation<TModel>> BeginExternalEditing(TModel mod
/// <param name="cancellationToken">An optional cancellation token.</param>
public virtual Live<TModel>? ImportModel(TModel item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => Realm.Run(realm =>
{
pauseIfNecessary(parameters, cancellationToken);
smoogipoo marked this conversation as resolved.
Show resolved Hide resolved

TModel? existing;

if (parameters.Batch && archive != null)
Expand Down Expand Up @@ -575,21 +578,29 @@ protected virtual void UndeleteForReuse(TModel existing)
/// <returns>Whether to perform deletion.</returns>
protected virtual bool ShouldDeleteArchive(string path) => false;

private void pauseIfNecessary(ImportParameters importParameters, CancellationToken cancellationToken)
private async Task pauseIfNecessaryAsync(ImportParameters importParameters, ProgressNotification notification, CancellationToken cancellationToken)
{
if (!PauseImports || importParameters.ImportImmediately)
return;

Logger.Log($@"{GetType().Name} is being paused.");

// A paused state could obviously be entered mid-import (during the `Task.WhenAll` below),
// but in order to keep things simple let's focus on the most common scenario.
notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is paused due to gameplay...";
notification.State = ProgressNotificationState.Queued;

while (PauseImports)
{
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(500);
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
}

cancellationToken.ThrowIfCancellationRequested();
Logger.Log($@"{GetType().Name} is being resumed.");

notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is resuming...";
notification.State = ProgressNotificationState.Active;
}

private IEnumerable<string> getIDs(IEnumerable<INamedFile> files)
Expand Down