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

Fire diagnostic source events from IHostBuilder.Build #53757

Merged
merged 17 commits into from
Jun 8, 2021
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

#nullable enable

Expand All @@ -16,6 +22,9 @@ internal sealed class HostFactoryResolver
public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder);
public const string CreateHostBuilder = nameof(CreateHostBuilder);

// The amount of time we wait for the diagnostic source events to fire
private static readonly TimeSpan s_defaultWaitTimeout = TimeSpan.FromSeconds(5);

public static Func<string[], TWebHost>? ResolveWebHostFactory<TWebHost>(Assembly assembly)
{
return ResolveFactory<TWebHost>(assembly, BuildWebHost);
Expand All @@ -31,6 +40,35 @@ internal sealed class HostFactoryResolver
return ResolveFactory<THostBuilder>(assembly, CreateHostBuilder);
}

public static Func<string[], object>? ResolveHostFactory(Assembly assembly, TimeSpan? waitTimeout = null, bool stopApplication = true, Action<object>? configureHostBuilder = null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a test that uses configureHostBuilder. Do we need this parameter?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, then it would be great if we had a test ensuring it doesn't break.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 things:

  1. As of right now, ASP.NET Core will need to use it so it kinda has to work or tests will fail regardless 😄.
  2. I agree with you and will add a test.

{
if (assembly.EntryPoint is null)
{
return null;
}

try
{
// Attempt to load hosting and check the version to make sure the events
// even have a change of firing (they were adding in .NET >= 6)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
// even have a change of firing (they were adding in .NET >= 6)
// even have a chance of firing (they were added in .NET >= 6)

var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting");
if (hostingAssembly.GetName().Version is Version version && version.Major < 6)
{
return null;
}

// We're using a version >= 6 so the events can fire. If they don't fire
// then it's because the application isn't using the hosting APIs
}
catch
{
// There was an error loading the extensions assembly, return null.
return null;
}

return args => new HostingListener(args, assembly.EntryPoint, waitTimeout ?? s_defaultWaitTimeout, stopApplication, configureHostBuilder).CreateHost();
}

private static Func<string[], T>? ResolveFactory<T>(Assembly assembly, string name)
{
var programType = assembly?.EntryPoint?.DeclaringType;
Expand Down Expand Up @@ -58,7 +96,7 @@ private static bool IsFactory<TReturn>(MethodInfo? factory)
}

// Used by EF tooling without any Hosting references. Looses some return type safety checks.
public static Func<string[], IServiceProvider?>? ResolveServiceProviderFactory(Assembly assembly)
public static Func<string[], IServiceProvider?>? ResolveServiceProviderFactory(Assembly assembly, TimeSpan? waitTimeout = null)
{
// Prefer the older patterns by default for back compat.
var webHostFactory = ResolveWebHostFactory<object>(assembly);
Expand Down Expand Up @@ -93,6 +131,16 @@ private static bool IsFactory<TReturn>(MethodInfo? factory)
};
}

var hostFactory = ResolveHostFactory(assembly, waitTimeout: waitTimeout);
if (hostFactory != null)
{
return args =>
{
var host = hostFactory(args);
return GetServiceProvider(host);
};
}

return null;
}

Expand All @@ -112,5 +160,133 @@ private static bool IsFactory<TReturn>(MethodInfo? factory)
var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup);
return (IServiceProvider?)servicesProperty?.GetValue(host);
}

private class HostingListener : IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object?>>
{
private readonly string[] _args;
private readonly MethodInfo _entryPoint;
private readonly TimeSpan _waitTimeout;
private readonly bool _stopApplication;

private readonly TaskCompletionSource<object> _hostTcs = new();
private IDisposable? _disposable;
private Action<object>? _configure;

public HostingListener(string[] args, MethodInfo entryPoint, TimeSpan waitTimeout, bool stopApplication, Action<object>? configure)
{
_args = args;
_entryPoint = entryPoint;
_waitTimeout = waitTimeout;
_stopApplication = stopApplication;
_configure = configure;
}

public object CreateHost()
{
using var subscription = DiagnosticListener.AllListeners.Subscribe(this);

// Kick off the entry point on a new thread so we don't block the current one
// in case we need to timeout the execution
var thread = new Thread(() =>
{
try
{
var parameters = _entryPoint.GetParameters();
if (parameters.Length == 0)
{
_entryPoint.Invoke(null, Array.Empty<object>());
}
else
{
_entryPoint.Invoke(null, new object[] { _args });
}

// Try to set an exception if the entry point returns gracefully, this will force
// build to throw
_hostTcs.TrySetException(new InvalidOperationException("Unable to build IHost"));
}
catch (TargetInvocationException tie) when (tie.InnerException is StopTheHostException)
{
// The host was stopped by our own logic
}
catch (TargetInvocationException tie)
{
// Another exception happened, propagate that to the caller
_hostTcs.TrySetException(tie.InnerException ?? tie);
}
catch (Exception ex)
{
// Another exception happened, propagate that to the caller
_hostTcs.TrySetException(ex);
}
})
{
// Make sure this doesn't hang the process
IsBackground = true
};

// Start the thread
thread.Start();

try
{
// Wait before throwing an exception
if (!_hostTcs.Task.Wait(_waitTimeout))
{
throw new InvalidOperationException("Unable to build IHost");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use a different message here - specifically call out that it timed out. That way we can tell the difference between the entrypoint returning gracefully vs. timing out.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want the timeout to be an implementation detail. I'm also thinking this could also return null.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who do you expect to see the message? If the message is primarily shown to people who are trying to resolve the issue (or users will relay it to someone else who resolves the issue) then it might help to make it more descriptive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to check the callers but its possible null is a better return than throwing.

}
}
catch (AggregateException) when (_hostTcs.Task.IsCompleted)
{
// Lets this propagate out of the call to GetAwaiter().GetResult()
}

Debug.Assert(_hostTcs.Task.IsCompleted);
halter73 marked this conversation as resolved.
Show resolved Hide resolved

return _hostTcs.Task.GetAwaiter().GetResult();
}

public void OnCompleted()
{
_disposable?.Dispose();
}

public void OnError(Exception error)
{

}

public void OnNext(DiagnosticListener value)
{
if (value.Name == "Microsoft.Extensions.Hosting")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we wire up to the new event to intercept calls to build.

{
_disposable = value.Subscribe(this);
}
}

public void OnNext(KeyValuePair<string, object?> value)
{
if (value.Key == "HostBuilding")
{
_configure?.Invoke(value.Value!);
}

if (value.Key == "HostBuilt")
{
_hostTcs.TrySetResult(value.Value!);

if (_stopApplication)
{
// Stop the host from running further
throw new StopTheHostException();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prize for dirty hackery this week : D

Is it important to guarantee that the thread really does stop? It would be easy enough for an app to throw a try/catch around the part of their code where this gets raised so that it never gets back to the entrypoint. Your tool code will be running in parallel with the user's unknown error handling app code. It doesn't seem obviously harmful to me but figured I mention it.

Copy link
Member Author

@davidfowl davidfowl Jun 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposal is one of the things I'm worried about and why I think throwing to stop execution makes the most sense here. The main app never gets a handle on the application here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be easy enough for an app to throw a try/catch around the part of their code where this gets raised so that it never gets back to the entrypoint.

The thing that stops this from happening in the throw case is the fact that the application never gets access to the IHost instance. They can't call build again on it because double building throws. Even if they catch the exception and do something else, the IHost isntance that came out of Build is never observed by the application.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I wasn't imagining they continue normally, more like they have some complex error handling code that will be running in parallel.

the application never gets access to the IHost instance

[Joking] What do you mean? This PR just added the official mechanism that lets everyone access IHost without the pesky inconvenience of needing Build() to return it to you ;p

}
}
}

private class StopTheHostException : Exception
{

}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
// The .NET Foundation licenses this file to you under the MIT license.

using MockHostTypes;
using Microsoft.Extensions.Hosting;
davidfowl marked this conversation as resolved.
Show resolved Hide resolved

namespace CreateHostBuilderInvalidSignature
{
public class Program
{
public static void Main(string[] args)
{
var webHost = CreateHostBuilder(null, args).Build();
var webHost = CreateHostBuilder(null, args)?.Build();
}

// Extra parameter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using MockHostTypes;
using Microsoft.Extensions.Hosting;

namespace CreateHostBuilderPatternTestSite
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
using MockHostTypes;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.DotNet.RemoteExecutor;
using Xunit;

namespace Microsoft.Extensions.Hosting.Tests
{
public class HostFactoryResolverTests
{
public static bool RequirementsMet => RemoteExecutor.IsSupported && PlatformDetection.IsThreadingSupported;

private static readonly TimeSpan s_WaitTimeout = TimeSpan.FromSeconds(20);

[Fact]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BuildWebHostPatternTestSite.Program))]
public void BuildWebHostPattern_CanFindWebHost()
Expand Down Expand Up @@ -46,7 +52,7 @@ public void BuildWebHostPattern__Invalid_CantFindServiceProvider()
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(BuildWebHostInvalidSignature.Program).Assembly);

Assert.Null(factory);
Assert.NotNull(factory);
}

[Fact]
Expand Down Expand Up @@ -119,13 +125,95 @@ public void CreateHostBuilderPattern__Invalid_CantFindHostBuilder()
Assert.Null(factory);
}

[Fact]
[ConditionalFact(nameof(RequirementsMet))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CreateHostBuilderInvalidSignature.Program))]
public void CreateHostBuilderPattern__Invalid_CantFindServiceProvider()
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(CreateHostBuilderInvalidSignature.Program).Assembly);
using var _ = RemoteExecutor.Invoke(() =>
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(CreateHostBuilderInvalidSignature.Program).Assembly, s_WaitTimeout);

Assert.Null(factory);
Assert.NotNull(factory);
Assert.Throws<InvalidOperationException>(() => factory(Array.Empty<string>()));
});
}

[ConditionalFact(nameof(RequirementsMet))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NoSpecialEntryPointPattern.Program))]
public void NoSpecialEntryPointPattern()
{
using var _ = RemoteExecutor.Invoke(() =>
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(NoSpecialEntryPointPattern.Program).Assembly, s_WaitTimeout);

Assert.NotNull(factory);
Assert.IsAssignableFrom<IServiceProvider>(factory(Array.Empty<string>()));
});
}

[ConditionalFact(nameof(RequirementsMet))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NoSpecialEntryPointPatternThrows.Program))]
public void NoSpecialEntryPointPatternThrows()
{
using var _ = RemoteExecutor.Invoke(() =>
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(NoSpecialEntryPointPatternThrows.Program).Assembly, s_WaitTimeout);

Assert.NotNull(factory);
Assert.Throws<Exception>(() => factory(Array.Empty<string>()));
});
}

[ConditionalFact(nameof(RequirementsMet))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NoSpecialEntryPointPatternExits.Program))]
public void NoSpecialEntryPointPatternExits()
{
using var _ = RemoteExecutor.Invoke(() =>
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(NoSpecialEntryPointPatternExits.Program).Assembly, s_WaitTimeout);

Assert.NotNull(factory);
Assert.Throws<InvalidOperationException>(() => factory(Array.Empty<string>()));
});
}

[ConditionalFact(nameof(RequirementsMet))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NoSpecialEntryPointPatternHangs.Program))]
public void NoSpecialEntryPointPatternHangs()
{
using var _ = RemoteExecutor.Invoke(() =>
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(NoSpecialEntryPointPatternHangs.Program).Assembly, s_WaitTimeout);

Assert.NotNull(factory);
Assert.Throws<InvalidOperationException>(() => factory(Array.Empty<string>()));
});
}

[ConditionalFact(nameof(RequirementsMet))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NoSpecialEntryPointPatternMainNoArgs.Program))]
public void NoSpecialEntryPointPatternMainNoArgs()
{
using var _ = RemoteExecutor.Invoke(() =>
{
var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(NoSpecialEntryPointPatternMainNoArgs.Program).Assembly, s_WaitTimeout);

Assert.NotNull(factory);
Assert.IsAssignableFrom<IServiceProvider>(factory(Array.Empty<string>()));
});
}

[ConditionalFact(nameof(RequirementsMet))]
public void TopLevelStatements()
{
using var _ = RemoteExecutor.Invoke(() =>
{
var assembly = Assembly.Load("TopLevelStatements");
var factory = HostFactoryResolver.ResolveServiceProviderFactory(assembly, s_WaitTimeout);

Assert.NotNull(factory);
Assert.IsAssignableFrom<IServiceProvider>(factory(Array.Empty<string>()));
});
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(NetCoreAppCurrent);net461</TargetFrameworks>
<IncludeRemoteExecutor>true</IncludeRemoteExecutor>
</PropertyGroup>

<ItemGroup>
Expand All @@ -20,5 +21,15 @@
<ProjectReference Include="CreateHostBuilderPatternTestSite\CreateHostBuilderPatternTestSite.csproj" />
<ProjectReference Include="CreateWebHostBuilderInvalidSignature\CreateWebHostBuilderInvalidSignature.csproj" />
<ProjectReference Include="CreateWebHostBuilderPatternTestSite\CreateWebHostBuilderPatternTestSite.csproj" />
<ProjectReference Include="NoSpecialEntryPointPattern\NoSpecialEntryPointPattern.csproj" />
<ProjectReference Include="NoSpecialEntryPointPatternThrows\NoSpecialEntryPointPatternThrows.csproj" />
<ProjectReference Include="NoSpecialEntryPointPatternExits\NoSpecialEntryPointPatternExits.csproj" />
<ProjectReference Include="NoSpecialEntryPointPatternHangs\NoSpecialEntryPointPatternHangs.csproj" />
<ProjectReference Include="NoSpecialEntryPointPatternMainNoArgs\NoSpecialEntryPointPatternMainNoArgs.csproj" />
<ProjectReference Include="TopLevelStatements\TopLevelStatements.csproj" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Hosting.Abstractions\src\Microsoft.Extensions.Hosting.Abstractions.csproj" />
</ItemGroup>
</Project>
Loading