Skip to content

Commit

Permalink
Merge pull request #353 from 0xced/single-app-no-assemblies-exception
Browse files Browse the repository at this point in the history
Make sure that single-file apps can find assemblies that contains sinks
  • Loading branch information
nblumhardt authored May 5, 2023
2 parents 246920e + e87d4e8 commit 325577e
Show file tree
Hide file tree
Showing 18 changed files with 546 additions and 24 deletions.
5 changes: 4 additions & 1 deletion Build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ if($LASTEXITCODE -ne 0) { exit 2 }

Write-Output "build: Testing"

& dotnet test test\Serilog.Settings.Configuration.Tests --configuration Release --no-build --no-restore
# Dotnet test doesn't run separate TargetFrameworks in parallel: https://github.com/dotnet/sdk/issues/19147
# Workaround: use `dotnet test` on dlls directly in order to pass the `--parallel` option to vstest.
# The _reported_ runtime is wrong but the _actual_ used runtime is correct, see https://github.com/microsoft/vstest/issues/2037#issuecomment-720549173
& dotnet test test\Serilog.Settings.Configuration.Tests\bin\Release\*\Serilog.Settings.Configuration.Tests.dll --parallel

if($LASTEXITCODE -ne 0) { exit 3 }
5 changes: 5 additions & 0 deletions serilog-settings-configuration.sln
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample", "sample\Sample\Sam
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestDummies", "test\TestDummies\TestDummies.csproj", "{B7CF5068-DD19-4868-A268-5280BDE90361}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApp", "test\TestApp\TestApp.csproj", "{1B6E08F3-16C9-4912-BEEE-57DB78C92A12}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -54,6 +56,8 @@ Global
{B7CF5068-DD19-4868-A268-5280BDE90361}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7CF5068-DD19-4868-A268-5280BDE90361}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7CF5068-DD19-4868-A268-5280BDE90361}.Release|Any CPU.Build.0 = Release|Any CPU
{1B6E08F3-16C9-4912-BEEE-57DB78C92A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B6E08F3-16C9-4912-BEEE-57DB78C92A12}.Release|Any CPU.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -63,6 +67,7 @@ Global
{F793C6E8-C40A-4018-8884-C97E2BE38A54} = {D551DCB0-7771-4D01-BEBD-F7B57D1CF0E3}
{A00E5E32-54F9-401A-BBA1-2F6FCB6366CD} = {D24872B9-57F3-42A7-BC8D-F9DA222FCE1B}
{B7CF5068-DD19-4868-A268-5280BDE90361} = {D551DCB0-7771-4D01-BEBD-F7B57D1CF0E3}
{1B6E08F3-16C9-4912-BEEE-57DB78C92A12} = {D551DCB0-7771-4D01-BEBD-F7B57D1CF0E3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {485F8843-42D7-4267-B5FB-20FE9181DEE9}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,7 @@ protected static bool IsCaseInsensitiveMatch(string? text, string textToFind)

public static AssemblyFinder Auto()
{
try
{
// Need to check `Assembly.GetEntryAssembly()` first because
// `DependencyContext.Default` throws an exception when `Assembly.GetEntryAssembly()` returns null
if (Assembly.GetEntryAssembly() != null && DependencyContext.Default != null)
{
return new DependencyContextAssemblyFinder(DependencyContext.Default);
}
}
catch (NotSupportedException) when (typeof(object).Assembly.Location is "") // bundled mode detection
{
}

return new DllScanningAssemblyFinder();
return new CompositeAssemblyFinder(new DependencyContextAssemblyFinder(DependencyContext.Default), new DllScanningAssemblyFinder());
}

public static AssemblyFinder ForSource(ConfigurationAssemblySource configurationAssemblySource)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Reflection;

namespace Serilog.Settings.Configuration.Assemblies;

class CompositeAssemblyFinder : AssemblyFinder
{
readonly AssemblyFinder[] _assemblyFinders;

public CompositeAssemblyFinder(params AssemblyFinder[] assemblyFinders)
{
_assemblyFinders = assemblyFinders;
}

public override IReadOnlyList<AssemblyName> FindAssembliesContainingName(string nameToFind)
{
var assemblyNames = new List<AssemblyName>();
foreach (var assemblyFinder in _assemblyFinders)
{
assemblyNames.AddRange(assemblyFinder.FindAssembliesContainingName(nameToFind));
}
return assemblyNames;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@ namespace Serilog.Settings.Configuration.Assemblies;

sealed class DependencyContextAssemblyFinder : AssemblyFinder
{
readonly DependencyContext _dependencyContext;
readonly DependencyContext? _dependencyContext;

public DependencyContextAssemblyFinder(DependencyContext dependencyContext)
public DependencyContextAssemblyFinder(DependencyContext? dependencyContext)
{
_dependencyContext = dependencyContext ?? throw new ArgumentNullException(nameof(dependencyContext));
_dependencyContext = dependencyContext;
}

public override IReadOnlyList<AssemblyName> FindAssembliesContainingName(string nameToFind)
{
if (_dependencyContext == null)
return Array.Empty<AssemblyName>();

var query = from library in _dependencyContext.RuntimeLibraries
where IsReferencingSerilog(library)
from assemblyName in library.GetDefaultAssemblyNames(_dependencyContext)
where IsCaseInsensitiveMatch(assemblyName.Name, nameToFind)
select assemblyName;

return query.ToList().AsReadOnly();
return query.ToList();

static bool IsReferencingSerilog(Library library)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ where IsCaseInsensitiveMatch(assemblyFileName, nameToFind)
where assemblyName != null
select assemblyName;

return query.ToList().AsReadOnly();
return query.ToList();

static AssemblyName? TryGetAssemblyNameFrom(string path)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ static IReadOnlyCollection<Assembly> LoadConfigurationAssemblies(IConfiguration
{
if (string.IsNullOrWhiteSpace(simpleName))
throw new InvalidOperationException(
"A zero-length or whitespace assembly name was supplied to a Serilog.Using configuration statement.");
$"A zero-length or whitespace assembly name was supplied to a {usingSection.Path} configuration statement.");

var assembly = Assembly.Load(new AssemblyName(simpleName));
if (!assemblies.ContainsKey(assembly.FullName!))
Expand All @@ -384,7 +384,19 @@ static IReadOnlyCollection<Assembly> LoadConfigurationAssemblies(IConfiguration
assemblies.Add(assumed.FullName!, assumed);
}

return assemblies.Values.ToList().AsReadOnly();
if (assemblies.Count == 1)
{
var message = $"""
No {usingSection.Path} configuration section is defined and no Serilog assemblies were found.
This is most likely because the application is published as single-file.
Either add a {usingSection.Path} section or explicitly specify assemblies that contains sinks and other types through the reader options. For example:
var options = new ConfigurationReaderOptions(typeof(ConsoleLoggerConfigurationExtensions).Assembly, typeof(SerilogExpression).Assembly);
new LoggerConfiguration().ReadFrom.Configuration(configuration, options);
""";
throw new InvalidOperationException(message);
}

return assemblies.Values;
}

void CallConfigurationMethods(ILookup<string, Dictionary<string, IConfigurationArgumentValue>> methods, IReadOnlyCollection<MethodInfo> configurationMethods, object receiver)
Expand Down
158 changes: 158 additions & 0 deletions test/Serilog.Settings.Configuration.Tests/PublishSingleFileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.Diagnostics;
using System.Text;
using CliWrap;
using CliWrap.Exceptions;
using FluentAssertions;
using FluentAssertions.Execution;
using Serilog.Settings.Configuration.Tests.Support;
using Xunit.Abstractions;

namespace Serilog.Settings.Configuration.Tests;

[Trait("Category", "Integration")]
public sealed class PublishSingleFileTests : IDisposable, IClassFixture<TestApp>
{
readonly ITestOutputHelper _outputHelper;
readonly TestApp _testApp;
readonly AssertionScope _scope;

public PublishSingleFileTests(ITestOutputHelper outputHelper, TestApp testApp)
{
_outputHelper = outputHelper;
_testApp = testApp;
_scope = new AssertionScope();
}

public void Dispose()
{
_scope.Dispose();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_NoUsingAndNoAssembly(PublishMode publishMode)
{
var (isSingleFile, stdOut, stdErr) = await RunTestAppAsync(publishMode);
stdOut.Should().Be(isSingleFile ? "Expected exception" : "(Main thread) [Information] Expected success");
stdErr.Should().BeEmpty();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_UsingConsole(PublishMode publishMode)
{
var (isSingleFile, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--using-console");
stdOut.Should().Be(isSingleFile ? "() [Information] Expected success" : "(Main thread) [Information] Expected success");
if (isSingleFile)
stdErr.Should().Contain("Unable to find a method called WithThreadName");
else
stdErr.Should().BeEmpty();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_UsingThread(PublishMode publishMode)
{
var (isSingleFile, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--using-thread");
stdOut.Should().Be(isSingleFile ? "" : "(Main thread) [Information] Expected success");
if (isSingleFile)
stdErr.Should().Contain("Unable to find a method called Console");
else
stdErr.Should().BeEmpty();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_AssemblyThread(PublishMode publishMode)
{
var (_, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--assembly-thread");
stdOut.Should().BeEmpty();
stdErr.Should().Contain("Unable to find a method called Console");
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_AssemblyConsole(PublishMode publishMode)
{
var (_, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--assembly-console");
stdOut.Should().Be("() [Information] Expected success");
stdErr.Should().Contain("Unable to find a method called WithThreadName");
}

[Theory]
[ClassData(typeof(PublishModeAndStrategyTheoryData))]
public async Task RunTestApp_ConsoleAndThread(PublishMode publishMode, string strategy)
{
var (_, stdOut, stdErr) = await RunTestAppAsync(publishMode, $"--{strategy}-console", $"--{strategy}-thread");
stdOut.Should().Be("(Main thread) [Information] Expected success");
stdErr.Should().BeEmpty();
}

async Task<(bool IsSingleFile, string StdOut, string StdErr)> RunTestAppAsync(PublishMode publishMode, params string[] args)
{
// Determine whether the app is a _true_ single file, i.e. not a .NET Core 3.x version which
// [extracts bundled files to disk][1] and thus can find dlls.
// [1]: https://github.com/dotnet/designs/blob/main/accepted/2020/single-file/extract.md
var (isSingleFile, _) = await RunTestAppInternalAsync(publishMode, "is-single-file");
var (stdOut, stdErr) = await RunTestAppInternalAsync(publishMode, args);
return (bool.Parse(isSingleFile), stdOut, stdErr);
}

async Task<(string StdOut, string StdErr)> RunTestAppInternalAsync(PublishMode publishMode, params string[] args)
{
var stdOutBuilder = new StringBuilder();
var stdErrBuilder = new StringBuilder();

var command = Cli.Wrap(_testApp.GetExecutablePath(publishMode))
.WithArguments(args)
.WithValidation(CommandResultValidation.None)
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuilder))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuilder));

_outputHelper.WriteLine(command.ToString());

var stopwatch = Stopwatch.StartNew();
var result = await command.ExecuteAsync();
var executionTime = stopwatch.ElapsedMilliseconds;

var stdOut = stdOutBuilder.ToString().Trim();
var stdErr = stdErrBuilder.ToString().Trim();

_outputHelper.WriteLine($"Executed in {executionTime} ms");
_outputHelper.WriteLine(stdOut.Length > 0 ? $"stdout: {stdOut}" : "nothing on stdout");
_outputHelper.WriteLine(stdErr.Length > 0 ? $"stderr: {stdErr}" : "nothing on stderr");
_outputHelper.WriteLine("");

if (result.ExitCode != 0)
{
throw new CommandExecutionException(command, result.ExitCode, $"An unexpected exception has occurred while running {command}{Environment.NewLine}{stdErr}".Trim());
}

return (stdOut, stdErr);
}

class PublishModeTheoryData : TheoryData<PublishMode>
{
public PublishModeTheoryData()
{
foreach (var publishMode in PublishModeExtensions.GetPublishModes())
{
Add(publishMode);
}
}
}

class PublishModeAndStrategyTheoryData : TheoryData<PublishMode, string>
{
public PublishModeAndStrategyTheoryData()
{
foreach (var publishMode in PublishModeExtensions.GetPublishModes())
{
foreach (var strategy in new[] { "using", "assembly" })
{
Add(publishMode, strategy);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks Condition=" '$(OS)' == 'Windows_NT'">net48</TargetFrameworks>
<TargetFrameworks>$(TargetFrameworks);net7.0</TargetFrameworks>
<TargetFrameworks>$(TargetFrameworks);net7.0;net6.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
Expand All @@ -17,8 +17,12 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="CliWrap" Version="3.6.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NuGet.Frameworks" Version="6.5.0" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Serilog.Expressions" Version="3.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageReference Include="xunit" Version="2.4.2" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Serilog.Settings.Configuration.Tests;

static class DirectoryInfoExtensions
{
public static DirectoryInfo SubDirectory(this DirectoryInfo directory, params string[] paths)
=> new(Path.GetFullPath(Path.Combine(paths.Prepend(directory.FullName).ToArray())));

public static FileInfo File(this DirectoryInfo directory, params string[] paths)
=> new(Path.GetFullPath(Path.Combine(paths.Prepend(directory.FullName).ToArray())));
}
25 changes: 25 additions & 0 deletions test/Serilog.Settings.Configuration.Tests/Support/PublishMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Serilog.Settings.Configuration.Tests.Support;

/// <summary>
/// The possible application publish modes for the TestApp.
/// See also the <a href="https://learn.microsoft.com/en-us/dotnet/core/deploying/">.NET application publishing overview</a> documentation.
/// </summary>
public enum PublishMode
{
/// <summary>
/// Standard app publish, all dlls and related files are copied along the main executable.
/// </summary>
Standard,

/// <summary>
/// Publish a single file as a framework-dependent binary.
/// </summary>
/// <remarks>On .NET Framework, <a href="https://github.com/Fody/Costura">Costura</a> is used to publish as a single file.</remarks>
SingleFile,

/// <summary>
/// Publish a single file as a self contained binary, i.e. including the .NET libraries and target runtime.
/// </summary>
/// <remarks>This mode is ignored on .NET Framework as it doesn't make sense.</remarks>
SelfContained,
}
Loading

0 comments on commit 325577e

Please sign in to comment.