Framework Fixes - #2910
Framework Fixes#2910MidoriKami wants to merge 15 commits into
Conversation
Fixes various deadlock cases, and ensures awaits work as expected.
…esult to maintain non-blocking behavior.
# Conflicts: # Dalamud/Game/Framework.cs
| cancellationToken, | ||
| TaskContinuationOptions.HideScheduler, | ||
| this.frameworkThreadTaskScheduler).Unwrap(); | ||
| await Task.Factory.StartNew( |
There was a problem hiding this comment.
Does this need a .Unwrap()? func is a functor that returns a Task, which you're explicitly not awaiting here, so the code people actually care about disappears into thin air and gets detached. The the generic overload above doesn't have this problem and it looks like the tests in the description don't catch it since they don't use await, I assume it's just an oversight. We should have a self-test that covers this tho, if it managed to slip through
There was a problem hiding this comment.
You're correct, the original code had an unwrap here, that got missed when updating to the new approach.
|
|
||
| linkedCts.Token.ThrowIfCancellationRequested(); | ||
|
|
||
| if (this.IsInFrameworkUpdateThread) |
There was a problem hiding this comment.
I think the fact that the two branches here act different now is pretty dangerous. The else branch hides the scheduler, so every await is back on the framework thread, but in the if branch doesn't do this, so the scheduler is preserved and you end up on the thread pool if you await. Is that what you were intending? The same call has two different threading behaviors based on where you call it from, so I think it might actually be more confusing
Maybe a good example is something like this:
await framework.Run(async () =>
{
await SomeOperation();
SomethingWithATK(); // framework thread if called off-thread but thread pool if called on it
});The synchronous Action / Func overloads don't have this, since there's no continuation. So one option is to keep the inline shortcut only for those two and let the async overloads always go through the scheduler, maybe, but I don't know if that has problems during shutdown. Otherwise the inline path needs to run under the framework scheduler as well. We should also have a test for that.
There was a problem hiding this comment.
Good catch, I hadn't considered the difference between passing an action/func vs passing a task here.
I have tested with removing the the IsInFrameworkUpdateThread shortcut, and there seems to be no issues with unloading that I found.
|
|
||
| linkedCts.Token.ThrowIfCancellationRequested(); | ||
|
|
||
| if (this.IsInFrameworkUpdateThread) |
There was a problem hiding this comment.
This has the same problem as L181
There was a problem hiding this comment.
Same as previous feedback, removed the shortcut and tested behavior.
|
|
||
| /// <inheritdoc/> | ||
| public Task<T> RunOnTick<T>(Func<T> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) | ||
| public async Task<T> RunOnTick<T>(Func<T> func, TimeSpan delay = default, int delayTicks = 0, CancellationToken cancellationToken = default) |
There was a problem hiding this comment.
I guess making these async means that if you call .Wait() or .Result on them on the framework thread you would deadlock for sure since you're awaiting something on the next tick from the current tick? Should probably be in the docs if that's intentional?
There was a problem hiding this comment.
Tested that theory with:
await IFramework.Get().Run(() => {
IFramework.Get().RunOnTick(() => IPluginLog.Get().Debug("Did we deadlock?")).Wait();
});And it does definitely deadlock. I'll add some docs. Personally it does not make sense to use RunOnTick.Wait() logically you'd use Run.
Because I feel that it's invalid to say "I wanna do this next frame(or later), but I want to wait for it." in non-async context is weird.
| /// version of <c>RunOnFrameworkThread</c>.</para> | ||
| /// </remarks> | ||
| [Obsolete($"Use {nameof(RunOnTick)} instead.")] | ||
| [Obsolete($"Use {nameof(RunOnTick)} instead.", true)] |
There was a problem hiding this comment.
We use these still in Dalamud, right? Should we move? The one in Framework isn't obsolete so we don't get warnings inside Dalamud
There was a problem hiding this comment.
The one in Framework not being marked Obsolete was an oversight. This PR already switched over a few calls that resolve it via the interface, and there were no issues with those. I will mark the Framework calls as Obsolete aswell, update dalamud internals, and test for side-effect. I don't anticipate any side effects.
There was a problem hiding this comment.
I have updated all the calls to RunOnFrameworkThread in dalamud to use Run or RunOnTick, there were one or two cases where Run caused unexpected behavior, but those were things that get set inside delegates that get invoked through some complex mechanism that I wasn't able to track down, so those were switched to RunOnTick and seemed to behave properly.
|
|
||
| /// <inheritdoc/> | ||
| public Task<T> RunOnTick<T>(Func<T> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) | ||
| public async Task<T> RunOnTick<T>(Func<T> func, TimeSpan delay = default, int delayTicks = 0, CancellationToken cancellationToken = default) |
There was a problem hiding this comment.
Another thing, these return a cancelled task during shutdown now instead of just running in-place we we used to. Maybe that's fine (makes some sense to me since it's more correct, but less predictable for devs since they will not notice till they shut their game down and manage to look in the log after) but the calls in Dalamud still use the old functions that do the in-place run so I'm afraid that this is unexpected for devs. Intentional?
There was a problem hiding this comment.
Dalamud unload logic was changed with Hasel's PR's for cleaner unload, so it's my understanding that frameworkDestroyed only occurs after all plugins have been unloaded.
Hasel's PR effectively made plugins now no longer need to know if the game is unloading, as the games tick is kept alive until all plugins have been unloaded/disposed.
KamiToolKit and VanillaPlus take significant advantage of this behavior. So I don't think devs will see any issues with things not running and throwing exceptions/task cancellations during unload.
I may be misunderstanding the issue here.
| [ServiceManager.ServiceDependency] | ||
| private readonly Framework frameworkService = Service<Framework>.Get(); | ||
|
|
||
| private readonly CancellationTokenSource pluginUnloadCancellationToken; |
There was a problem hiding this comment.
Dispose? Also it's a token source not a token (but whatever)
There was a problem hiding this comment.
I've renamed the variable, but I'm not sure what the proper way to dispose of a CTS, it feels improper to .Cancel then .Dispose? Unless that's fine?
| => this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken); | ||
| public async Task<T> RunOnTick<T>(Func<T> func, TimeSpan delay = default, int delayTicks = 0, CancellationToken cancellationToken = default) | ||
| { | ||
| using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, this.pluginUnloadCancellationToken.Token); |
There was a problem hiding this comment.
We have like triple-layered CTS flying around here now, maybe deserves a comment at the top of the file explaining the idea behind it. Also need to be very careful to dispose all of these since they're linked to the session-wide source and created for every call but it looks fine at a glance
There was a problem hiding this comment.
Oh not sure why my comment to this one disappeared...
I did some digging on the linked token source, and it does not seem to mutate or effect the original tokens in any way, and every use of the CreateLinkedTokenSource is automatically disposed via using, so I think we're good here.
As for performance impacts, docs seemed to indicate that there's no meaningful impact for creating nested cancellation tokens.
The main intent with them is to have dalamud unload, plugin unload, and user cancellation all behave the same way.
| /// This class represents the Framework of the native game client and grants access to various subsystems. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para><b>Choosing between <c>RunOnFrameworkThread</c> and <c>Run</c></b></para> |
There was a problem hiding this comment.
I guess this still kind of matters in a way since there is still a difference in threading model between Run() and RunOnTick(). Might be worth explaining that here
There was a problem hiding this comment.
I struggle with the particular nuance here, as both Run and RunOnTick will be operating on the main thread, what bearing does that have on a particular plugin dev other than that?
I understand that Run is using a task pool on the main thread, where as RunOnTick is running inside the games tick function, would the only meaningful difference be the timining of which Run vs RunOnTick occurs?
|
Pushed updated Framework Fixes code, there's some some questions I have that I have posted to individual feedbacks. I have tested dalamud loading and unloading with each individual change from RunOnFrameworkThread to Run/RunOnTick. The places where I use RunOnTick seemed to have issues with using Run and would prevent the game from loading. It wasn't feezing/deadlocking the game, but it was leaving me with a black screen and often either a windows cursor, and sometimes a game cursor. |
The primary goal of this PR is to reduce/eliminate various async footguns when using the Framework service.
The Problem
It is very unintuitive that
await IFramework.RunOnFrameworkThread(...)would cause all code executed after the call to be on the main thread. This has shown to be a mistake that multiple plugin devs have made and will likely continue to make as IAsyncDalamudPlugin becomes more widely used.IFramework.Run(...)
Changes the functionality of
IFramework.Run(...)to execute immediately if already on the main thread, this prevents the deadlock that can occur if you accidentallyawait IFramework.Run(...)while already on the main thread, as it will run the delegate immediately, and then returnTask.CompletedTask.IFramework.RunOnTick(...)
Additionally changes the functionality of
IFramework.RunOnTick(...)to both useasyncfunctionality and prevents awaiting the call from returning you back to the main thread as was the problem withawait IFramework.RunOnFrameworkThread(...).The result of these changes is IFramework Run/RunOnTick will behave intuitively, and safely.
IFramework.RunOnFrameworkThread
Additionally I have set obsoletes that have been around since 2024, to Obsolete with error, and marked
IFramework.RunOnFrameworkThreadas obsolete (without error).The actual behavior of
IFramework.RunOnFrameworkThreadis unchanged, it will continue to cause issues and confusion from people that are using this call until they switch to usingRunorRunOnTick.This is intended to be implemented as a non-breaking change, but as a improved behavior change. I hope this can be merged sooner than later, as some of these fixes will improve the overall experience for users and devs.
Cancellation Tokens
The way cancellation tokens are handled is a little different now, and performs much more intuitively. Linked CancellationTokens are now used allowing cancellation from both the provided token and FrameworkDestroyed cancellation token at the same time.
Additionally, FrameworkPluginScoped now has a PluginUnloadingCancellation token added, that is cancelled when the scoped service is being unloaded, resulting in logical task cancellation when the plugin is being unloaded in addition to any provided cancellation token and the FrameworkDestroyed cancellation token.
Testing
This was tested using VanillaPlus as a testing platform running async from the game during initial load with the following code:
Testing using
RunOnTickAnd the following log output:
Testing using
RunDemonstrates that all parts of these functions are operating as expected.
Misc: Fixes some IDE suggestions in UiBuilder to use backing fields.