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

[Blazor] Async disposable support for Blazor #23813

Merged
merged 9 commits into from Jul 16, 2020

Conversation

javiercn
Copy link
Member

@javiercn javiercn commented Jul 9, 2020

Currently we only support disposing components synchronously. For disposable components there are several design decissions that we've made along the way:

  • Within a render batch we process the disposed child components for a given component as a bundle, and produce a list of errors each component we process.
  • We don't however, stop the render batch from running. Instead we simply notify the error and let the renderer deal with it within its handle error implementation.
    • RemoteRenderer will terminate the circuit.
    • WebAssemblyRenderer will log the error to the console.
  • A component being disposed can trigger a render somewhere else due to some notification during disposal, for example.

All these behaviors happen synchronously, so that means that a render batch only finishes rendering after all the components have been disposed and there are no more renders to process.

Supporting async disposal brings in several questions:

  • Do we wait for disposed child components within a given component render to finish disposing before continuing the work?
  • Do we wait to send UI updates until all components have successfully been disposed asynchronously?
  • Do we trigger new render batches after each component has completed disposing or do we wait until all of the components disposed in a previous batch have finished disposing?

Overview of the design

  • We want disposal to behave in a similar way as event handlers and lifecycle methods.
  • If as part of disposing an async disposable component a render is triggered within the syncrhonous piece, that render belongs in the same render batch being currently processed.
  • Otherwise, it belongs in a separate render batch.
  • Synchronous exceptions within async disposables are reported right away.
  • Asynchronous exceptions are reported once the task completes.
  • We report all the asynchronous disposal errors for a single render batch at the same time.
    • This simplifies processing, but we can also group them by component instead.
    • That said, synchronous errors will be reported before asynchronous errors are so as to not wait for them to complete.

@javiercn javiercn requested review from SteveSandersonMS and a team as code owners July 9, 2020 16:05
@ghost ghost added the area-blazor Includes: Blazor, Razor Components label Jul 9, 2020
@pranavkm
Copy link
Contributor

pranavkm commented Jul 9, 2020

Thanks @javiercn for the description. The design seems consistent with the rest of rendering. Steve would probably know best if this bit is problematic:

If as part of disposing an async disposable component a render is triggered within the syncrhonous piece, that render belongs in the same render batch being currently processed.

But the rest of it looks great.

Copy link
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

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

Unit tests?

src/Components/Components/src/RenderTree/Renderer.cs Outdated Show resolved Hide resolved
src/Components/Components/src/RenderTree/Renderer.cs Outdated Show resolved Hide resolved
src/Components/Components/src/RenderTree/Renderer.cs Outdated Show resolved Hide resolved
src/Components/Components/src/RenderTree/Renderer.cs Outdated Show resolved Hide resolved
@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Jul 10, 2020

I'm still going through this PR, but here are initial reactions to the questions. My overall hope is that async disposal behaves like a set of invisible background jobs that nothing is waiting for (except prerendering). If there's an exception we report it, but otherwise async disposal doesn't influence anything else.

Do we wait for disposed child components within a given component render to finish disposing before continuing the work?

No, the inner loop of rendering is all about keeping things synchronous. I don't think anything benefits from knowing when async disposal is finished, other than prerendering which already has a system for tracking incomplete async tasks.

Do we wait to send UI updates until all components have successfully been disposed asynchronously?

Yes. There's nothing to be gained by waiting for disposal (AFAIK).

Do we trigger new render batches after each component has completed disposing or do we wait until all of the components disposed in a previous batch have finished disposing?

Neither. I don't think that the completion of disposal should trigger any rendering. We know that the component that was disposed is no longer in the UI, so what would we render?

Of course I might be missing something important. Please let me know if you think I am!

@SteveSandersonMS
Copy link
Member

Thanks for starting the discussion about the design goals here! It's helpful to see a sketch implementation without getting too bogged down in tests.

I notice that quite a bit of the complexity here (and I'm not saying it's crazy complex) comes from tracking the disposals in groups so we can report their aggregate results. Let's consider whether we could drop some of that and make things simpler for ourselves, which might also make things easier to understand for customers.

Specifically, what if we treat each async disposal as a completely independent background task? If any of them fail individually then we report it (immediately, not waiting for the rest of its friends), but otherwise nothing else happens. Then we don't have to deal with any of the aggregate reporting.

I think this might be easier for customers to understand because if you have a lot of async disposal, some members of the group might take ages to complete, or might not even ever complete. Then if you're waiting for the whole group, you'd never see the failure statuses of the ones that did fail sooner.

It does make sense for prerendering (only) to be waiting for all its associated async disposals to complete before we issue the response, because we do want to fail the response if any of the async disposals fail. However we could do that using the existing _render.AddToPendingTasks facility that @javiercn implemented long ago.

* Handles async disposal of components within the Blazor pipeline.
* Renders remain synchronous and don't wait for disposal to complete.
* Synchronous disposal executions remain inlined.
* Async disposal executions can trigger renders in different batches.
@javiercn javiercn force-pushed the javiercn/blazor-async-disposable branch from 4d268cf to 790764c Compare July 15, 2020 15:55
@javiercn
Copy link
Member Author

🆙📅

@javiercn
Copy link
Member Author

This is good to go, I think

@@ -222,5 +227,31 @@ private void DisposeBuffers()
((IDisposable)CurrentRenderTree).Dispose();
_latestDirectParametersSnapshot?.Dispose();
}

internal Task DisposeInBatchAsync(RenderBatchBuilder batchBuilder)
Copy link
Member

Choose a reason for hiding this comment

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

ComponentState is already internal

Suggested change
internal Task DisposeInBatchAsync(RenderBatchBuilder batchBuilder)
public Task DisposeInBatchAsync(RenderBatchBuilder batchBuilder)

}
else
{
return Awaited(result);
Copy link
Member

Choose a reason for hiding this comment

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

Is this any different from result.AsTask()? There's probably some subtlety I'm missing. If so, would you consider adding a comment to clarify what we're doing the await for?

Copy link
Member

Choose a reason for hiding this comment

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

Can the entire try/catch be replaced with return ((IAsyncDisposable)Component).DisposeAsync().AsTask();? I don't know what it is adding.

Copy link
Member Author

Choose a reason for hiding this comment

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

AsTask probably works, I'm just too used to task and I didn't think of just calling AsTask, I would check though to ensure it does the right thing.

As for the try..catch, we want to avoid the caller having to deal with capturing exceptions, this is a result of us not using async to avoid the state machine. We follow this pattern in several places.

Copy link
Member Author

Choose a reason for hiding this comment

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

From what I can see, https://github.com/dotnet/runtime/blob/bf2e135c12cbd34aeba2fa4a31d0e84184041a17/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/ValueTask.cs#L191-L228 AsTask will do just this, so I think I'll replace the entire thing with that.

@JamesNK thanks for the suggestion!

Copy link
Member Author

Choose a reason for hiding this comment

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

Nevermind, I still need the try..catch for when the thing throws synchronously

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually, AsTask doesn't work here because when the exception is thrown synchronously the result is never set, which leads to an incorrect result.

Copy link
Member Author

Choose a reason for hiding this comment

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

It can be used in the async code path, so I've removed the Awaited bit

Copy link
Member

Choose a reason for hiding this comment

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

LGTM!

Copy link
Member

@SteveSandersonMS SteveSandersonMS left a comment

Choose a reason for hiding this comment

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

Superb - great to see all these test cases!

I did have one minor suggestion (internal -> public in one place) and one question that might warrant adding a comment if possible.

{
_componentWasDisposed = true;

CleanupComponentStateResources(batchBuilder);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the order of calling CleanupComponentStateResources(batchBuilder); before DisposeAsync important? Because in TryDisposeInBatch it is called after Dispose.

Copy link
Member Author

Choose a reason for hiding this comment

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

It is not important, but it makes a bit more sense in the async case to do it before instead of after since dispose async can take longer and you don't want to be holding on to resources you aren't going to need. I was conservative and didn't change the behavior on TryDisposeInBatch just in case. Mostly because I don't think it matters in that case.

@javiercn javiercn merged commit 763ccb2 into master Jul 16, 2020
@javiercn javiercn deleted the javiercn/blazor-async-disposable branch July 16, 2020 11:21
@zmadkour
Copy link

zmadkour commented Sep 6, 2020

Can you tell me, in which .net core version should I expect this PR? :D

@campersau
Copy link
Contributor

@ZiadMadkour in RC1 see https://devblogs.microsoft.com/aspnet/asp-net-core-updates-in-net-5-preview-8/comment-page-2/#comment-2575

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants