Skip to content
This repository was archived by the owner on Jun 5, 2023. It is now read-only.

Commit 2b0c3db

Browse files
committed
Fix for #88
1 parent 128771d commit 2b0c3db

File tree

5 files changed

+223
-11
lines changed

5 files changed

+223
-11
lines changed

src/WebWindow.Blazor.JS/WebWindow.Blazor.JS.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
<WebpackInputs Include="**\*.ts" Exclude="node_modules\**" />
1616
</ItemGroup>
1717

18+
<ItemGroup>
19+
<WebpackInputs Remove="src\RenderQueue.ts" />
20+
</ItemGroup>
21+
1822
<ItemGroup>
1923
<Folder Include="dist\" />
2024
</ItemGroup>

src/WebWindow.Blazor.JS/src/Boot.Desktop.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import '@dotnet/jsinterop/dist/Microsoft.JSInterop';
22
import '@browserjs/GlobalExports';
3-
import { OutOfProcessRenderBatch } from '@browserjs/Rendering/RenderBatch/OutOfProcessRenderBatch';
43
import { setEventDispatcher } from '@browserjs/Rendering/RendererEventDispatcher';
54
import { internalFunctions as navigationManagerFunctions } from '@browserjs/Services/NavigationManager';
6-
import { renderBatch } from '@browserjs/Rendering/Renderer';
75
import { decode } from 'base64-arraybuffer';
86
import * as ipc from './IPC';
7+
import { RenderQueue } from './RenderQueue';
98

109
function boot() {
1110
setEventDispatcher((eventDescriptor, eventArgs) => DotNet.invokeMethodAsync('WebWindow.Blazor', 'DispatchEvent', eventDescriptor, JSON.stringify(eventArgs)));
1211
navigationManagerFunctions.listenForNavigationEvents((uri: string, intercepted: boolean) => {
1312
return DotNet.invokeMethodAsync('WebWindow.Blazor', 'NotifyLocationChanged', uri, intercepted);
1413
});
14+
const renderQueue = RenderQueue.getOrCreate();
1515

1616
// Configure the mechanism for JS<->NET calls
1717
DotNet.attachDispatcher({
@@ -33,9 +33,9 @@ function boot() {
3333
DotNet.jsCallDispatcher.endInvokeDotNetFromJS(callId, success, resultOrError);
3434
});
3535

36-
ipc.on('JS.RenderBatch', (rendererId, batchBase64) => {
37-
var batchData = new Uint8Array(decode(batchBase64));
38-
renderBatch(rendererId, new OutOfProcessRenderBatch(batchData));
36+
ipc.on('JS.RenderBatch', (batchId, batchBase64) => {
37+
var batchData = new Uint8Array(decode(batchBase64));
38+
renderQueue.processBatch(batchId, batchData);
3939
});
4040

4141
ipc.on('JS.Error', (message) => {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { renderBatch } from '@browserjs/Rendering/Renderer';
2+
import { OutOfProcessRenderBatch } from '@browserjs/Rendering/RenderBatch/OutOfProcessRenderBatch';
3+
import * as ipc from './IPC';
4+
5+
export class RenderQueue {
6+
private static instance: RenderQueue;
7+
8+
private nextBatchId = 2;
9+
10+
private fatalError?: string;
11+
12+
public browserRendererId: number;
13+
14+
public constructor(browserRendererId: number) {
15+
this.browserRendererId = browserRendererId;
16+
}
17+
18+
public static getOrCreate(): RenderQueue {
19+
if (!RenderQueue.instance) {
20+
RenderQueue.instance = new RenderQueue(0);
21+
}
22+
23+
return this.instance;
24+
}
25+
26+
public async processBatch(receivedBatchId: number, batchData: Uint8Array): Promise<void> {
27+
if (receivedBatchId < this.nextBatchId) {
28+
await this.completeBatch(receivedBatchId);
29+
return;
30+
}
31+
32+
if (receivedBatchId > this.nextBatchId) {
33+
if (this.fatalError) {
34+
console.log(`Received a new batch ${receivedBatchId} but errored out on a previous batch ${this.nextBatchId - 1}`);
35+
await ipc.send('OnRenderCompleted', [this.nextBatchId - 1, this.fatalError.toString()]);
36+
return;
37+
}
38+
return;
39+
}
40+
41+
try {
42+
this.nextBatchId++;
43+
renderBatch(this.browserRendererId, new OutOfProcessRenderBatch(batchData));
44+
await this.completeBatch(receivedBatchId);
45+
} catch (error) {
46+
this.fatalError = error.toString();
47+
console.error(`There was an error applying batch ${receivedBatchId}.`);
48+
49+
// If there's a rendering exception, notify server *and* throw on client
50+
ipc.send('OnRenderCompleted', [receivedBatchId, error.toString()]);
51+
throw error;
52+
}
53+
}
54+
55+
public getLastBatchid(): number {
56+
return this.nextBatchId - 1;
57+
}
58+
59+
private async completeBatch(batchId: number): Promise<void> {
60+
try {
61+
await ipc.send('OnRenderCompleted', [batchId, null]);
62+
} catch {
63+
console.warn(`Failed to deliver completion notification for render '${batchId}'.`);
64+
}
65+
}
66+
}

src/WebWindow.Blazor/DesktopRenderer.cs

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
using Microsoft.Extensions.Logging;
66
using Microsoft.JSInterop;
77
using System;
8+
using System.Collections.Concurrent;
89
using System.IO;
910
using System.Reflection;
11+
using System.Threading;
1012
using System.Threading.Tasks;
1113

1214
namespace WebWindows.Blazor
@@ -17,11 +19,16 @@ namespace WebWindows.Blazor
1719

1820
internal class DesktopRenderer : Renderer
1921
{
22+
private static readonly Task CanceledTask = Task.FromCanceled(new CancellationToken(canceled: true));
23+
2024
private const int RendererId = 0; // Not relevant, since we have only one renderer in Desktop
2125
private readonly IPC _ipc;
2226
private readonly IJSRuntime _jsRuntime;
2327
private static readonly Type _writer;
2428
private static readonly MethodInfo _writeMethod;
29+
internal readonly ConcurrentQueue<UnacknowledgedRenderBatch> _unacknowledgedRenderBatches = new ConcurrentQueue<UnacknowledgedRenderBatch>();
30+
private bool _disposing = false;
31+
private long _nextRenderId = 1;
2532

2633
public override Dispatcher Dispatcher { get; } = NullDispatcher.Instance;
2734

@@ -78,6 +85,12 @@ public Task AddComponentAsync(Type componentType, string domElementSelector)
7885
/// <inheritdoc />
7986
protected override Task UpdateDisplayAsync(in RenderBatch batch)
8087
{
88+
if (_disposing)
89+
{
90+
// We are being disposed, so do no work.
91+
return CanceledTask;
92+
}
93+
8194
string base64;
8295
using (var memoryStream = new MemoryStream())
8396
{
@@ -91,13 +104,111 @@ protected override Task UpdateDisplayAsync(in RenderBatch batch)
91104
base64 = Convert.ToBase64String(batchBytes);
92105
}
93106

94-
_ipc.Send("JS.RenderBatch", RendererId, base64);
107+
var renderId = Interlocked.Increment(ref _nextRenderId);
108+
109+
var pendingRender = new UnacknowledgedRenderBatch(
110+
renderId,
111+
new TaskCompletionSource<object>());
112+
113+
// Buffer the rendered batches no matter what. We'll send it down immediately when the client
114+
// is connected or right after the client reconnects.
115+
116+
_unacknowledgedRenderBatches.Enqueue(pendingRender);
95117

96-
// TODO: Consider finding a way to get back a completion message from the Desktop side
97-
// in case there was an error. We don't really need to wait for anything to happen, since
98-
// this is not prerendering and we don't care how quickly the UI is updated, but it would
99-
// be desirable to flow back errors.
100-
return Task.CompletedTask;
118+
_ipc.Send("JS.RenderBatch", renderId, base64);
119+
120+
return pendingRender.CompletionSource.Task;
121+
}
122+
123+
public Task OnRenderCompletedAsync(long incomingBatchId, string errorMessageOrNull)
124+
{
125+
if (_disposing)
126+
{
127+
// Disposing so don't do work.
128+
return Task.CompletedTask;
129+
}
130+
131+
// When clients send acks we know for sure they received and applied the batch.
132+
// We send batches right away, and hold them in memory until we receive an ACK.
133+
// If one or more client ACKs get lost (e.g., with long polling, client->server delivery is not guaranteed)
134+
// we might receive an ack for a higher batch.
135+
// We confirm all previous batches at that point (because receiving an ack is guarantee
136+
// from the client that it has received and successfully applied all batches up to that point).
137+
138+
// If receive an ack for a previously acknowledged batch, its an error, as the messages are
139+
// guaranteed to be delivered in order, so a message for a render batch of 2 will never arrive
140+
// after a message for a render batch for 3.
141+
// If that were to be the case, it would just be enough to relax the checks here and simply skip
142+
// the message.
143+
144+
// A batch might get lost when we send it to the client, because the client might disconnect before receiving and processing it.
145+
// In this case, once it reconnects the server will re-send any unacknowledged batches, some of which the
146+
// client might have received and even believe it did send back an acknowledgement for. The client handles
147+
// those by re-acknowledging.
148+
149+
// Even though we're not on the renderer sync context here, it's safe to assume ordered execution of the following
150+
// line (i.e., matching the order in which we received batch completion messages) based on the fact that SignalR
151+
// synchronizes calls to hub methods. That is, it won't issue more than one call to this method from the same hub
152+
// at the same time on different threads.
153+
154+
if (!_unacknowledgedRenderBatches.TryPeek(out var nextUnacknowledgedBatch) || incomingBatchId < nextUnacknowledgedBatch.BatchId)
155+
{
156+
// TODO: Log duplicated batch ack.
157+
return Task.CompletedTask;
158+
}
159+
else
160+
{
161+
var lastBatchId = nextUnacknowledgedBatch.BatchId;
162+
// Order is important here so that we don't prematurely dequeue the last nextUnacknowledgedBatch
163+
while (_unacknowledgedRenderBatches.TryPeek(out nextUnacknowledgedBatch) && nextUnacknowledgedBatch.BatchId <= incomingBatchId)
164+
{
165+
lastBatchId = nextUnacknowledgedBatch.BatchId;
166+
// At this point the queue is definitely not full, we have at least emptied one slot, so we allow a further
167+
// full queue log entry the next time it fills up.
168+
_unacknowledgedRenderBatches.TryDequeue(out _);
169+
ProcessPendingBatch(errorMessageOrNull, nextUnacknowledgedBatch);
170+
}
171+
172+
if (lastBatchId < incomingBatchId)
173+
{
174+
// This exception is due to a bad client input, so we mark it as such to prevent logging it as a warning and
175+
// flooding the logs with warnings.
176+
throw new InvalidOperationException($"Received an acknowledgement for batch with id '{incomingBatchId}' when the last batch produced was '{lastBatchId}'.");
177+
}
178+
179+
// Normally we will not have pending renders, but it might happen that we reached the limit of
180+
// available buffered renders and new renders got queued.
181+
// Invoke ProcessBufferedRenderRequests so that we might produce any additional batch that is
182+
// missing.
183+
184+
// We return the task in here, but the caller doesn't await it.
185+
return Dispatcher.InvokeAsync(() =>
186+
{
187+
// Now we're on the sync context, check again whether we got disposed since this
188+
// work item was queued. If so there's nothing to do.
189+
if (!_disposing)
190+
{
191+
ProcessPendingRender();
192+
}
193+
});
194+
}
195+
}
196+
197+
private void ProcessPendingBatch(string errorMessageOrNull, UnacknowledgedRenderBatch entry)
198+
{
199+
CompleteRender(entry.CompletionSource, errorMessageOrNull);
200+
}
201+
202+
private void CompleteRender(TaskCompletionSource<object> pendingRenderInfo, string errorMessageOrNull)
203+
{
204+
if (errorMessageOrNull == null)
205+
{
206+
pendingRenderInfo.TrySetResult(null);
207+
}
208+
else
209+
{
210+
pendingRenderInfo.TrySetException(new InvalidOperationException(errorMessageOrNull));
211+
}
101212
}
102213

103214
private async void CaptureAsyncExceptions(ValueTask<object> task)
@@ -116,5 +227,29 @@ protected override void HandleException(Exception exception)
116227
{
117228
Console.WriteLine(exception.ToString());
118229
}
230+
231+
/// <inheritdoc />
232+
protected override void Dispose(bool disposing)
233+
{
234+
_disposing = true;
235+
while (_unacknowledgedRenderBatches.TryDequeue(out var entry))
236+
{
237+
entry.CompletionSource.TrySetCanceled();
238+
}
239+
base.Dispose(true);
240+
}
241+
242+
internal readonly struct UnacknowledgedRenderBatch
243+
{
244+
public UnacknowledgedRenderBatch(long batchId, TaskCompletionSource<object> completionSource)
245+
{
246+
BatchId = batchId;
247+
CompletionSource = completionSource;
248+
}
249+
250+
public long BatchId { get; }
251+
252+
public TaskCompletionSource<object> CompletionSource { get; }
253+
}
119254
}
120255
}

src/WebWindow.Blazor/JSInteropMethods.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,12 @@ public static void NotifyLocationChanged(string uri, bool isInterceptedLink)
2626
{
2727
DesktopNavigationManager.Instance.SetLocation(uri, isInterceptedLink);
2828
}
29+
30+
[JSInvokable(nameof(OnRenderCompleted))]
31+
public static async Task OnRenderCompleted(long renderId, string errorMessageOrNull)
32+
{
33+
var renderer = ComponentsDesktop.DesktopRenderer;
34+
await renderer.OnRenderCompletedAsync(renderId, errorMessageOrNull);
35+
}
2936
}
3037
}

0 commit comments

Comments
 (0)