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

[release/8.0] [Blazor] Close the circuit when all Blazor Server components are disposed #50170

Merged
merged 9 commits into from
Aug 25, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

194 changes: 50 additions & 144 deletions src/Components/Web.JS/src/Boot.Server.Common.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,78 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { DotNet } from '@microsoft/dotnet-js-interop';
import { Blazor } from './GlobalExports';
import { HubConnectionBuilder, HubConnection, HttpTransportType } from '@microsoft/signalr';
import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack';
import { showErrorNotification } from './BootErrors';
import { RenderQueue } from './Platform/Circuits/RenderQueue';
import { ConsoleLogger } from './Platform/Logging/Loggers';
import { LogLevel, Logger } from './Platform/Logging/Logger';
import { CircuitDescriptor } from './Platform/Circuits/CircuitManager';
import { LogLevel } from './Platform/Logging/Logger';
import { CircuitManager } from './Platform/Circuits/CircuitManager';
import { resolveOptions, CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
import { attachRootComponentToLogicalElement } from './Rendering/Renderer';
import { discoverPersistedState, ServerComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
import { sendJSDataStream } from './Platform/Circuits/CircuitStreamingInterop';
import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.Server';
import { WebRendererId } from './Rendering/WebRendererId';
import { RootComponentManager } from './Services/RootComponentManager';

let renderingFailed = false;
let hasStarted = false;
let connection: HubConnection;
let circuit: CircuitDescriptor;
let dispatcher: DotNet.ICallDispatcher;
let userOptions: Partial<CircuitStartOptions> | undefined;
let started = false;
let appState: string;
let circuit: CircuitManager;
let options: CircuitStartOptions;
let logger: ConsoleLogger;

export function setCircuitOptions(circuitUserOptions?: Partial<CircuitStartOptions>) {
if (userOptions) {
if (options) {
throw new Error('Circuit options have already been configured.');
}

userOptions = circuitUserOptions;
options = resolveOptions(circuitUserOptions);
}

export async function startCircuit(components: RootComponentManager<ServerComponentDescriptor>): Promise<void> {
if (hasStarted) {
export async function startServer(components: RootComponentManager<ServerComponentDescriptor>): Promise<void> {
if (started) {
throw new Error('Blazor Server has already started.');
}

hasStarted = true;
started = true;
appState = discoverPersistedState(document) || '';
logger = new ConsoleLogger(options.logLevel);
circuit = new CircuitManager(components, appState, options, logger);

// Establish options to be used
const options = resolveOptions(userOptions);
const jsInitializer = await fetchAndInvokeInitializers(options);

const logger = new ConsoleLogger(options.logLevel);
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');

Blazor.reconnect = async (existingConnection?: HubConnection): Promise<boolean> => {
if (renderingFailed) {
Blazor.reconnect = async () => {
if (circuit.didRenderingFail()) {
// We can't reconnect after a failure, so exit early.
return false;
}

const reconnection = existingConnection || await initializeConnection(options, logger, circuit);
if (!(await circuit.reconnect(reconnection))) {
if (!(await circuit.reconnect())) {
logger.log(LogLevel.Information, 'Reconnection attempt to the circuit was rejected by the server. This may indicate that the associated state is no longer available on the server.');
return false;
}

options.reconnectionHandler!.onConnectionUp();

return true;
};
Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);

Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);
options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler;
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');

const appState = discoverPersistedState(document);
circuit = new CircuitDescriptor(components, appState || '');

// Configure navigation via SignalR
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanged', uri, state, intercepted);
return circuit.sendLocationChanged(uri, state, intercepted);
}, (callId: number, uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanging', callId, uri, state, intercepted);
return circuit.sendLocationChanging(callId, uri, state, intercepted);
});

Blazor._internal.forceCloseConnection = () => connection.stop();
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => sendJSDataStream(connection, data, streamId, chunkSize);

dispatcher = DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson): void => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
},
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
},
sendByteArray: (id: number, data: Uint8Array): void => {
connection.send('ReceiveByteArray', id, data);
},
});
Blazor._internal.forceCloseConnection = () => circuit.disconnect();
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => circuit.sendJsDataStream(data, streamId, chunkSize);

const initialConnection = await initializeConnection(options, logger, circuit);
const circuitStarted = await circuit.startCircuit(initialConnection);
const jsInitializer = await fetchAndInvokeInitializers(options);
const circuitStarted = await circuit.start();
if (!circuitStarted) {
logger.log(LogLevel.Error, 'Failed to start the circuit.');
return;
}

let disconnectSent = false;
const cleanup = () => {
if (!disconnectSent) {
const data = new FormData();
const circuitId = circuit.circuitId!;
data.append('circuitId', circuitId);
disconnectSent = navigator.sendBeacon('_blazor/disconnect', data);
}
circuit.sendDisconnectBeacon();
};

Blazor.disconnect = cleanup;
Expand All @@ -119,94 +84,35 @@ export async function startCircuit(components: RootComponentManager<ServerCompon
jsInitializer.invokeAfterStartedCallbacks(Blazor);
}

async function initializeConnection(options: CircuitStartOptions, logger: Logger, circuit: CircuitDescriptor): Promise<HubConnection> {
const hubProtocol = new MessagePackHubProtocol();
(hubProtocol as unknown as { name: string }).name = 'blazorpack';

const connectionBuilder = new HubConnectionBuilder()
.withUrl('_blazor')
.withHubProtocol(hubProtocol);

options.configureSignalR(connectionBuilder);

const newConnection = connectionBuilder.build();

newConnection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(WebRendererId.Server, circuit.resolveElement(selector, componentId), componentId, false));
newConnection.on('JS.BeginInvokeJS', dispatcher.beginInvokeJSFromDotNet.bind(dispatcher));
newConnection.on('JS.EndInvokeDotNet', dispatcher.endInvokeDotNetFromJS.bind(dispatcher));
newConnection.on('JS.ReceiveByteArray', dispatcher.receiveByteArray.bind(dispatcher));

newConnection.on('JS.BeginTransmitStream', (streamId: number) => {
const readableStream = new ReadableStream({
start(controller) {
newConnection.stream('SendDotNetStreamToJS', streamId).subscribe({
next: (chunk: Uint8Array) => controller.enqueue(chunk),
complete: () => controller.close(),
error: (err) => controller.error(err),
});
},
});

dispatcher.supplyDotNetStream(streamId, readableStream);
});

const renderQueue = RenderQueue.getOrCreate(logger);
newConnection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
logger.log(LogLevel.Debug, `Received render batch with id ${batchId} and ${batchData.byteLength} bytes.`);
renderQueue.processBatch(batchId, batchData, newConnection);
});

newConnection.on('JS.EndLocationChanging', Blazor._internal.navigationManager.endLocationChanging);

newConnection.onclose(error => !renderingFailed && options.reconnectionHandler!.onConnectionDown(options.reconnectionOptions, error));
newConnection.on('JS.Error', error => {
renderingFailed = true;
unhandledError(newConnection, error, logger);
showErrorNotification();
});

try {
await newConnection.start();
connection = newConnection;
} catch (ex: any) {
unhandledError(newConnection, ex as Error, logger);

if (ex.errorType === 'FailedToNegotiateWithServerError') {
// Connection with the server has been interrupted, and we're in the process of reconnecting.
// Throw this exception so it can be handled at the reconnection layer, and don't show the
// error notification.
throw ex;
} else {
showErrorNotification();
}
export function startCircuit(): Promise<boolean> {
if (!started) {
throw new Error('Cannot start the circuit until Blazor Server has started.');
}

if (ex.innerErrors) {
if (ex.innerErrors.some(e => e.errorType === 'UnsupportedTransportError' && e.transport === HttpTransportType.WebSockets)) {
logger.log(LogLevel.Error, 'Unable to connect, please ensure you are using an updated browser that supports WebSockets.');
} else if (ex.innerErrors.some(e => e.errorType === 'FailedToStartTransportError' && e.transport === HttpTransportType.WebSockets)) {
logger.log(LogLevel.Error, 'Unable to connect, please ensure WebSockets are available. A VPN or proxy may be blocking the connection.');
} else if (ex.innerErrors.some(e => e.errorType === 'DisabledTransportError' && e.transport === HttpTransportType.LongPolling)) {
logger.log(LogLevel.Error, 'Unable to initiate a SignalR connection to the server. This might be because the server is not configured to support WebSockets. For additional details, visit https://aka.ms/blazor-server-websockets-error.');
}
}
if (circuit.didRenderingFail()) {
// We can't start a new circuit after a rendering failure because the renderer
// might be in an invalid state.
return Promise.resolve(false);
}

// Check if the connection is established using the long polling transport,
// using the `features.inherentKeepAlive` property only present with long polling.
if ((newConnection as any).connection?.features?.inherentKeepAlive) {
logger.log(LogLevel.Warning, 'Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To troubleshoot this, visit https://aka.ms/blazor-server-using-fallback-long-polling.');
if (circuit.isDisposedOrDisposing()) {
// If the current circuit is no longer available, create a new one.
circuit = new CircuitManager(circuit.getRootComponentManager(), appState, options, logger);
}

return newConnection;
// Start the circuit. If the circuit has already started, this will return the existing
// circuit start promise.
return circuit.start();
}

function unhandledError(connection: HubConnection, err: Error, logger: Logger): void {
logger.log(LogLevel.Error, err);
export function hasStartedServer(): boolean {
return started;
}

// Disconnect on errors.
//
// Trying to call methods on the connection after its been closed will throw.
if (connection) {
connection.stop();
}
export async function disposeCircuit() {
await circuit?.dispose();
}

export function isCircuitAvailable(): boolean {
return !circuit.isDisposedOrDisposing();
}
4 changes: 2 additions & 2 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Blazor } from './GlobalExports';
import { shouldAutoStart } from './BootCommon';
import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { setCircuitOptions, startCircuit } from './Boot.Server.Common';
import { setCircuitOptions, startServer } from './Boot.Server.Common';
import { ServerComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
import { DotNet } from '@microsoft/dotnet-js-interop';
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
Expand All @@ -21,7 +21,7 @@ function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {

const serverComponents = discoverComponents(document, 'server') as ServerComponentDescriptor[];
const components = new InitialRootComponentsList(serverComponents);
return startCircuit(components);
return startServer(components);
}

Blazor.start = boot;
Expand Down
4 changes: 3 additions & 1 deletion src/Components/Web.JS/src/Boot.Web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { attachComponentDescriptorHandler, registerAllComponentDescriptors } fro
import { hasProgrammaticEnhancedNavigationHandler, performProgrammaticEnhancedNavigation } from './Services/NavigationUtils';

let started = false;
const rootComponentManager = new WebRootComponentManager();
let rootComponentManager: WebRootComponentManager;

function boot(options?: Partial<WebStartOptions>) : Promise<void> {
if (started) {
Expand All @@ -43,6 +43,8 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
setCircuitOptions(options?.circuit);
setWebAssemblyOptions(options?.webAssembly);

rootComponentManager = new WebRootComponentManager(options?.ssr?.circuitInactivityTimeoutMs ?? 2000);

attachComponentDescriptorHandler(rootComponentManager);
attachStreamingRenderingListener(options?.ssr, rootComponentManager);

Expand Down
26 changes: 21 additions & 5 deletions src/Components/Web.JS/src/Boot.WebAssembly.Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { RootComponentManager } from './Services/RootComponentManager';

let options: Partial<WebAssemblyStartOptions> | undefined;
let platformLoadPromise: Promise<void> | undefined;
let hasStarted = false;
let loadedWebAssemblyPlatform = false;
let started = false;

let resolveBootConfigPromise: (value: MonoConfig) => void;
const bootConfigPromise = new Promise<MonoConfig>(resolve => {
Expand All @@ -35,11 +36,11 @@ export function setWebAssemblyOptions(webAssemblyOptions?: Partial<WebAssemblySt
}

export async function startWebAssembly(components: RootComponentManager<WebAssemblyComponentDescriptor>): Promise<void> {
if (hasStarted) {
if (started) {
throw new Error('Blazor WebAssembly has already started.');
}

hasStarted = true;
started = true;

if (inAuthRedirectIframe()) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
Expand All @@ -54,7 +55,7 @@ export async function startWebAssembly(components: RootComponentManager<WebAssem
// focus, in turn triggering a 'change' event. It may also be possible to listen to other DOM mutation events
// that are themselves triggered by the application of a renderbatch.
const renderer = getRendererer(browserRendererId);
if (renderer.eventDelegator.getHandler(eventHandlerId)) {
if (renderer?.eventDelegator.getHandler(eventHandlerId)) {
monoPlatform.invokeWhenHeapUnlocked(continuation);
}
});
Expand Down Expand Up @@ -146,15 +147,30 @@ export async function startWebAssembly(components: RootComponentManager<WebAssem
api.invokeLibraryInitializers('afterStarted', [Blazor]);
}

export function hasStartedWebAssembly(): boolean {
return started;
}

export function waitForBootConfigLoaded(): Promise<MonoConfig> {
return bootConfigPromise;
}

export function loadWebAssemblyPlatformIfNotStarted(): Promise<void> {
platformLoadPromise ??= monoPlatform.load(options ?? {}, resolveBootConfigPromise);
platformLoadPromise ??= (async () => {
await monoPlatform.load(options ?? {}, resolveBootConfigPromise);
loadedWebAssemblyPlatform = true;
})();
return platformLoadPromise;
}

export function hasStartedLoadingWebAssemblyPlatform(): boolean {
return platformLoadPromise !== undefined;
}

export function hasLoadedWebAssemblyPlatform(): boolean {
return loadedWebAssemblyPlatform;
}

// obsolete, legacy, don't use for new code!
function invokeJSFromDotNet(callInfo: Pointer, arg0: any, arg1: any, arg2: any): any {
const functionIdentifier = monoPlatform.readStringField(callInfo, 0)!;
Expand Down
Loading