Skip to content

Commit

Permalink
Improve assembly resolve behavior (#358)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasongin authored Aug 15, 2024
1 parent 6ffa6a2 commit a1ef423
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 82 deletions.
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

0 comments on commit a1ef423

Please sign in to comment.