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

Improve assembly resolve behavior #358

Merged
merged 2 commits into from
Aug 15, 2024
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
4 changes: 2 additions & 2 deletions docs/scenarios/js-dotnet-dynamic.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ For examples of this scenario, see one of these directories in the repo:
of bin-placing all dependencies together. If some dependencies are are in another location,
set up a `resolving` event handler _before_ loading the target assembly:
```JavaScript
dotnet.addListener('resolving', (name, version) => {
dotnet.addListener('resolving', (name, version, resolve) => {
const filePath = path.join(__dirname, 'bin', name + '.dll');
if (fs.existsSync(filePath)) dotnet.load(filePath);
if (fs.existsSync(filePath)) resolve(filePath);
});
```

Expand Down
3 changes: 0 additions & 3 deletions examples/semantic-kernel/example.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
// Licensed under the MIT License.

import dotnet from 'node-api-dotnet';
import './bin/System.Text.Encodings.Web.js';
import './bin/Microsoft.Extensions.DependencyInjection.js';
import './bin/Microsoft.Extensions.Logging.Abstractions.js';
import './bin/Microsoft.SemanticKernel.Abstractions.js';
import './bin/Microsoft.SemanticKernel.Core.js';
import './bin/Microsoft.SemanticKernel.Connectors.OpenAI.js';
Expand Down
165 changes: 94 additions & 71 deletions src/NodeApi.DotNetHost/ManagedHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,6 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable
private readonly AssemblyLoadContext _loadContext = new(name: default);
#endif

/// <summary>
/// Path to the assembly currently being loaded, or null when not loading.
/// </summary>
/// <remarks>
/// This is used to automatically load dependency assemblies from the same directory as
/// the initially loaded assembly, if there was no location provided by a resolve handler.
/// Note since a .NET host cannot be shared by multiple JS threads (workers), only one
/// assembly can be loaded at a time.
/// </remarks>
private string? _loadingPath;

private JSValueScope? _rootScope;

/// <summary>
Expand All @@ -68,6 +57,11 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable
/// </summary>
private readonly Dictionary<string, Assembly> _loadedAssembliesByName = new();

/// <summary>
/// Tracks names of assemblies that have been exported to JS.
/// </summary>
private readonly HashSet<string> _exportedAssembliesByName = new();

/// <summary>
/// Mapping from assembly file paths to strong references to module exports.
/// </summary>
Expand Down Expand Up @@ -139,16 +133,11 @@ JSValue removeListener(JSCallbackArgs args)
Environment.GetEnvironmentVariable("NODE_API_DELAYLOAD") != "0"
};

// Export the System.Runtime and System.Console assemblies by default.
_typeExporter.ExportAssemblyTypes(typeof(object).Assembly);
_loadedAssembliesByName.Add(
typeof(object).Assembly.GetName().Name!, typeof(object).Assembly);

// System.Runtime and System.Console assembly types are auto-exported on first use.
_exportedAssembliesByName.Add(typeof(object).Assembly.GetName().Name!);
if (typeof(Console).Assembly != typeof(object).Assembly)
{
_typeExporter.ExportAssemblyTypes(typeof(Console).Assembly);
_loadedAssembliesByName.Add(
typeof(Console).Assembly.GetName().Name!, typeof(Console).Assembly);
_exportedAssembliesByName.Add(typeof(Console).Assembly.GetName().Name!);
}
}

Expand Down Expand Up @@ -276,40 +265,63 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
return typeof(ManagedHost).Assembly;
}

Trace($" Resolving assembly: {assemblyName} {assemblyVersion}");
Emit(ResolvingEventName, assemblyName, assemblyVersion!);
Trace($"> ManagedHost.OnResolvingAssembly({assemblyName}, {assemblyVersion})");

// Resolve listeners may call load(assemblyFilePath) to load the requested assembly.
// The version of the loaded assembly might not match the requested version.
if (_loadedAssembliesByName.TryGetValue(assemblyName, out Assembly? assembly))
// Try to load the named assembly from .NET system directories.
Assembly? assembly;
try
{
Trace($" Resolved at: {assembly.Location}");
return assembly;
assembly = LoadAssembly(assemblyName, allowNativeLibrary: false);
}
catch (FileNotFoundException)
{
// The assembly was not found in the system directories.
// Emit a resolving event to allow listeners to load the assembly.
// Resolve listeners may call load(assemblyFilePath) to load the requested assembly.
Emit(
ResolvingEventName,
assemblyName,
assemblyVersion!,
new JSFunction(ResolveAssembly));
_loadedAssembliesByName.TryGetValue(assemblyName, out assembly);
}

if (!string.IsNullOrEmpty(_loadingPath))
if (assembly == null)
{
// The dependency assembly was not resolved by an event-handler.
// Look for it in the same directory as the initially loaded assembly.
string adjacentPath = Path.Combine(
Path.GetDirectoryName(_loadingPath) ?? string.Empty,
assemblyName + ".dll");
try
{
assembly = LoadAssembly(adjacentPath);
}
catch (FileNotFoundException)
// Look for it in the same directory as any already-loaded assemblies.
foreach (string? loadedAssemblyFile in
_loadedModules.Keys.Concat(_loadedAssembliesByPath.Keys))
{
Trace($" Assembly not found at: {adjacentPath}");
return default;
string assemblyDirectory =
Path.GetDirectoryName(loadedAssemblyFile) ?? string.Empty;
if (!string.IsNullOrEmpty(assemblyDirectory))
{
string adjacentPath = Path.Combine(assemblyDirectory, assemblyName + ".dll");
try
{
assembly = LoadAssembly(adjacentPath, allowNativeLibrary: false);
break;
}
catch (FileNotFoundException)
{
Trace($" ManagedHost.OnResolvingAssembly(" +
$"{assemblyName}) not found at {adjacentPath}");
}
}
}
}

Trace($" Resolved at: {assembly.Location}");
if (assembly != null)
{
Trace($"< ManagedHost.OnResolvingAssembly({assemblyName}) => {assembly.Location}");
return assembly;
}

Trace($" Assembly not resolved: {assemblyName}");
return default;
else
{
Trace($"< ManagedHost.OnResolvingAssembly({assemblyName}) => not resolved");
return default;
}
}

public static JSValue GetRuntimeVersion(JSCallbackArgs _)
Expand Down Expand Up @@ -349,22 +361,13 @@ public JSValue LoadModule(JSCallbackArgs args)
}

Assembly assembly;
string? previousLoadingPath = _loadingPath;
try
{
_loadingPath = assemblyFilePath;

#if NETFRAMEWORK || NETSTANDARD
// TODO: Load module assemblies in separate appdomains.
assembly = Assembly.LoadFrom(assemblyFilePath);
// TODO: Load module assemblies in separate appdomains.
assembly = Assembly.LoadFrom(assemblyFilePath);
#else
assembly = _loadContext.LoadFromAssemblyPath(assemblyFilePath);
assembly = _loadContext.LoadFromAssemblyPath(assemblyFilePath);
#endif
}
finally
{
_loadingPath = previousLoadingPath;
}

MethodInfo? initializeMethod = null;

Expand Down Expand Up @@ -455,16 +458,33 @@ public JSValue LoadAssembly(JSCallbackArgs args)
{
string assemblyNameOrFilePath = (string)args[0];

if (!_loadedAssembliesByPath.ContainsKey(assemblyNameOrFilePath) &&
!_loadedAssembliesByName.ContainsKey(assemblyNameOrFilePath))
if (!_loadedAssembliesByPath.TryGetValue(assemblyNameOrFilePath, out Assembly? assembly) &&
!_loadedAssembliesByName.TryGetValue(assemblyNameOrFilePath, out assembly))
{
LoadAssembly(assemblyNameOrFilePath, allowNativeLibrary: true);
assembly = LoadAssembly(assemblyNameOrFilePath, allowNativeLibrary: true);
}

if (!_exportedAssembliesByName.Contains(assembly.GetName().Name!))
{
_typeExporter.ExportAssemblyTypes(assembly);
_exportedAssembliesByName.Add(assembly.GetName().Name!);
}

return default;
}

private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLibrary = false)
/// <summary>
/// Callback from the 'resolving' event which completes the resolve operation by loading an
/// assembly from a file path specified by the event listener.
/// </summary>
private JSValue ResolveAssembly(JSCallbackArgs args)
{
string assemblyFilePath = (string)args[0];
LoadAssembly(assemblyFilePath, allowNativeLibrary: false);
return default;
}

private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLibrary)
{
Trace($"> ManagedHost.LoadAssembly({assemblyNameOrFilePath})");

Expand All @@ -477,14 +497,21 @@ private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLib
Path.GetDirectoryName(typeof(object).Assembly.Location)!,
assemblyFilePath + ".dll");

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
// Also support loading ASP.NET system assemblies.
string assemblyFilePath2 = assemblyFilePath.Replace(
"Microsoft.NETCore.App", "Microsoft.AspNetCore.App");
if (File.Exists(assemblyFilePath2))
{
assemblyFilePath = assemblyFilePath2;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Also support loading Windows-specific system assemblies.
string assemblyFilePath2 = assemblyFilePath.Replace(
string assemblyFilePath3 = assemblyFilePath.Replace(
"Microsoft.NETCore.App", "Microsoft.WindowsDesktop.App");
if (File.Exists(assemblyFilePath2))
if (File.Exists(assemblyFilePath3))
{
assemblyFilePath = assemblyFilePath2;
assemblyFilePath = assemblyFilePath3;
}
}
}
Expand All @@ -496,19 +523,14 @@ private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLib
}

Assembly assembly;
string? previousLoadingPath = _loadingPath;
try
{
_loadingPath = assemblyFilePath;

#if NETFRAMEWORK || NETSTANDARD
// TODO: Load assemblies in a separate appdomain.
assembly = Assembly.LoadFrom(assemblyFilePath);
#else
assembly = _loadContext.LoadFromAssemblyPath(assemblyFilePath);
#endif

_typeExporter.ExportAssemblyTypes(assembly);
}
catch (BadImageFormatException)
{
Expand All @@ -522,18 +544,19 @@ private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLib
// any later DllImport operations for the same library name.
NativeLibrary.Load(assemblyFilePath);

Trace("< ManagedHost.LoadAssembly() => loaded native library");
Trace($"< ManagedHost.LoadAssembly() => {assemblyFilePath} (native library)");
return null!;
}
finally
catch (FileNotFoundException fnfex)
{
_loadingPath = previousLoadingPath;
throw new FileNotFoundException(
$"Assembly file not found: {assemblyNameOrFilePath}", fnfex);
}

_loadedAssembliesByPath.Add(assemblyFilePath, assembly);
_loadedAssembliesByName.Add(assembly.GetName().Name!, assembly);

Trace("< ManagedHost.LoadAssembly() => newly loaded");
Trace($"< ManagedHost.LoadAssembly() => {assemblyFilePath}, {assembly.GetName().Version}");
return assembly;
}

Expand Down
30 changes: 28 additions & 2 deletions src/NodeApi.DotNetHost/TypeExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ public TypeExporter(JSMarshaller marshaller, JSObject? namespaces = null)
if (namespaces != null)
{
_namespaces = new JSReference(namespaces.Value);

namespaces.Value.DefineProperties(JSPropertyDescriptor.AccessorProperty(
"System",
getter: AutoExportBaseTypes,
attributes: JSPropertyAttributes.Enumerable | JSPropertyAttributes.Configurable));
}
}

Expand All @@ -79,6 +84,23 @@ public TypeExporter(JSMarshaller marshaller, JSObject? namespaces = null)
/// </summary>
public bool IsDelayLoadEnabled { get; set; } = true;

/// <summary>
/// Automatically export base types like `System.Object` and `System.Console` as soon as the
/// 'System' namespace is referenced.
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
private JSValue AutoExportBaseTypes(JSCallbackArgs args)
{
ExportAssemblyTypes(typeof(object).Assembly);
if (typeof(Console).Assembly != typeof(object).Assembly)
{
ExportAssemblyTypes(typeof(Console).Assembly);
}

return _namespaces!.GetValue()["System"];
}

/// <summary>
/// Exports all types from a .NET assembly to JavaScript.
/// </summary>
Expand Down Expand Up @@ -124,9 +146,13 @@ public void ExportAssemblyTypes(Assembly assembly)

if (_namespaces != null)
{
// Add a property on the namespaces JS object.
// Define a property on the namespaces JS object.
JSObject namespacesObject = (JSObject)_namespaces.GetValue();
namespacesObject[namespaceParts[0]] = parentNamespace.Value;
namespacesObject.DefineProperties(
JSPropertyDescriptor.DataProperty(
namespaceParts[0],
parentNamespace.Value,
JSPropertyAttributes.Enumerable));
}
}

Expand Down
14 changes: 11 additions & 3 deletions src/node-api-dotnet/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,20 @@ export function load(assemblyNameOrFilePath: string): void;

/**
* Adds a listener for the `resolving` event, which is raised when a .NET assembly requires
* an additional dependent assembly to be resolved and loaded. The listener must call `load()`
* to load the requested assembly file.
* an additional dependent assembly to be resolved and loaded. The listener may call `resolve()`
* to load the requested assembly from a resolved file path. If the listener does not call
* `resolve()`, the runtime will then attempt to resolve the assembly by searching in the same
* application directory as other already-loaded assemblies, if there were any.
*/
export function addListener(
event: 'resolving',
listener: (assemblyName: string, assemblyVersion: string) => void,
/**
* Resolving event listener funciton to be invokved when a .NET assembly is being resolved.
* @param assemblyName Name of the assembly to be resolved.
* @param assemblyVersion Version of the assembly to be resolved.
* @param resolve Callback to invoke with the full path to the resolved assembly file.
*/
listener: (assemblyName: string, assemblyVersion: string, resolve: (string) => void) => void,
): void;

/**
Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/AArnott/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",

"version": "0.7",
"version": "0.8",

"publicReleaseRefSpec": [
"^refs/heads/main$",
Expand Down
Loading