Skip to content

WIP: WASM redesign #49021

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
112 changes: 68 additions & 44 deletions src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,30 @@ setTimeout(async function () {
}
window[scriptInjectedSentinel] = true;

// dotnet-watch browser reload script
const AgentMessageSeverity_Error = 2

var pendingUpdates = [];
var pendingUpdatesLog = [];
var initializedBlazor = undefined;

window["__BlazorWebAssemblyInitializeForHotReload"] = async function (blazor) {
// apply pending updates
try {
pendingUpdates.forEach(update => pendingUpdatesLog.push(blazor.applyHotReloadDeltas(update.deltas, update.responseLoggingLevel)));
initializedBlazor = api;
updatesApplied = true;
} catch (error) {
console.error(error);
pendingUpdatesLog.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error });

// no updates can't be applied to this process:
pendingUpdates = undefined;
return;
}

notifyHotReloadApplied();
};
// dotnet-watch browser reload script
const webSocketUrls = '{{hostString}}'.split(',');
const sharedSecret = await getSecret('{{ServerKey}}');
let connection;
Expand Down Expand Up @@ -44,9 +67,18 @@ setTimeout(async function () {
const payload = JSON.parse(message.data);
const action = {
'UpdateStaticFile': () => updateStaticFile(payload.path),
'BlazorHotReloadDeltav1': () => applyBlazorDeltas_legacy(payload.sharedSecret, payload.deltas, false),
'BlazorHotReloadDeltav2': () => applyBlazorDeltas_legacy(payload.sharedSecret, payload.deltas, true),
'BlazorHotReloadDeltav3': () => applyBlazorDeltas(payload.sharedSecret, payload.updateId, payload.deltas, payload.responseLoggingLevel),
'BlazorHotReloadDeltav1': () => {
validateSecret(payload.sharedSecret);
applyBlazorDeltas_legacy(payload.deltas, false);
},
'BlazorHotReloadDeltav2': () => {
validateSecret(payload.sharedSecret);
applyBlazorDeltas_legacy(payload.deltas, true);
},
'BlazorHotReloadDeltav3': () => {
validateSecret(payload.sharedSecret);
applyBlazorDeltas(payload.deltas, payload.responseLoggingLevel);
},
'HotReloadDiagnosticsv1': () => displayDiagnostics(payload.diagnostics),
'BlazorRequestApplyUpdateCapabilities': () => getBlazorWasmApplyUpdateCapabilities(false),
'BlazorRequestApplyUpdateCapabilities2': () => getBlazorWasmApplyUpdateCapabilities(true),
Expand Down Expand Up @@ -137,13 +169,15 @@ setTimeout(async function () {
styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling);
}

async function applyBlazorDeltas_legacy(serverSecret, deltas, sendErrorToClient) {
function validateSecret(serverSecret) {
if (sharedSecret && (serverSecret != sharedSecret.encodedSharedSecret)) {
// Validate the shared secret if it was specified. It might be unspecified in older versions of VS
// that do not support this feature as yet.
throw 'Unable to validate the server. Rejecting apply-update payload.';
}
}

async function applyBlazorDeltas_legacy(deltas, sendErrorToClient) {
let applyError = undefined;
if (window.Blazor?._internal?.applyHotReload) {
// Only apply hot reload deltas if Blazor has been initialized.
Expand Down Expand Up @@ -174,59 +208,49 @@ setTimeout(async function () {
}
}

async function applyBlazorDeltas(serverSecret, updateId, deltas, responseLoggingLevel) {
if (sharedSecret && (serverSecret != sharedSecret.encodedSharedSecret)) {
// Validate the shared secret if it was specified. It might be unspecified in older versions of VS
// that do not support this feature as yet.
throw 'Unable to validate the server. Rejecting apply-update payload.';
async function applyBlazorDeltas(deltas, responseLoggingLevel) {
let updatesApplied = false
let success = true;
let log = [];

if (pendingUpdatesLog.length > 0) {
log.push(pendingUpdatesLog);
pendingUpdatesLog = [];
}

const AgentMessageSeverity_Error = 2
let wasmDeltas = deltas.map(delta => {
return {
"moduleId": delta.moduleId,
"metadataDelta": delta.metadataDelta,
"ilDelta": delta.ilDelta,
"pdbDelta": delta.pdbDelta,
"updatedTypes": delta.updatedTypes,
};
});

let applyError = undefined;
let log = [];
if (window.Blazor?._internal?.applyHotReloadDeltas) {
// Only apply hot reload deltas if Blazor has been initialized.
// It's possible for Blazor to start after the initial page load, so we don't consider skipping this step
// to be a failure. These deltas will get applied later, when Blazor completes initialization.
if (initializedBlazor !== undefined) {
try {
let wasmDeltas = deltas.map(delta => {
return {
"moduleId": delta.moduleId,
"metadataDelta": delta.metadataDelta,
"ilDelta": delta.ilDelta,
"pdbDelta": delta.pdbDelta,
"updatedTypes": delta.updatedTypes,
};
});

log = window.Blazor._internal.applyHotReloadDeltas(wasmDeltas, responseLoggingLevel);
log.push(initializedBlazor.applyHotReloadDeltas(wasmDeltas, responseLoggingLevel));
updatesApplied = true;
} catch (error) {
console.warn(error);
applyError = error;
log.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error });
success = false;
}
}

try {
let body = JSON.stringify({
"id": updateId,
"deltas": deltas
});

await fetch('/_framework/blazor-hotreload', { method: 'post', headers: { 'content-type': 'application/json' }, body: body });
} catch (error) {
console.warn(error);
applyError = error;
log.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error });
} else if (pendingUpdates !== undefined) {
// Blazor is not initialized, defer delta application until it is:
pendingUpdates.push({ "deltas": wasmDeltas, "responseLoggingLevel": responseLoggingLevel })
} else {
// pending updates failed to apply, we can't apply any more updates:
success = false;
}

connection.send(JSON.stringify({
"success": !applyError,
"success": success,
"log": log
}));

if (!applyError) {
if (updatesApplied) {
notifyHotReloadApplied();
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ await Task.WhenAll(serversToDispose.Select(async server =>
ProcessSpec processSpec,
EnvironmentVariablesBuilder environmentBuilder,
ProjectOptions projectOptions,
CompilationHandler compilationHandler,
CancellationToken cancellationToken)
{
BrowserRefreshServer? server;
Expand All @@ -59,7 +60,7 @@ await Task.WhenAll(serversToDispose.Select(async server =>
hasExistingServer = _servers.TryGetValue(projectNode, out server);
if (!hasExistingServer)
{
server = IsServerSupported(projectNode) ? new BrowserRefreshServer(context.EnvironmentOptions, context.Reporter) : null;
server = IsServerSupported(projectNode) ? new BrowserRefreshServer(compilationHandler, context.EnvironmentOptions, context.Reporter) : null;
_servers.Add(projectNode, server);
}
}
Expand Down
10 changes: 7 additions & 3 deletions src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable
private static readonly ReadOnlyMemory<byte> s_waitMessage = Encoding.UTF8.GetBytes("Wait");
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web);

public readonly EnvironmentOptions Options;

private readonly List<BrowserConnection> _activeConnections = [];
private readonly RSA _rsa;
private readonly CompilationHandler _compilationHandler;
private readonly IReporter _reporter;
private readonly TaskCompletionSource _terminateWebSocket;
private readonly TaskCompletionSource _browserConnected;
Expand All @@ -39,11 +42,10 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable
private IHost? _refreshServer;
private string? _serverUrls;

public readonly EnvironmentOptions Options;

public BrowserRefreshServer(EnvironmentOptions options, IReporter reporter)
public BrowserRefreshServer(CompilationHandler compilationHandler, EnvironmentOptions options, IReporter reporter)
{
_rsa = RSA.Create(2048);
_compilationHandler = compilationHandler;
Options = options;
_reporter = reporter;
_terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down Expand Up @@ -175,6 +177,8 @@ private async Task WebSocketRequestAsync(HttpContext context)
_activeConnections.Add(connection);
}

// TODO: send previous updates

_browserConnected.TrySetResult();
await _terminateWebSocket.Task;
}
Expand Down
132 changes: 31 additions & 101 deletions src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@

using System.Buffers;
using System.Collections.Immutable;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api;

namespace Microsoft.DotNet.Watch
{
internal sealed class BlazorWebAssemblyDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, Version? targetFrameworkVersion) : SingleProcessDeltaApplier(reporter)
internal sealed class BlazorWebAssemblyDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, ProjectGraphNode project) : SingleProcessDeltaApplier(reporter)
{
private const string DefaultCapabilities60 = "Baseline";
private const string DefaultCapabilities70 = "Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes";
private const string DefaultCapabilities80 = "Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes AddInstanceFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod UpdateParameters GenericAddFieldToExistingType";
private static readonly ImmutableArray<string> s_defaultCapabilities60 =
["Baseline"];

private static readonly ImmutableArray<string> s_defaultCapabilities70 =
["Baseline", "AddMethodToExistingType", "AddStaticFieldToExistingType", "NewTypeDefinition", "ChangeCustomAttributes"];

private static readonly ImmutableArray<string> s_defaultCapabilities80 =
["Baseline", "AddMethodToExistingType", "AddStaticFieldToExistingType", "NewTypeDefinition", "ChangeCustomAttributes",
"AddInstanceFieldToExistingType", "GenericAddMethodToExistingType", "GenericUpdateMethod", "UpdateParameters", "GenericAddFieldToExistingType"];

private static readonly ImmutableArray<string> s_defaultCapabilities90 =
s_defaultCapabilities80;

private ImmutableArray<string> _cachedCapabilities;
private readonly SemaphoreSlim _capabilityRetrievalSemaphore = new(initialCount: 1);
private int _updateId;

public override void Dispose()
Expand All @@ -31,109 +39,31 @@ public override async Task WaitForProcessRunningAsync(CancellationToken cancella
// Alternatively, we could inject agent into blazor-devserver.dll and establish a connection on the named pipe.
=> await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken);

public override async Task<ImmutableArray<string>> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken)
public override Task<ImmutableArray<string>> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken)
{
var cachedCapabilities = _cachedCapabilities;
if (!cachedCapabilities.IsDefault)
{
return cachedCapabilities;
}
var capabilities = project.GetWebAssemblyCapabilities();

await _capabilityRetrievalSemaphore.WaitAsync(cancellationToken);
try
{
if (_cachedCapabilities.IsDefault)
{
_cachedCapabilities = await RetrieveAsync(cancellationToken);
}
}
finally
if (capabilities.IsEmpty)
{
_capabilityRetrievalSemaphore.Release();
}
var targetFramework = project.GetTargetFrameworkVersion();

return _cachedCapabilities;

async Task<ImmutableArray<string>> RetrieveAsync(CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(32 * 1024);
Reporter.Verbose($"Using capabilities based on target framework: '{targetFramework}'.");

try
capabilities = targetFramework?.Major switch
{
Reporter.Verbose("Connecting to the browser.");

await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken);

string capabilities;
if (browserRefreshServer.Options.TestFlags.HasFlag(TestFlags.MockBrowser))
{
// When testing return default capabilities without connecting to an actual browser.
capabilities = GetDefaultCapabilities(targetFrameworkVersion);
}
else
{
string? capabilityString = null;

await browserRefreshServer.SendAndReceiveAsync(
request: _ => default(JsonGetApplyUpdateCapabilitiesRequest),
response: (value, reporter) =>
{
var str = Encoding.UTF8.GetString(value);
if (str.StartsWith('!'))
{
reporter.Verbose($"Exception while reading WASM runtime capabilities: {str[1..]}");
}
else if (str.Length == 0)
{
reporter.Verbose($"Unable to read WASM runtime capabilities");
}
else if (capabilityString == null)
{
capabilityString = str;
}
else if (capabilityString != str)
{
reporter.Verbose($"Received different capabilities from different browsers:{Environment.NewLine}'{str}'{Environment.NewLine}'{capabilityString}'");
}
},
cancellationToken);

if (capabilityString != null)
{
capabilities = capabilityString;
}
else
{
capabilities = GetDefaultCapabilities(targetFrameworkVersion);
Reporter.Verbose($"Falling back to default WASM capabilities: '{capabilities}'");
}
}

// Capabilities are expressed a space-separated string.
// e.g. https://github.com/dotnet/runtime/blob/14343bdc281102bf6fffa1ecdd920221d46761bc/src/coreclr/System.Private.CoreLib/src/System/Reflection/Metadata/AssemblyExtensions.cs#L87
return capabilities.Split(' ').ToImmutableArray();
}
catch (Exception e) when (!cancellationToken.IsCancellationRequested)
{
Reporter.Error($"Failed to read capabilities: {e.Message}");

// Do not attempt to retrieve capabilities again if it fails once, unless the operation is canceled.
return [];
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
9 => s_defaultCapabilities90,
8 => s_defaultCapabilities80,
7 => s_defaultCapabilities70,
6 => s_defaultCapabilities60,
_ => [],
};
}
else
{
Reporter.Verbose($"Project specifies capabilities.");
}

static string GetDefaultCapabilities(Version? targetFrameworkVersion)
=> targetFrameworkVersion?.Major switch
{
>= 8 => DefaultCapabilities80,
>= 7 => DefaultCapabilities70,
>= 6 => DefaultCapabilities60,
_ => string.Empty,
};
return Task.FromResult(capabilities);
}

public override async Task<ApplyStatus> Apply(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@


using System.Collections.Immutable;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api;

namespace Microsoft.DotNet.Watch
{
internal sealed class BlazorWebAssemblyHostedDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, Version? targetFrameworkVersion) : DeltaApplier(reporter)
internal sealed class BlazorWebAssemblyHostedDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, ProjectGraphNode project) : DeltaApplier(reporter)
{
private readonly BlazorWebAssemblyDeltaApplier _wasmApplier = new(reporter, browserRefreshServer, targetFrameworkVersion);
private readonly BlazorWebAssemblyDeltaApplier _wasmApplier = new(reporter, browserRefreshServer, project);
private readonly DefaultDeltaApplier _hostApplier = new(reporter);

public override void Dispose()
Expand Down
Loading