Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<entry xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://logging.apache.org/xml/ns"
xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
type="fixed">
<issue id="233" link="https://github.com/apache/logging-log4net/issues/233"/>
<issue id="306" link="https://github.com/apache/logging-log4net/pull/306"/>
<description format="asciidoc">Make log4net usable from a `PublishAot` build, where `LogManager.GetLogger()`
used to throw `PlatformNotSupportedException` from `Assembly.GetCallingAssembly()`, and where
repositories and pattern converters were left without a constructor by the trimmer. Configuration
has to be done in code - see the new
https://logging.apache.org/log4net/latest/manual/native-aot.html[Native AOT and trimming] page
(reported by @vpenades, implemented by @FreeAndNil in https://github.com/apache/logging-log4net/pull/306[#306])</description>
</entry>
76 changes: 76 additions & 0 deletions src/log4net.Tests/Util/CallerAssemblyTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#region Apache License
//
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to you under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#endregion

using System.Reflection;
using System.Runtime.CompilerServices;
using log4net.Util;
using NUnit.Framework;

namespace log4net.Tests.Util;

/// <summary>
/// Tests for <see cref="CallerAssembly"/>, the guard that keeps the
/// <see cref="Assembly.GetCallingAssembly()"/> based overloads usable under Native AOT.
/// </summary>
/// <remarks>
/// <para>
/// The AOT half of the behaviour cannot be covered here - these tests always run on a JIT
/// runtime, where <see cref="CallerAssembly.IsSupported"/> is <see langword="true"/> and
/// <see cref="CallerAssembly.Fallback"/> is never consulted. What they do cover is that the
/// guard stays inert on a JIT runtime, so that no call site silently starts attributing
/// loggers to the entry assembly instead of the caller.
/// </para>
/// </remarks>
[TestFixture]
public class CallerAssemblyTest
{
/// <summary>
/// The probe recognises a runtime that does implement
/// <see cref="Assembly.GetCallingAssembly()"/>, so the guard stays out of the way everywhere
/// except Native AOT. A false negative here would silently move every logger to the entry
/// assembly's repository.
/// </summary>
[Test]
public void IsSupportedOnAJitRuntime() => Assert.That(CallerAssembly.IsSupported, Is.True);

/// <summary>
/// There is always a replacement assembly to attribute a call to, even though the entry
/// assembly is <see langword="null"/> in a host without a managed entry point.
/// </summary>
[Test]
public void FallbackIsAvailable() => Assert.That(CallerAssembly.Fallback, Is.Not.Null);

/// <summary>
/// The guard has to leave <see cref="Assembly.GetCallingAssembly()"/> in the method whose
/// caller is wanted, so a call from this assembly still resolves to this assembly.
/// </summary>
[Test]
public void GuardedCallStillReportsTheCallersAssembly()
=> Assert.That(GuardedCallingAssembly(), Is.SameAs(typeof(CallerAssemblyTest).Assembly));

/// <summary>
/// Stands in for a public log4net entry point. Inlining is suppressed because it would
/// hand <see cref="Assembly.GetCallingAssembly()"/> a different frame - the same effect
/// that makes the release build of <see cref="SystemInfoTest"/> unable to assert on an
/// exact assembly.
/// </summary>
[MethodImpl(MethodImplOptions.NoInlining)]
private static Assembly GuardedCallingAssembly()
=> CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback;
}
100 changes: 100 additions & 0 deletions src/log4net.Tests/Util/SystemInfoTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

using NUnit.Framework;

using System.Configuration;
using System.IO;
using System.Linq.Expressions;
using System.Reflection;

Expand Down Expand Up @@ -171,4 +173,102 @@ public void EqualsIgnoringCase_DifferentStrings_false()
[Platform(Include = "Win,Linux,MacOsX")]
public void IsAndoid()
=> Assert.That(typeof(SystemInfo).GetProperty("IsAndroid", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null), Is.False);

/// <summary>
/// <see cref="SystemInfo.GetAppSetting"/> falls back to environment variables once the
/// configuration system has failed - which is what happens under Native AOT, where
/// System.Configuration is trimmed away.
/// </summary>
/// <remarks>
/// <para>
/// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped
/// directly, the same way <see cref="IsAndoid"/> reaches a non-public member. The environment
/// must stay untouched while the configuration system still works, otherwise a malformed
/// <c>app.config</c> would silently change where every setting comes from.
/// </para>
/// </remarks>
[Test]
[NonParallelizable]
public void GetAppSettingFallsBackToTheEnvironmentOnceConfigurationIsUnavailable()
{
const string Key = "log4net.Tests.AppSettingFallback";
const string Value = "from-the-environment";

FieldInfo latch = AppSettingsUnavailableLatch();
bool originalLatch = (bool)latch.GetValue(null)!;
Environment.SetEnvironmentVariable(Key, Value);
try
{
latch.SetValue(null, false);
Assert.That(SystemInfo.GetAppSetting(Key), Is.Null);

latch.SetValue(null, true);
Assert.That(SystemInfo.GetAppSetting(Key), Is.EqualTo(Value));
}
finally
{
latch.SetValue(null, originalLatch);
Environment.SetEnvironmentVariable(Key, null);
}
}

/// <summary>
/// A key that is missing from the environment as well reads as <see langword="null"/>, so the
/// fallback leaves callers with the same "no such setting" answer they get from a working
/// configuration system.
/// </summary>
[Test]
[NonParallelizable]
public void GetAppSettingReturnsNullForAnUnsetEnvironmentVariable()
{
FieldInfo latch = AppSettingsUnavailableLatch();
bool originalLatch = (bool)latch.GetValue(null)!;
try
{
latch.SetValue(null, true);
Assert.That(SystemInfo.GetAppSetting("log4net.Tests.NoSuchSettingAnywhere"), Is.Null);
}
finally
{
latch.SetValue(null, originalLatch);
}
}

/// <summary>
/// A configuration file that does not parse is reported, not routed to the environment - the
/// behaviour on every runtime that has a working configuration system is unchanged.
/// </summary>
[Test]
public void MalformedConfigurationIsNotTreatedAsAMissingConfigurationSystem()
=> Assert.That(IsMissingConfigurationSystem(new ConfigurationErrorsException("malformed")), Is.False);

/// <summary>
/// Native AOT surfaces a trimmed configuration system as a <see cref="ConfigurationErrorsException"/>,
/// the same type a malformed file produces, so only the inner exception tells them apart.
/// </summary>
[Test]
public void TrimmedConfigurationSystemIsRecognisedThroughTheInnerException()
=> Assert.That(IsMissingConfigurationSystem(
new ConfigurationErrorsException("Configuration system failed to initialize",
new MissingMethodException("No parameterless constructor defined for type 'System.Configuration.ClientConfigurationHost'."))),
Is.True);

/// <summary>
/// A deployment without the System.Configuration.ConfigurationManager assembly fails on the
/// outermost exception rather than an inner one.
/// </summary>
[Test]
public void MissingConfigurationAssemblyIsRecognised()
=> Assert.That(IsMissingConfigurationSystem(new FileNotFoundException("System.Configuration.ConfigurationManager")), Is.True);

private static bool IsMissingConfigurationSystem(Exception exception)
{
MethodInfo method = typeof(SystemInfo).GetMethod("IsMissingConfigurationSystem", BindingFlags.Static | BindingFlags.NonPublic)
?? throw new InvalidOperationException("SystemInfo.IsMissingConfigurationSystem no longer exists - update this test along with it.");
return (bool)method.Invoke(null, [exception])!;
}

private static FieldInfo AppSettingsUnavailableLatch()
=> typeof(SystemInfo).GetField("_configurationSystemUnavailable", BindingFlags.Static | BindingFlags.NonPublic)
?? throw new InvalidOperationException("SystemInfo._configurationSystemUnavailable no longer exists - update this test along with it.");
}
1 change: 1 addition & 0 deletions src/log4net.Tests/log4net.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<VSTestLogger>quackers</VSTestLogger>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\log4net\Util\CallerAssembly.cs" Link="Util\CallerAssembly.cs" />
<Compile Include="..\log4net\Diagnostics\CodeAnalysis\CallerArgumentExpressionAttribute.cs" Link="Diagnostics\CodeAnalysis\CallerArgumentExpressionAttribute.cs" />
<Compile Include="..\log4net\Diagnostics\CodeAnalysis\IsExternalInit.cs" Link="Diagnostics\CodeAnalysis\IsExternalInit.cs" />
</ItemGroup>
Expand Down
12 changes: 10 additions & 2 deletions src/log4net/Appender/FileAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#endregion

using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.Serialization;
using System.Text;
Expand Down Expand Up @@ -838,14 +839,21 @@ public override void OnClose()
/// <summary>
/// Default locking model (when no locking model was configured)
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
private static Type _defaultLockingModelType = typeof(ExclusiveLock);

/// <summary>
/// Specify default locking model
/// </summary>
/// <typeparam name="TLockingModel">Type of LockingModel</typeparam>
public static void SetDefaultLockingModelType<TLockingModel>()
where TLockingModel : LockingModelBase
/// <remarks>
/// <para>
/// The locking model is created with <see cref="Activator.CreateInstance(Type)"/>, so the
/// <c>new()</c> constraint is what keeps its constructor alive in a trimmed or Native AOT build.
/// </para>
/// </remarks>
public static void SetDefaultLockingModelType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TLockingModel>()
where TLockingModel : LockingModelBase, new()
=> _defaultLockingModelType = typeof(TLockingModel);

/// <summary>
Expand Down
5 changes: 3 additions & 2 deletions src/log4net/Config/BasicConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ public static class BasicConfigurator
/// layout style.
/// </para>
/// </remarks>
public static ICollection Configure() => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly()));
public static ICollection Configure()
=> Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback));

/// <summary>
/// Initializes the log4net system using the specified appenders.
Expand All @@ -88,7 +89,7 @@ public static ICollection Configure(params IAppender[] appenders)
{
List<LogLog> configurationMessages = new();

ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);

using (new LogLog.LogReceivedAdapter(configurationMessages))
{
Expand Down
2 changes: 2 additions & 0 deletions src/log4net/Config/RepositoryAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#endregion

using System;
using System.Diagnostics.CodeAnalysis;

namespace log4net.Config;

Expand Down Expand Up @@ -104,5 +105,6 @@ public RepositoryAttribute()
/// repository.
/// </para>
/// </remarks>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type? RepositoryType { get; set; }
}
14 changes: 8 additions & 6 deletions src/log4net/Config/XmlConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ private static void InternalConfigure(ILoggerRepository repository, Func<XmlElem
/// </remarks>
/// <seealso cref="Log4NetConfigurationSectionHandler"/>
public static ICollection Configure()
=> Configure(LogManager.GetRepository(Assembly.GetCallingAssembly()));
=> Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see this logic quite a few times throughout - perhaps move to CallerAssembly with a lazy backing field and reference that static property elsewhere (eg CallerAssembly.ResolvedCallerAssembly? Main reason being that it's no longer just an obvious call to Assembly.GetCallingAssembly(), but now includes logic, which is repeated in quite a few places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@fluffynuts
Good catch, but this one can't move - though I don't like it either. Assembly.GetCallingAssembly()
returns the caller of the method containing the call, so in a property on CallerAssembly the caller
is log4net itself - every logger would land in log4net's own repository. Two-assembly harness,
called from UserApp:

inline (current PR)      -> UserApp       <- correct
via property (suggested) -> log4net

The lazy backing field is worse: the first assembly to touch it wins forever, so the result depends on
load order.

The BCL hits this exact problem and needs an internal enum for it - System.Threading.StackCrawlMark
(LookForMyCaller, LookForMyCallersCaller), passed by ref so Assembly.Load can delegate to a
private helper. It's NotPublic and no public API accepts it. It also wouldn't help us: it's
stack-walking machinery, and AOT throws precisely because there is no stack to walk.

Only a Roslyn interceptor would actually remove the repetition - left out here, but I'm happy to open a separate issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Personally, I think [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
is the bigger eyesore.


/// <summary>
/// Configures log4net using a <c>log4net</c> element
Expand All @@ -156,7 +156,7 @@ public static ICollection Configure(XmlElement element)
{
List<LogLog> configurationMessages = [];

ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);

using (new LogLog.LogReceivedAdapter(configurationMessages))
{
Expand Down Expand Up @@ -222,9 +222,11 @@ public static ICollection Configure(FileInfo configFile)
{
List<LogLog> configurationMessages = [];

Assembly repositoryAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback;

using (new LogLog.LogReceivedAdapter(configurationMessages))
{
InternalConfigure(LogManager.GetRepository(Assembly.GetCallingAssembly()), configFile);
InternalConfigure(LogManager.GetRepository(repositoryAssembly), configFile);
}

return configurationMessages;
Expand All @@ -248,7 +250,7 @@ public static ICollection Configure(Uri configUri)
{
List<LogLog> configurationMessages = [];

ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
using (new LogLog.LogReceivedAdapter(configurationMessages))
{
InternalConfigure(repository, configUri);
Expand Down Expand Up @@ -277,7 +279,7 @@ public static ICollection Configure(Stream configStream)
{
List<LogLog> configurationMessages = [];

ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
using (new LogLog.LogReceivedAdapter(configurationMessages))
{
InternalConfigure(repository, configStream);
Expand Down Expand Up @@ -644,7 +646,7 @@ public static ICollection ConfigureAndWatch(FileInfo configFile)
{
List<LogLog> configurationMessages = [];

ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);

using (new LogLog.LogReceivedAdapter(configurationMessages))
{
Expand Down
Loading
Loading