From bf839f78293923425577bb6431de049a089d3a64 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Wed, 5 Aug 2026 23:06:59 +0200 Subject: [PATCH 1/5] make log4net usable from a PublishAOT build (#306) Native AOT broke log4net in three ways (#233). Assembly.GetCallingAssembly() throws PlatformNotSupportedException there, so every overload that resolves the repository from the caller failed - LogManager.GetLogger(Type) among them. Guard the 18 call sites with CallerAssembly.IsSupported, a flag probed once, and fall back to the entry assembly when the runtime does not implement the call. The call itself has to stay in the public method whose caller is wanted, so it cannot be moved into the helper. SystemInfo.GetAppSetting() then reported a caught failure on every lookup, because a trimmed System.Configuration cannot initialize. Tell that apart from a configuration file that does not parse - Native AOT surfaces both as a ConfigurationErrorsException, so only the inner exception distinguishes them - and treat a missing configuration system as a property of the runtime rather than a fault: log it at debug level and let environment variables stand in for the config file, as they already do on Android. A malformed config file is still reported as an error and still yields no setting. Finally the trimmer removed the constructors of everything log4net creates reflectively, so no repository, pattern converter or locking model could be instantiated. Annotate that flow with DynamicallyAccessedMembers - polyfilled here, because the trimmer matches it by name and neither target framework declares it - and hold the built-in converters in a Dictionary of ConverterInfo rather than of Type, since a Type placed in a collection loses its annotation. The registries are now built through a generic method whose new() constraint states the same requirement structurally, so a converter without a public parameterless constructor fails to compile instead of failing in a trimmed build. Configuration still has to be done in code: XmlConfigurator names its types in strings and cannot work once they have been trimmed. Document that, and the fact that loggers from non-entry assemblies land in the entry assembly's repository, on a new Native AOT page in the manual. --- .../306-usable-from-a-publishaot-build.xml | 14 ++ src/log4net.Tests/Util/CallerAssemblyTest.cs | 76 +++++++ src/log4net.Tests/Util/SystemInfoTest.cs | 100 +++++++++ src/log4net.Tests/log4net.Tests.csproj | 1 + src/log4net/Appender/FileAppender.cs | 12 +- src/log4net/Config/BasicConfigurator.cs | 5 +- src/log4net/Config/RepositoryAttribute.cs | 2 + src/log4net/Config/XmlConfigurator.cs | 14 +- src/log4net/Core/DefaultRepositorySelector.cs | 21 +- src/log4net/Core/IRepositorySelector.cs | 7 +- src/log4net/Core/LoggerManager.cs | 7 +- .../DynamicallyAccessedMemberTypes.cs | 53 +++++ .../DynamicallyAccessedMembersAttribute.cs | 49 +++++ src/log4net/Layout/PatternLayout.cs | 137 ++++++------ src/log4net/LogManager.cs | 68 ++++-- src/log4net/Util/CallerAssembly.cs | 74 +++++++ src/log4net/Util/ConverterInfo.cs | 2 + src/log4net/Util/PatternString.cs | 73 ++++--- src/log4net/Util/SystemInfo.cs | 83 +++++++- .../Util/TypeConverters/ConverterRegistry.cs | 7 +- src/site/antora/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/manual/configuration.adoc | 7 + .../modules/ROOT/pages/manual/native-aot.adoc | 196 ++++++++++++++++++ 23 files changed, 861 insertions(+), 148 deletions(-) create mode 100644 src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml create mode 100644 src/log4net.Tests/Util/CallerAssemblyTest.cs create mode 100644 src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs create mode 100644 src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs create mode 100644 src/log4net/Util/CallerAssembly.cs create mode 100644 src/site/antora/modules/ROOT/pages/manual/native-aot.adoc diff --git a/src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml b/src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml new file mode 100644 index 000000000..62c79d4a0 --- /dev/null +++ b/src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml @@ -0,0 +1,14 @@ + + + + + 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]) + diff --git a/src/log4net.Tests/Util/CallerAssemblyTest.cs b/src/log4net.Tests/Util/CallerAssemblyTest.cs new file mode 100644 index 000000000..cfc134696 --- /dev/null +++ b/src/log4net.Tests/Util/CallerAssemblyTest.cs @@ -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; + +/// +/// Tests for , the guard that keeps the +/// based overloads usable under Native AOT. +/// +/// +/// +/// The AOT half of the behaviour cannot be covered here - these tests always run on a JIT +/// runtime, where is and +/// 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. +/// +/// +[TestFixture] +public class CallerAssemblyTest +{ + /// + /// The probe recognises a runtime that does implement + /// , 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. + /// + [Test] + public void IsSupportedOnAJitRuntime() => Assert.That(CallerAssembly.IsSupported, Is.True); + + /// + /// There is always a replacement assembly to attribute a call to, even though the entry + /// assembly is in a host without a managed entry point. + /// + [Test] + public void FallbackIsAvailable() => Assert.That(CallerAssembly.Fallback, Is.Not.Null); + + /// + /// The guard has to leave in the method whose + /// caller is wanted, so a call from this assembly still resolves to this assembly. + /// + [Test] + public void GuardedCallStillReportsTheCallersAssembly() + => Assert.That(GuardedCallingAssembly(), Is.SameAs(typeof(CallerAssemblyTest).Assembly)); + + /// + /// Stands in for a public log4net entry point. Inlining is suppressed because it would + /// hand a different frame - the same effect + /// that makes the release build of unable to assert on an + /// exact assembly. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static Assembly GuardedCallingAssembly() + => CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; +} diff --git a/src/log4net.Tests/Util/SystemInfoTest.cs b/src/log4net.Tests/Util/SystemInfoTest.cs index 9b9067c91..c24914ff1 100644 --- a/src/log4net.Tests/Util/SystemInfoTest.cs +++ b/src/log4net.Tests/Util/SystemInfoTest.cs @@ -23,6 +23,8 @@ using NUnit.Framework; +using System.Configuration; +using System.IO; using System.Linq.Expressions; using System.Reflection; @@ -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); + + /// + /// falls back to environment variables once the + /// configuration system has failed - which is what happens under Native AOT, where + /// System.Configuration is trimmed away. + /// + /// + /// + /// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped + /// directly, the same way reaches a non-public member. The environment + /// must stay untouched while the configuration system still works, otherwise a malformed + /// app.config would silently change where every setting comes from. + /// + /// + [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); + } + } + + /// + /// A key that is missing from the environment as well reads as , so the + /// fallback leaves callers with the same "no such setting" answer they get from a working + /// configuration system. + /// + [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); + } + } + + /// + /// 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. + /// + [Test] + public void MalformedConfigurationIsNotTreatedAsAMissingConfigurationSystem() + => Assert.That(IsMissingConfigurationSystem(new ConfigurationErrorsException("malformed")), Is.False); + + /// + /// Native AOT surfaces a trimmed configuration system as a , + /// the same type a malformed file produces, so only the inner exception tells them apart. + /// + [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); + + /// + /// A deployment without the System.Configuration.ConfigurationManager assembly fails on the + /// outermost exception rather than an inner one. + /// + [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."); } \ No newline at end of file diff --git a/src/log4net.Tests/log4net.Tests.csproj b/src/log4net.Tests/log4net.Tests.csproj index 1ce53b952..f9668c7a3 100644 --- a/src/log4net.Tests/log4net.Tests.csproj +++ b/src/log4net.Tests/log4net.Tests.csproj @@ -19,6 +19,7 @@ quackers + diff --git a/src/log4net/Appender/FileAppender.cs b/src/log4net/Appender/FileAppender.cs index 5b444afd2..993e2974e 100644 --- a/src/log4net/Appender/FileAppender.cs +++ b/src/log4net/Appender/FileAppender.cs @@ -19,6 +19,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.Serialization; using System.Text; @@ -838,14 +839,21 @@ public override void OnClose() /// /// Default locking model (when no locking model was configured) /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] private static Type _defaultLockingModelType = typeof(ExclusiveLock); /// /// Specify default locking model /// /// Type of LockingModel - public static void SetDefaultLockingModelType() - where TLockingModel : LockingModelBase + /// + /// + /// The locking model is created with , so the + /// new() constraint is what keeps its constructor alive in a trimmed or Native AOT build. + /// + /// + public static void SetDefaultLockingModelType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TLockingModel>() + where TLockingModel : LockingModelBase, new() => _defaultLockingModelType = typeof(TLockingModel); /// diff --git a/src/log4net/Config/BasicConfigurator.cs b/src/log4net/Config/BasicConfigurator.cs index ea7dfa684..2af06e672 100644 --- a/src/log4net/Config/BasicConfigurator.cs +++ b/src/log4net/Config/BasicConfigurator.cs @@ -73,7 +73,8 @@ public static class BasicConfigurator /// layout style. /// /// - public static ICollection Configure() => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); + public static ICollection Configure() + => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); /// /// Initializes the log4net system using the specified appenders. @@ -88,7 +89,7 @@ public static ICollection Configure(params IAppender[] appenders) { List configurationMessages = new(); - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { diff --git a/src/log4net/Config/RepositoryAttribute.cs b/src/log4net/Config/RepositoryAttribute.cs index fd5c6308e..5cba2d6ec 100644 --- a/src/log4net/Config/RepositoryAttribute.cs +++ b/src/log4net/Config/RepositoryAttribute.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; namespace log4net.Config; @@ -104,5 +105,6 @@ public RepositoryAttribute() /// repository. /// /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public Type? RepositoryType { get; set; } } \ No newline at end of file diff --git a/src/log4net/Config/XmlConfigurator.cs b/src/log4net/Config/XmlConfigurator.cs index fa060cb1d..e7ffd2db5 100644 --- a/src/log4net/Config/XmlConfigurator.cs +++ b/src/log4net/Config/XmlConfigurator.cs @@ -140,7 +140,7 @@ private static void InternalConfigure(ILoggerRepository repository, Func /// public static ICollection Configure() - => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); + => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); /// /// Configures log4net using a log4net element @@ -156,7 +156,7 @@ public static ICollection Configure(XmlElement element) { List configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { @@ -222,9 +222,11 @@ public static ICollection Configure(FileInfo configFile) { List 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; @@ -248,7 +250,7 @@ public static ICollection Configure(Uri configUri) { List 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); @@ -277,7 +279,7 @@ public static ICollection Configure(Stream configStream) { List 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); @@ -644,7 +646,7 @@ public static ICollection ConfigureAndWatch(FileInfo configFile) { List configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { diff --git a/src/log4net/Core/DefaultRepositorySelector.cs b/src/log4net/Core/DefaultRepositorySelector.cs index a14d6c820..406f11529 100644 --- a/src/log4net/Core/DefaultRepositorySelector.cs +++ b/src/log4net/Core/DefaultRepositorySelector.cs @@ -21,6 +21,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using log4net.Config; @@ -71,12 +72,13 @@ public class DefaultRepositorySelector : IRepositorySelector /// /// is . /// does not implement . - public DefaultRepositorySelector(Type defaultRepositoryType) + public DefaultRepositorySelector([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type defaultRepositoryType) { // Check that the type is a repository if (!typeof(ILoggerRepository).IsAssignableFrom(defaultRepositoryType.EnsureNotNull())) { - throw SystemInfo.CreateArgumentOutOfRangeException("defaultRepositoryType", defaultRepositoryType, $"Parameter: defaultRepositoryType, Value: [{defaultRepositoryType}] out of range. Argument must implement the ILoggerRepository interface"); + throw SystemInfo.CreateArgumentOutOfRangeException("defaultRepositoryType", defaultRepositoryType, + $"Parameter: defaultRepositoryType, Value: [{defaultRepositoryType}] out of range. Argument must implement the ILoggerRepository interface"); } this._defaultRepositoryType = defaultRepositoryType; @@ -175,7 +177,8 @@ public ILoggerRepository GetRepository(string repositoryName) /// /// /// is . - public ILoggerRepository CreateRepository(Assembly assembly, Type repositoryType) + public ILoggerRepository CreateRepository(Assembly assembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) => CreateRepository(assembly, repositoryType, DefaultRepositoryName, true); /// @@ -216,7 +219,9 @@ public ILoggerRepository CreateRepository(Assembly assembly, Type repositoryType /// /// /// is . - public ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type? repositoryType, string repositoryName, bool readAssemblyAttributes) + public ILoggerRepository CreateRepository(Assembly repositoryAssembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type? repositoryType, + string repositoryName, bool readAssemblyAttributes) { repositoryAssembly.EnsureNotNull(); @@ -305,7 +310,8 @@ public ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type? rep /// /// is . /// already exists. - public ILoggerRepository CreateRepository(string repositoryName, Type? repositoryType) + public ILoggerRepository CreateRepository(string repositoryName, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type? repositoryType) { repositoryName.EnsureNotNull(); @@ -479,7 +485,8 @@ protected virtual void OnLoggerRepositoryCreatedEvent(ILoggerRepository reposito /// in/out param to hold the repository name to use for the assembly, caller should set this to the default value before calling. /// in/out param to hold the type of the repository to create for the assembly, caller should set this to the default value before calling. /// is . - private void GetInfoForAssembly(Assembly assembly, ref string repositoryName, ref Type repositoryType) + private void GetInfoForAssembly(Assembly assembly, ref string repositoryName, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] ref Type repositoryType) { assembly.EnsureNotNull(); @@ -735,5 +742,7 @@ private void LoadAliases(Assembly assembly, ILoggerRepository repository) private readonly Dictionary _name2Repository = new(StringComparer.Ordinal); private readonly Dictionary _assembly2Repository = []; private readonly Dictionary _alias2Repository = new(StringComparer.Ordinal); + + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] private readonly Type _defaultRepositoryType; } \ No newline at end of file diff --git a/src/log4net/Core/IRepositorySelector.cs b/src/log4net/Core/IRepositorySelector.cs index 2ac3d52d8..30b85186d 100644 --- a/src/log4net/Core/IRepositorySelector.cs +++ b/src/log4net/Core/IRepositorySelector.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using log4net.Repository; @@ -127,7 +128,8 @@ public interface IRepositorySelector /// this association. /// /// - ILoggerRepository CreateRepository(Assembly assembly, Type repositoryType); + ILoggerRepository CreateRepository(Assembly assembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType); /// /// Creates a new repository with the name specified. @@ -142,7 +144,8 @@ public interface IRepositorySelector /// same name will return the same repository instance. /// /// - ILoggerRepository CreateRepository(string repositoryName, Type? repositoryType); + ILoggerRepository CreateRepository(string repositoryName, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type? repositoryType); /// /// Test if a named repository exists diff --git a/src/log4net/Core/LoggerManager.cs b/src/log4net/Core/LoggerManager.cs index 3ef057959..29176c800 100644 --- a/src/log4net/Core/LoggerManager.cs +++ b/src/log4net/Core/LoggerManager.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text; using log4net.Util; @@ -466,7 +467,8 @@ public static ILoggerRepository CreateRepository(string repository) /// /// /// The specified repository already exists. - public static ILoggerRepository CreateRepository(string repository, Type repositoryType) + public static ILoggerRepository CreateRepository(string repository, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) => RepositorySelector.CreateRepository(repository.EnsureNotNull(), repositoryType.EnsureNotNull()); /// @@ -484,7 +486,8 @@ public static ILoggerRepository CreateRepository(string repository, Type reposit /// same assembly specified will return the same repository instance. /// /// - public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type repositoryType) + public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) => RepositorySelector.CreateRepository(repositoryAssembly.EnsureNotNull(), repositoryType.EnsureNotNull()); /// diff --git a/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs new file mode 100644 index 000000000..54cda914c --- /dev/null +++ b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs @@ -0,0 +1,53 @@ +#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 + +// inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs + +#if !NET6_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Specifies the types of members that are dynamically accessed. +/// +/// +/// +/// The trimmer matches this type by its full name rather than by identity, so the values have to +/// keep the numbering the framework uses. Only the members log4net annotates with are declared; +/// add further ones from the runtime source above as they are needed. +/// +/// +[Flags] +internal enum DynamicallyAccessedMemberTypes +{ + /// + /// Specifies no members. + /// + None = 0, + + /// + /// Specifies the default, parameterless public constructor. + /// + PublicParameterlessConstructor = 0x0001, + + /// + /// Specifies all public constructors. + /// + PublicConstructors = 0x0002 | PublicParameterlessConstructor, +} +#endif diff --git a/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs new file mode 100644 index 000000000..421f56ca7 --- /dev/null +++ b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs @@ -0,0 +1,49 @@ +#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 + +// inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs + +#if !NET6_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +/// +/// States which members of a are accessed dynamically, so that a trimmer keeps +/// them instead of removing them as unused. +/// +/// +/// +/// Neither net462 nor netstandard2.0 declares this attribute, but the trimmer +/// recognizes it by full name, so a library can supply its own and still be understood - which is +/// what lets log4net keep working when a consumer publishes with PublishAot or +/// PublishTrimmed. +/// +/// +/// The members that are dynamically accessed. +[AttributeUsage( + AttributeTargets.Field | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter + | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Method, + Inherited = false)] +internal sealed class DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) : Attribute +{ + /// + /// Gets the members that are dynamically accessed. + /// + public DynamicallyAccessedMemberTypes MemberTypes { get; } = memberTypes; +} +#endif diff --git a/src/log4net/Layout/PatternLayout.cs b/src/log4net/Layout/PatternLayout.cs index 29e64746a..d1bf32cd2 100644 --- a/src/log4net/Layout/PatternLayout.cs +++ b/src/log4net/Layout/PatternLayout.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.IO; @@ -820,84 +821,78 @@ public class PatternLayout : LayoutSkeleton /// This static map is overridden by the converterRegistry instance map /// /// - private static readonly Dictionary _sGlobalRulesRegistry = new(StringComparer.Ordinal) + private static readonly Dictionary _sGlobalRulesRegistry = CreateGlobalRulesRegistry(); + + /// + /// Builds the registry of built-in pattern converters. + /// + /// the built-in rules, keyed by the name used in a conversion pattern + /// + /// + /// The registry holds rather than a bare because a + /// put into a collection loses any + /// annotation, + /// which is what left these converters without a constructor once a Native AOT build had trimmed + /// them. carries the annotation, so it survives the round trip. + /// + /// + /// The new() constraint states the same requirement a second time, structurally: a + /// converter that loses its public parameterless constructor becomes a compile error here rather + /// than a run-time failure that only shows up in a trimmed build. + /// + /// + private static Dictionary CreateGlobalRulesRegistry() { - ["literal"] = typeof(LiteralPatternConverter), - ["newline"] = typeof(NewLinePatternConverter), - ["n"] = typeof(NewLinePatternConverter), + Dictionary rules = new(StringComparer.Ordinal); + + Add("literal"); + Add("newline", "n"); // .NET Standard has no support for ASP.NET #if NET462_OR_GREATER - ["aspnet-cache"] = typeof(AspNetCachePatternConverter), - ["aspnet-context"] = typeof(AspNetContextPatternConverter), - ["aspnet-request"] = typeof(AspNetRequestPatternConverter), - ["aspnet-session"] = typeof(AspNetSessionPatternConverter), + Add("aspnet-cache"); + Add("aspnet-context"); + Add("aspnet-request"); + Add("aspnet-session"); #endif - ["c"] = typeof(LoggerPatternConverter), - ["logger"] = typeof(LoggerPatternConverter), - - ["C"] = typeof(TypeNamePatternConverter), - ["class"] = typeof(TypeNamePatternConverter), - ["type"] = typeof(TypeNamePatternConverter), - - ["d"] = typeof(DatePatternConverter), - ["date"] = typeof(DatePatternConverter), - - ["exception"] = typeof(ExceptionPatternConverter), - - ["F"] = typeof(FileLocationPatternConverter), - ["file"] = typeof(FileLocationPatternConverter), - - ["l"] = typeof(FullLocationPatternConverter), - ["location"] = typeof(FullLocationPatternConverter), - - ["L"] = typeof(LineLocationPatternConverter), - ["line"] = typeof(LineLocationPatternConverter), - - ["m"] = typeof(MessagePatternConverter), - ["message"] = typeof(MessagePatternConverter), - - ["M"] = typeof(MethodLocationPatternConverter), - ["method"] = typeof(MethodLocationPatternConverter), - - ["p"] = typeof(LevelPatternConverter), - ["level"] = typeof(LevelPatternConverter), - - ["P"] = typeof(PropertyPatternConverter), - ["property"] = typeof(PropertyPatternConverter), - ["properties"] = typeof(PropertyPatternConverter), - - ["r"] = typeof(RelativeTimePatternConverter), - ["timestamp"] = typeof(RelativeTimePatternConverter), - - ["stacktrace"] = typeof(StackTracePatternConverter), - ["stacktracedetail"] = typeof(StackTraceDetailPatternConverter), - - ["t"] = typeof(ThreadPatternConverter), - ["thread"] = typeof(ThreadPatternConverter), + Add("c", "logger"); + Add("C", "class", "type"); + Add("d", "date"); + Add("exception"); + Add("F", "file"); + Add("l", "location"); + Add("L", "line"); + Add("m", "message"); + Add("M", "method"); + Add("p", "level"); + Add("r", "timestamp"); + Add("stacktrace"); + Add("stacktracedetail"); + Add("t", "thread"); // For backwards compatibility the NDC patterns - ["x"] = typeof(NdcPatternConverter), - ["ndc"] = typeof(NdcPatternConverter), + Add("x", "ndc"); // For backwards compatibility the MDC patterns just do a property lookup - ["X"] = typeof(PropertyPatternConverter), - ["mdc"] = typeof(PropertyPatternConverter), - - ["a"] = typeof(AppDomainPatternConverter), - ["appdomain"] = typeof(AppDomainPatternConverter), + Add("P", "property", "properties", "X", "mdc"); - ["u"] = typeof(IdentityPatternConverter), - ["identity"] = typeof(IdentityPatternConverter), + Add("a", "appdomain"); + Add("u", "identity"); + Add("utcdate", "utcDate", "UtcDate"); + Add("w", "username"); - ["utcdate"] = typeof(UtcDatePatternConverter), - ["utcDate"] = typeof(UtcDatePatternConverter), - ["UtcDate"] = typeof(UtcDatePatternConverter), + return rules; - ["w"] = typeof(UserNamePatternConverter), - ["username"] = typeof(UserNamePatternConverter), - }; + void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>( + params string[] names) where T : PatternConverter, new() + { + foreach (string name in names) + { + rules[name] = new() { Name = name, Type = typeof(T) }; + } + } + } /// /// the head of the pattern converter chain @@ -983,14 +978,9 @@ protected virtual PatternParser CreatePatternParser(string pattern) PatternParser patternParser = new(pattern); // Add all the builtin patterns - foreach (KeyValuePair entry in _sGlobalRulesRegistry) + foreach (KeyValuePair entry in _sGlobalRulesRegistry) { - ConverterInfo converterInfo = new() - { - Name = entry.Key, - Type = entry.Value - }; - patternParser.PatternConverters[entry.Key] = converterInfo; + patternParser.PatternConverters[entry.Key] = entry.Value; } // Add the instance patterns foreach (KeyValuePair entry in _instanceRulesRegistry) @@ -1093,7 +1083,8 @@ public void AddConverter(ConverterInfo converterInfo) /// type. /// /// - public void AddConverter(string name, Type type) => AddConverter(new() + public void AddConverter(string name, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type) => AddConverter(new() { Name = name.EnsureNotNull(), Type = type.EnsureNotNull() diff --git a/src/log4net/LogManager.cs b/src/log4net/LogManager.cs index cc255a540..36e958b86 100644 --- a/src/log4net/LogManager.cs +++ b/src/log4net/LogManager.cs @@ -71,7 +71,8 @@ public static class LogManager /// /// The fully qualified logger name to look for. /// The logger found, or if no logger could be found. - public static ILog? Exists(string name) => Exists(Assembly.GetCallingAssembly(), name); + public static ILog? Exists(string name) + => Exists(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name); /// Get the currently defined loggers. /// @@ -81,7 +82,8 @@ public static class LogManager /// The root logger is not included in the returned array. /// /// All the defined loggers. - public static ILog[] GetCurrentLoggers() => GetCurrentLoggers(Assembly.GetCallingAssembly()); + public static ILog[] GetCurrentLoggers() + => GetCurrentLoggers(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// Get or create a logger. /// @@ -101,7 +103,8 @@ public static class LogManager /// /// The name of the logger to retrieve. /// The logger with the name specified. - public static ILog GetLogger(string name) => GetLogger(Assembly.GetCallingAssembly(), name); + public static ILog GetLogger(string name) + => GetLogger(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name); /// /// Returns the named logger if it exists. @@ -119,7 +122,8 @@ public static class LogManager /// The logger found, or if the logger doesn't exist in the specified /// repository. /// - public static ILog? Exists(string repository, string name) => WrapLogger(LoggerManager.Exists(repository, name)); + public static ILog? Exists(string repository, string name) + => WrapLogger(LoggerManager.Exists(repository, name)); /// /// Returns the named logger if it exists. @@ -137,7 +141,8 @@ public static class LogManager /// The logger, or if the logger doesn't exist in the specified /// assembly's repository. /// - public static ILog? Exists(Assembly repositoryAssembly, string name) => WrapLogger(LoggerManager.Exists(repositoryAssembly, name)); + public static ILog? Exists(Assembly repositoryAssembly, string name) + => WrapLogger(LoggerManager.Exists(repositoryAssembly, name)); /// /// Returns all the currently defined loggers in the specified repository. @@ -147,7 +152,8 @@ public static class LogManager /// The root logger is not included in the returned array. /// /// All the defined loggers. - public static ILog[] GetCurrentLoggers(string repository) => WrapLoggers(LoggerManager.GetCurrentLoggers(repository)); + public static ILog[] GetCurrentLoggers(string repository) + => WrapLoggers(LoggerManager.GetCurrentLoggers(repository)); /// /// Returns all the currently defined loggers in the specified assembly's repository. @@ -157,7 +163,8 @@ public static class LogManager /// The root logger is not included in the returned array. /// /// All the defined loggers. - public static ILog[] GetCurrentLoggers(Assembly repositoryAssembly) => WrapLoggers(LoggerManager.GetCurrentLoggers(repositoryAssembly)); + public static ILog[] GetCurrentLoggers(Assembly repositoryAssembly) + => WrapLoggers(LoggerManager.GetCurrentLoggers(repositoryAssembly)); /// /// Retrieves or creates a named logger. @@ -178,7 +185,8 @@ public static class LogManager /// The repository to lookup in. /// The name of the logger to retrieve. /// The logger with the name specified. - public static ILog GetLogger(string repository, string name) => WrapLogger(LoggerManager.GetLogger(repository, name))!; + public static ILog GetLogger(string repository, string name) + => WrapLogger(LoggerManager.GetLogger(repository, name))!; /// /// Retrieves or creates a named logger. @@ -211,7 +219,10 @@ public static ILog GetLogger(Assembly repositoryAssembly, string name) /// The full name of will be used as the name of the logger to retrieve. /// The logger with the name specified. public static ILog GetLogger(Type type) - => GetLogger(Assembly.GetCallingAssembly(), type.EnsureNotNull().FullName!); + => GetLogger(CallerAssembly.IsSupported + ? Assembly.GetCallingAssembly() + : CallerAssembly.Fallback, + type.EnsureNotNull().FullName!); /// /// Shorthand for . @@ -277,7 +288,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// and again to a nested appender. /// /// - public static void ShutdownRepository() => ShutdownRepository(Assembly.GetCallingAssembly()); + public static void ShutdownRepository() + => ShutdownRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// /// Shuts down the repository for the repository specified. @@ -299,7 +311,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The repository to shut down. - public static void ShutdownRepository(string repository) => LoggerManager.ShutdownRepository(repository); + public static void ShutdownRepository(string repository) + => LoggerManager.ShutdownRepository(repository); /// /// Shuts down the repository specified. @@ -323,7 +336,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The assembly to use to look up the repository. - public static void ShutdownRepository(Assembly repositoryAssembly) => LoggerManager.ShutdownRepository(repositoryAssembly); + public static void ShutdownRepository(Assembly repositoryAssembly) + => LoggerManager.ShutdownRepository(repositoryAssembly); /// Reset the configuration of a repository /// @@ -339,7 +353,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// message disabling is set to its default "off" value. /// /// - public static void ResetConfiguration() => ResetConfiguration(Assembly.GetCallingAssembly()); + public static void ResetConfiguration() + => ResetConfiguration(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// /// Resets all values contained in this repository instance to their defaults. @@ -371,7 +386,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The assembly to use to look up the repository to reset. - public static void ResetConfiguration(Assembly repositoryAssembly) => LoggerManager.ResetConfiguration(repositoryAssembly); + public static void ResetConfiguration(Assembly repositoryAssembly) + => LoggerManager.ResetConfiguration(repositoryAssembly); /// Get a logger repository. /// @@ -384,7 +400,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The instance for the default repository. - public static ILoggerRepository GetRepository() => GetRepository(Assembly.GetCallingAssembly()); + public static ILoggerRepository GetRepository() + => GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// /// Returns the default instance. @@ -410,7 +427,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The assembly to use to look up the repository. - public static ILoggerRepository GetRepository(Assembly repositoryAssembly) => LoggerManager.GetRepository(repositoryAssembly); + public static ILoggerRepository GetRepository(Assembly repositoryAssembly) + => LoggerManager.GetRepository(repositoryAssembly); /// Create a logger repository. /// @@ -427,7 +445,9 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// the same repository instance. /// /// - public static ILoggerRepository CreateRepository(Type repositoryType) => CreateRepository(Assembly.GetCallingAssembly(), repositoryType); + public static ILoggerRepository CreateRepository( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) + => CreateRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, repositoryType); /// /// Creates a repository with the specified name. @@ -445,7 +465,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// The name of the repository, this must be unique amongst repositories. /// The created for the repository. /// The specified repository already exists. - public static ILoggerRepository CreateRepository(string repository) => LoggerManager.CreateRepository(repository); + public static ILoggerRepository CreateRepository(string repository) + => LoggerManager.CreateRepository(repository); /// /// Creates a repository with the specified name and repository type. @@ -462,7 +483,9 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// as the for the repository specified. /// The created for the repository. /// The specified repository already exists. - public static ILoggerRepository CreateRepository(string repository, Type repositoryType) => LoggerManager.CreateRepository(repository, repositoryType); + public static ILoggerRepository CreateRepository(string repository, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) + => LoggerManager.CreateRepository(repository, repositoryType); /// /// Creates a repository for the specified assembly and repository type. @@ -479,7 +502,9 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// and has a no arg constructor. An instance of this type will be created to act /// as the for the repository specified. /// The created for the repository. - public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type repositoryType) => LoggerManager.CreateRepository(repositoryAssembly, repositoryType); + public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) + => LoggerManager.CreateRepository(repositoryAssembly, repositoryType); /// /// Gets the list of currently defined repositories. @@ -499,7 +524,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// if all logging events were flushed successfully, else . public static bool Flush(int millisecondsTimeout) { - if (LoggerManager.GetRepository(Assembly.GetCallingAssembly()) is not IFlushable flushableRepository) + Assembly callerAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; + if (LoggerManager.GetRepository(callerAssembly) is not IFlushable flushableRepository) { return false; } diff --git a/src/log4net/Util/CallerAssembly.cs b/src/log4net/Util/CallerAssembly.cs new file mode 100644 index 000000000..424a5b1d1 --- /dev/null +++ b/src/log4net/Util/CallerAssembly.cs @@ -0,0 +1,74 @@ +#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; +using System.Reflection; + +namespace log4net.Util; + +/// +/// Support for the calls that select the +/// of the caller. +/// +/// +/// +/// Native AOT does not implement - it throws +/// unconditionally, because the stack frames it +/// would have to walk no longer exist after compilation. Callers therefore have to test +/// and use instead. +/// +/// +/// The test cannot be hidden behind a helper that calls +/// itself: the calling assembly of such a helper is log4net, not the assembly that called log4net. +/// has to stay in the public method whose caller is +/// wanted, so this class only supplies the flag and the replacement value. +/// +/// +internal static class CallerAssembly +{ + /// + /// Whether works on the current runtime. + /// + internal static bool IsSupported { get; } = Probe(); + + /// + /// The assembly to attribute a call to when is . + /// + /// + /// + /// The entry assembly is the closest available stand-in: an application published with + /// Native AOT is self-contained, so its loggers would almost always have ended up in the + /// entry assembly's repository anyway. Hosts without a managed entry point fall back to + /// log4net itself, which yields the default repository. + /// + /// + internal static Assembly Fallback { get; } = Assembly.GetEntryAssembly() ?? typeof(CallerAssembly).Assembly; + + private static bool Probe() + { + try + { + return Assembly.GetCallingAssembly() is not null; + } + catch (PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/src/log4net/Util/ConverterInfo.cs b/src/log4net/Util/ConverterInfo.cs index 0a122f6ef..421354545 100644 --- a/src/log4net/Util/ConverterInfo.cs +++ b/src/log4net/Util/ConverterInfo.cs @@ -20,6 +20,7 @@ */ using System; +using System.Diagnostics.CodeAnalysis; namespace log4net.Util; @@ -42,6 +43,7 @@ public sealed class ConverterInfo /// /// Gets or sets the type of the converter. The type must extend . /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public Type? Type { get; set; } /// diff --git a/src/log4net/Util/PatternString.cs b/src/log4net/Util/PatternString.cs index 568b272e6..7978c9566 100644 --- a/src/log4net/Util/PatternString.cs +++ b/src/log4net/Util/PatternString.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using log4net.Util.PatternStringConverters; @@ -258,29 +259,51 @@ public class PatternString : IOptionHandler /// /// Internal map of converter identifiers to converter types. /// - private static readonly Dictionary _sGlobalRulesRegistry = new(StringComparer.Ordinal) + private static readonly Dictionary _sGlobalRulesRegistry = CreateGlobalRulesRegistry(); + + /// + /// Builds the registry of built-in converters. + /// + /// the built-in rules, keyed by the name used in a pattern + /// + /// + /// Holds rather than a bare for the reason given + /// on 's registry: a in a collection loses + /// its trimmer annotation, and these converters are only ever created reflectively. + /// + /// + private static Dictionary CreateGlobalRulesRegistry() { // TODO - have added common variants of casing for utcdate and appsetting. // Wouldn't it be better to use a case-insensitive dictionary? - ["appdomain"] = typeof(AppDomainPatternConverter), - ["appsetting"] = typeof(AppSettingPatternConverter), - ["appSetting"] = typeof(AppSettingPatternConverter), - ["AppSetting"] = typeof(AppSettingPatternConverter), - ["date"] = typeof(DatePatternConverter), - ["env"] = typeof(EnvironmentPatternConverter), - ["envFolderPath"] = typeof(EnvironmentFolderPathPatternConverter), - ["identity"] = typeof(IdentityPatternConverter), - ["literal"] = typeof(LiteralPatternConverter), - ["newline"] = typeof(NewLinePatternConverter), - ["processid"] = typeof(ProcessIdPatternConverter), - ["property"] = typeof(PropertyPatternConverter), - ["random"] = typeof(RandomStringPatternConverter), - ["username"] = typeof(UserNamePatternConverter), - ["utcdate"] = typeof(UtcDatePatternConverter), - ["utcDate"] = typeof(UtcDatePatternConverter), - ["UtcDate"] = typeof(UtcDatePatternConverter), - }; + Dictionary rules = new(StringComparer.Ordinal); + + Add("appdomain"); + Add("appsetting", "appSetting", "AppSetting"); + Add("date"); + Add("env"); + Add("envFolderPath"); + Add("identity"); + Add("literal"); + Add("newline"); + Add("processid"); + Add("property"); + Add("random"); + Add("username"); + Add("utcdate", "utcDate", "UtcDate"); + + return rules; + + void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>( + params string[] names) where T : PatternConverter, new() + { + foreach (string name in names) + { + rules[name] = new() { Name = name, Type = typeof(T) }; + } + } + } /// /// the head of the pattern converter chain @@ -379,14 +402,9 @@ private PatternParser CreatePatternParser(string pattern) PatternParser patternParser = new(pattern); // Add all the builtin patterns - foreach (KeyValuePair entry in _sGlobalRulesRegistry) + foreach (KeyValuePair entry in _sGlobalRulesRegistry) { - ConverterInfo converterInfo = new() - { - Name = entry.Key, - Type = entry.Value - }; - patternParser.PatternConverters.Add(entry.Key, converterInfo); + patternParser.PatternConverters.Add(entry.Key, entry.Value); } // Add the instance patterns foreach (KeyValuePair entry in _instanceRulesRegistry) @@ -461,7 +479,8 @@ public void AddConverter(ConverterInfo converterInfo) /// /// the name of the conversion pattern for this converter /// the type of the converter - public void AddConverter(string name, Type type) + public void AddConverter(string name, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type) { AddConverter(new() { diff --git a/src/log4net/Util/SystemInfo.cs b/src/log4net/Util/SystemInfo.cs index d5b9f9d39..d0c2647c6 100644 --- a/src/log4net/Util/SystemInfo.cs +++ b/src/log4net/Util/SystemInfo.cs @@ -22,6 +22,7 @@ using System.Reflection; using System.IO; using System.Collections; +using System.Runtime.CompilerServices; namespace log4net.Util; @@ -475,7 +476,9 @@ public static string AssemblyFileName(Assembly myAssembly) /// /// public static Type? GetTypeFromString(string typeName, bool throwOnError, bool ignoreCase) - => GetTypeFromString(Assembly.GetCallingAssembly(), typeName, throwOnError, ignoreCase); + => GetTypeFromString(CallerAssembly.IsSupported + ? Assembly.GetCallingAssembly() + : CallerAssembly.Fallback, typeName, throwOnError, ignoreCase); /// /// Loads the type specified in the type string. @@ -680,20 +683,90 @@ public static bool TryParse(string s, out short val) /// the value for the key, or public static string? GetAppSetting(string key) { - if (IsAndroid) - return Environment.GetEnvironmentVariable(key); // Android does not support config files + // Android does not support config files, and neither does a runtime that has trimmed the + // configuration system away. + if (IsAndroid || _configurationSystemUnavailable) + return Environment.GetEnvironmentVariable(key); try { - return ConfigurationManager.AppSettings[key]; + return ReadAppSetting(key); } catch (Exception e) when (!e.IsFatal()) { - // If an exception is thrown here then it looks like the config file does not parse correctly. + if (IsMissingConfigurationSystem(e)) + { + // There is no configuration system to read - Native AOT trims System.Configuration away. + // That is a property of the runtime rather than a fault, so it is not reported as an + // error, and the environment stands in for the config file as it does on Android. + _configurationSystemUnavailable = true; + LogLog.Debug(_declaringType, + "No configuration system on this runtime. Using environment variables for application settings.", e); + return Environment.GetEnvironmentVariable(key); + } + + // The config file itself does not parse. Report it and treat the setting as absent, without + // falling back to the environment - a broken config file must not silently change where + // settings come from. LogLog.Error(_declaringType, "Exception while reading ConfigurationSettings. Check your .config file is well formed XML.", e); } return null; } + /// + /// Determines whether means that there is no configuration system + /// on this runtime, as opposed to a configuration file that does not parse. + /// + /// the exception thrown while reading an application setting + /// if the configuration system itself is unavailable + /// + /// + /// The inner exceptions have to be walked, because Native AOT surfaces this as a + /// - the very type a malformed file produces. What + /// distinguishes it is further down the chain: a for + /// ClientConfigurationHost, whose constructor the trimmer removed. + /// + /// + /// An unrecognized failure is treated as a configuration file problem, which is the safer way + /// round: it is reported rather than silently swallowed. + /// + /// + private static bool IsMissingConfigurationSystem(Exception? exception) + { + for (; exception is not null; exception = exception.InnerException) + { + if (exception is MissingMethodException or TypeLoadException or FileNotFoundException + or PlatformNotSupportedException or NotSupportedException) + { + return true; + } + } + return false; + } + + /// + /// Reads a single application setting. + /// + /// the application settings key to lookup + /// the value for the key, or + /// + /// + /// Separate from , and never inlined into it, so that the failure to + /// resolve itself is raised on entry to this method - inside + /// the caller's try block - rather than on entry to , where nothing + /// would catch it and a would escape the static constructor + /// as a . + /// + /// + /// The package declares a dependency on System.Configuration.ConfigurationManager, so this only + /// arises where the assembly is deployed by other means than the package - it costs one method + /// to keep those deployments running instead of failing at type initialization. + /// + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static string? ReadAppSetting(string key) => ConfigurationManager.AppSettings[key]; + + private static bool _configurationSystemUnavailable; + /// /// Convert a path into a fully qualified local file path. /// diff --git a/src/log4net/Util/TypeConverters/ConverterRegistry.cs b/src/log4net/Util/TypeConverters/ConverterRegistry.cs index 03259584a..af8518966 100644 --- a/src/log4net/Util/TypeConverters/ConverterRegistry.cs +++ b/src/log4net/Util/TypeConverters/ConverterRegistry.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Concurrent; namespace log4net.Util.TypeConverters; @@ -82,7 +83,8 @@ public static void AddConverter(Type? destinationType, object? converter) /// /// The type being converted to. /// The type of the type converter to use to convert to the destination type. - public static void AddConverter(Type destinationType, Type converterType) + public static void AddConverter(Type destinationType, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type converterType) => AddConverter(destinationType, CreateConverterInstance(converterType.EnsureNotNull())); /// @@ -187,7 +189,8 @@ public static void AddConverter(Type destinationType, Type converterType) /// and must have a public default (no argument) constructor. /// /// - private static object? CreateConverterInstance(Type converterType) + private static object? CreateConverterInstance( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type converterType) { // Check type is a converter if (typeof(IConvertFrom).IsAssignableFrom(converterType) || typeof(IConvertTo).IsAssignableFrom(converterType)) diff --git a/src/site/antora/modules/ROOT/nav.adoc b/src/site/antora/modules/ROOT/nav.adoc index bf81497b5..8aadc37df 100644 --- a/src/site/antora/modules/ROOT/nav.adoc +++ b/src/site/antora/modules/ROOT/nav.adoc @@ -42,6 +42,7 @@ **** xref:manual/configuration/appenders/udpappender.adoc[] *** xref:manual/configuration/filters.adoc[] *** xref:manual/configuration/layouts.adoc[] +** xref:manual/native-aot.adoc[] ** xref:manual/examples.adoc[] ** xref:manual/faq.adoc[] * xref:features.adoc[] diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration.adoc index 6d300ea6e..4619fd13d 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration.adoc @@ -21,6 +21,13 @@ The recommended way to configure log4net is through a configuration file. This section explains the structure of a configuration file and how log4net processes it. +[NOTE] +==== +Configuration files cannot be used in an application published with `PublishAot`, because the types +they name are removed by the trimmer. +See xref:manual/native-aot.adoc[] for how to configure log4net in code instead. +==== + [source,csharp] ---- using Animals.Carnivora; diff --git a/src/site/antora/modules/ROOT/pages/manual/native-aot.adoc b/src/site/antora/modules/ROOT/pages/manual/native-aot.adoc new file mode 100644 index 000000000..ff101412f --- /dev/null +++ b/src/site/antora/modules/ROOT/pages/manual/native-aot.adoc @@ -0,0 +1,196 @@ +//// + 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. +//// + +[#native-aot] += Native AOT and trimming + +log4net can be used from an application published with `PublishAot` or `PublishTrimmed`, with one +important restriction: **you have to configure log4net in code.** + +A Native AOT application is compiled ahead of time and trimmed, so any type that is only ever named +in a string is removed from the build. That is exactly how XML configuration works, which is why it +cannot be supported. + +[#configuring] +== Configuring in code + +Build the appenders and layouts yourself and hand them to +xref:manual/configuration.adoc[`BasicConfigurator`]. +Because you construct them with `new`, the compiler sees them and keeps them: + +[source,csharp] +---- +using log4net; +using log4net.Appender; +using log4net.Config; +using log4net.Core; +using log4net.Layout; + +ConsoleAppender appender = new() +{ + Layout = new PatternLayout("%level %logger - %message%newline"), + Threshold = Level.All, +}; +appender.ActivateOptions(); +BasicConfigurator.Configure(appender); + +ILog log = LogManager.GetLogger(typeof(Program)); +log.Info("Hello from Native AOT."); +---- + +Conversion patterns work as usual. The built-in pattern converters are resolved by name at run time, +but log4net declares them in a way the trimmer understands, so they are preserved for you. + +A custom converter is preserved as long as you register it by type: + +[source,csharp] +---- +PatternLayout layout = new(); +layout.AddConverter("mine", typeof(MyPatternConverter)); +layout.ConversionPattern = "%mine %message%newline"; +layout.ActivateOptions(); +---- + +[#levels-from-a-file] +== Reading levels from a configuration file + +log4net cannot read a configuration file under Native AOT, but *your application can*, and levels +can be set at any time through the API. That covers the common case of wanting to change verbosity +without rebuilding, without needing XML configuration. + +Put the levels wherever your application already keeps its settings: + +[source,json] +---- +{ + "Logging": { + "Default": "WARN", + "Loggers": { + "Noisy.Component": "ERROR", + "Important.Component": "DEBUG" + } + } +} +---- + +Read them yourself and apply them to the repository: + +[source,csharp] +---- +using System.Text.Json; +using log4net.Core; +using log4net.Repository.Hierarchy; + +Hierarchy hierarchy = (Hierarchy)LogManager.GetRepository(); + +using JsonDocument document = JsonDocument.Parse( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))); +JsonElement logging = document.RootElement.GetProperty("Logging"); + +// the level of the root logger, inherited by every logger that has none of its own +if (hierarchy.LevelMap[logging.GetProperty("Default").GetString()!] is Level rootLevel) +{ + hierarchy.Root.Level = rootLevel; +} + +// and levels for individual loggers +foreach (JsonProperty entry in logging.GetProperty("Loggers").EnumerateObject()) +{ + if (hierarchy.LevelMap[entry.Value.GetString()!] is Level loggerLevel) + { + ((Logger)hierarchy.GetLogger(entry.Name)).Level = loggerLevel; + } +} +---- + +With the settings above, `Important.Component` logs from `DEBUG` upwards, `Noisy.Component` only +`ERROR` and above, and every other logger inherits `WARN` from the root. + +[TIP] +==== +`LevelMap` returns `null` for a name it does not know, which is why both lookups are written as +`is Level`. An unrecognised name in the file then leaves the level unchanged rather than throwing. +Custom levels registered with `hierarchy.LevelMap.Add(...)` can be named in the file too. +==== + +[NOTE] +==== +`JsonDocument` is used here because it parses without reflection and is safe to trim. +`JsonSerializer.Deserialize()` is not, unless you generate a `JsonSerializerContext` for your +settings type. Any other format your application can already read works just as well - the point is +only that *your* code reads the file, not log4net's. +==== + +Levels can be changed whenever you like, so the same code can be run again to reload the file while +the application is running. + +[#unsupported] +== What does not work + +[IMPORTANT] +==== +xref:manual/configuration.adoc[XML configuration] - `XmlConfigurator.Configure()`, the +`log4net.config` file and the `` section of `app.config` - is **not available** under +Native AOT. Appender, layout and filter types are named as strings there, and the trimmer has no way +to know that they are needed. + +This is about *log4net* reading the file. Your application can still read a file of its own and +apply what it finds - see <>. +==== + +`ConfigurationManager` cannot initialize either, so log4net's own `appSettings` keys are read from +**environment variables** instead. To set them, use the same names you would have used in +`app.config`: + +[source,shell] +---- +log4net.NullText=NULL +log4net.NotAvailableText=N/A +---- + +[#repositories] +== Loggers and repositories + +`Assembly.GetCallingAssembly()` is not implemented by Native AOT. The log4net methods that infer a +repository from their caller - `LogManager.GetLogger(string)`, `LogManager.GetLogger(Type)`, +`LogManager.GetRepository()` and their siblings - therefore fall back to the entry assembly. + +For most applications this changes nothing, because there is a single default repository and both +answers lead to it. It matters only if you use **per-assembly repositories**, for example by placing + +[source,csharp] +---- +[assembly: log4net.Config.Repository("MyRepository")] +---- + +on a library. Under Native AOT that library's loggers are placed in the entry assembly's repository +rather than its own. + +If you depend on this, use the overloads that take the assembly explicitly. They are exact on every +runtime and need no special handling: + +[source,csharp] +---- +ILog log = LogManager.GetLogger(typeof(MyType).Assembly, typeof(MyType)); +---- + +[#warnings] +== Trimming warnings + +Publishing may report `IL3000` for `log4net.Util.SystemInfo.AssemblyLocationInfo`, because +`Assembly.Location` returns an empty string for an assembly embedded in a single-file application. +This affects the `%file`-style location information only; logging itself is unaffected. From f78341fcf5c15b20d0892d4a2aacb3242ad06a05 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Sun, 16 Aug 2026 21:06:44 +0200 Subject: [PATCH 2/5] Copy the built-in ConverterInfo entries per parser instead of sharing them, make _configurationSystemUnavailable volatile, and fix the IsAndoid test name typo. --- src/log4net.Tests/Util/SystemInfoTest.cs | 4 ++-- src/log4net/Layout/PatternLayout.cs | 11 +++++++++-- src/log4net/Util/PatternString.cs | 8 ++++++-- src/log4net/Util/SystemInfo.cs | 2 +- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/log4net.Tests/Util/SystemInfoTest.cs b/src/log4net.Tests/Util/SystemInfoTest.cs index c24914ff1..8179a7d3d 100644 --- a/src/log4net.Tests/Util/SystemInfoTest.cs +++ b/src/log4net.Tests/Util/SystemInfoTest.cs @@ -171,7 +171,7 @@ public void EqualsIgnoringCase_DifferentStrings_false() [Test] [Platform(Include = "Win,Linux,MacOsX")] - public void IsAndoid() + public void IsAndroid() => Assert.That(typeof(SystemInfo).GetProperty("IsAndroid", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null), Is.False); /// @@ -182,7 +182,7 @@ public void IsAndoid() /// /// /// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped - /// directly, the same way reaches a non-public member. The environment + /// directly, the same way reaches a non-public member. The environment /// must stay untouched while the configuration system still works, otherwise a malformed /// app.config would silently change where every setting comes from. /// diff --git a/src/log4net/Layout/PatternLayout.cs b/src/log4net/Layout/PatternLayout.cs index d1bf32cd2..1fee419a3 100644 --- a/src/log4net/Layout/PatternLayout.cs +++ b/src/log4net/Layout/PatternLayout.cs @@ -977,10 +977,17 @@ protected virtual PatternParser CreatePatternParser(string pattern) { PatternParser patternParser = new(pattern); - // Add all the builtin patterns + // Add all the builtin patterns. The registry entries are copied rather than handed out: + // PatternConverters is public, and PatternParser assigns ConverterInfo.Properties to every + // converter it creates, so sharing one instance would let a single layout mutate state that + // every other layout in the process can see. foreach (KeyValuePair entry in _sGlobalRulesRegistry) { - patternParser.PatternConverters[entry.Key] = entry.Value; + patternParser.PatternConverters[entry.Key] = new ConverterInfo + { + Name = entry.Value.Name, + Type = entry.Value.Type + }; } // Add the instance patterns foreach (KeyValuePair entry in _instanceRulesRegistry) diff --git a/src/log4net/Util/PatternString.cs b/src/log4net/Util/PatternString.cs index 7978c9566..e4e3e1270 100644 --- a/src/log4net/Util/PatternString.cs +++ b/src/log4net/Util/PatternString.cs @@ -401,10 +401,14 @@ private PatternParser CreatePatternParser(string pattern) { PatternParser patternParser = new(pattern); - // Add all the builtin patterns + // Add all the builtin patterns - copied, not shared; see PatternLayout.CreatePatternParser foreach (KeyValuePair entry in _sGlobalRulesRegistry) { - patternParser.PatternConverters.Add(entry.Key, entry.Value); + patternParser.PatternConverters.Add(entry.Key, new ConverterInfo + { + Name = entry.Value.Name, + Type = entry.Value.Type + }); } // Add the instance patterns foreach (KeyValuePair entry in _instanceRulesRegistry) diff --git a/src/log4net/Util/SystemInfo.cs b/src/log4net/Util/SystemInfo.cs index d0c2647c6..500aa2463 100644 --- a/src/log4net/Util/SystemInfo.cs +++ b/src/log4net/Util/SystemInfo.cs @@ -765,7 +765,7 @@ private static bool IsMissingConfigurationSystem(Exception? exception) [MethodImpl(MethodImplOptions.NoInlining)] private static string? ReadAppSetting(string key) => ConfigurationManager.AppSettings[key]; - private static bool _configurationSystemUnavailable; + private static volatile bool _configurationSystemUnavailable; /// /// Convert a path into a fully qualified local file path. From ae3ee38586af5305de658d0b13c28f225ad9f912 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Sun, 16 Aug 2026 21:15:41 +0200 Subject: [PATCH 3/5] narrow the missing configuration system check (#306) Ask the runtime instead of guessing from the exception: Native AOT is identified by CallerAssembly.IsSupported, and elsewhere only a failure naming System.Configuration itself counts, so a broken app.config is still reported. --- src/log4net.Tests/Util/SystemInfoTest.cs | 25 ++++++++++++++----- src/log4net/Util/SystemInfo.cs | 31 ++++++++++++++++++------ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/log4net.Tests/Util/SystemInfoTest.cs b/src/log4net.Tests/Util/SystemInfoTest.cs index 8179a7d3d..fbf544bdc 100644 --- a/src/log4net.Tests/Util/SystemInfoTest.cs +++ b/src/log4net.Tests/Util/SystemInfoTest.cs @@ -244,22 +244,35 @@ public void MalformedConfigurationIsNotTreatedAsAMissingConfigurationSystem() /// /// Native AOT surfaces a trimmed configuration system as a , - /// the same type a malformed file produces, so only the inner exception tells them apart. + /// the same type a malformed file produces. The two are not told apart by guessing at the inner + /// exception: on a runtime that has a configuration system at all, this is reported as an error. /// [Test] - public void TrimmedConfigurationSystemIsRecognisedThroughTheInnerException() + public void TrimmedHostExceptionIsNotGuessedAtOnAJitRuntime() => Assert.That(IsMissingConfigurationSystem( new ConfigurationErrorsException("Configuration system failed to initialize", new MissingMethodException("No parameterless constructor defined for type 'System.Configuration.ClientConfigurationHost'."))), - Is.True); + Is.False); /// - /// A deployment without the System.Configuration.ConfigurationManager assembly fails on the - /// outermost exception rather than an inner one. + /// A deployment without the System.Configuration.ConfigurationManager assembly is recognised by + /// the name of the assembly that could not be loaded. /// [Test] public void MissingConfigurationAssemblyIsRecognised() - => Assert.That(IsMissingConfigurationSystem(new FileNotFoundException("System.Configuration.ConfigurationManager")), Is.True); + => Assert.That(IsMissingConfigurationSystem( + new FileNotFoundException("Could not load file or assembly", "System.Configuration.ConfigurationManager")), Is.True); + + /// + /// The application's own missing assembly is its problem, not evidence that the configuration + /// system is gone, so it must keep being reported rather than silently redirecting every setting + /// to the environment. + /// + [Test] + public void MissingApplicationAssemblyIsNotTreatedAsAMissingConfigurationSystem() + => Assert.That(IsMissingConfigurationSystem( + new ConfigurationErrorsException("An error occurred creating the configuration section handler", + new FileNotFoundException("Could not load file or assembly", "Contoso.SectionHandlers"))), Is.False); private static bool IsMissingConfigurationSystem(Exception exception) { diff --git a/src/log4net/Util/SystemInfo.cs b/src/log4net/Util/SystemInfo.cs index 500aa2463..823c9f047 100644 --- a/src/log4net/Util/SystemInfo.cs +++ b/src/log4net/Util/SystemInfo.cs @@ -62,18 +62,18 @@ static SystemInfo() // Look for log4net.NullText in AppSettings string? nullTextAppSettingsKey = GetAppSetting("log4net.NullText"); - if (nullTextAppSettingsKey is not null && nullTextAppSettingsKey.Length > 0) + if (!string.IsNullOrEmpty(nullTextAppSettingsKey)) { LogLog.Debug(_declaringType, $"Initializing NullText value to [{nullTextAppSettingsKey}]."); - nullText = nullTextAppSettingsKey; + nullText = nullTextAppSettingsKey!; } // Look for log4net.NotAvailableText in AppSettings string? notAvailableTextAppSettingsKey = GetAppSetting("log4net.NotAvailableText"); - if (notAvailableTextAppSettingsKey is not null && notAvailableTextAppSettingsKey.Length > 0) + if (!string.IsNullOrEmpty(notAvailableTextAppSettingsKey)) { LogLog.Debug(_declaringType, $"Initializing NotAvailableText value to [{notAvailableTextAppSettingsKey}]."); - notAvailableText = notAvailableTextAppSettingsKey; + notAvailableText = notAvailableTextAppSettingsKey!; } NotAvailableText = notAvailableText; NullText = nullText; @@ -732,17 +732,34 @@ public static bool TryParse(string s, out short val) /// private static bool IsMissingConfigurationSystem(Exception? exception) { + // Native AOT is what this exists for, and it identifies itself without any guesswork: + // GetCallingAssembly is unsupported there for the same reason the configuration system cannot + // initialize, so no configuration file can be read whatever the exception happens to be. + if (!CallerAssembly.IsSupported) + { + return true; + } + + // Anywhere else, only a failure that names System.Configuration itself counts. A failure that + // names anything else belongs to the application's own configuration and has to keep being + // reported as an error rather than silently redirecting every setting to the environment. for (; exception is not null; exception = exception.InnerException) { - if (exception is MissingMethodException or TypeLoadException or FileNotFoundException - or PlatformNotSupportedException or NotSupportedException) + switch (exception) { - return true; + case FileNotFoundException { FileName: string fileName } + when IsConfigurationSystem(fileName): + case TypeLoadException { TypeName: string typeName } + when IsConfigurationSystem(typeName): + return true; } } return false; } + private static bool IsConfigurationSystem(string name) + => name.StartsWith("System.Configuration", StringComparison.Ordinal); + /// /// Reads a single application setting. /// From 364e0ee4823ef83264598ce40d866aa24caacf62 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Sun, 16 Aug 2026 23:18:12 +0200 Subject: [PATCH 4/5] enforce IDE0005 on build and remove unnecessary usings (#306) Set EnforceCodeStyleInBuild and GenerateDocumentationFile in Directory.Build.props, suppress CS1591 for test and integration projects, and clear the 15 usings this surfaced. --- .editorconfig | 3 +++ src/Directory.Build.props | 8 ++++++++ .../log4net-611-lib/DerivedAppender.cs | 1 - src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs | 1 - src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs | 1 - src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj | 4 ++-- src/log4net.Tests/Appender/AppenderSkeletonTest.cs | 2 +- src/log4net.Tests/Core/LoggingEventTest.cs | 6 ++++-- src/log4net.Tests/Core/ShutdownTest.cs | 3 ++- .../Hierarchy/XmlHierarchyConfiguratorTest.cs | 1 - src/log4net.Tests/log4net.Tests.csproj | 4 ++-- src/log4net/Appender/EventLogAppender.cs | 1 - src/log4net/Core/LoggingEvent.cs | 2 ++ src/log4net/Filter/LevelMatchFilter.cs | 2 -- src/log4net/Filter/LevelRangeFilter.cs | 2 -- src/log4net/Filter/PropertyFilter.cs | 2 -- src/log4net/Filter/StringMatchFilter.cs | 1 - src/log4net/Layout/ExceptionLayout.cs | 1 - src/log4net/Layout/SimpleLayout.cs | 1 - src/log4net/Layout/XmlLayoutBase.cs | 1 - .../PatternStringConverters/UserNamePatternConverter.cs | 2 ++ src/log4net/Util/WindowsSecurityContext.cs | 1 - 22 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.editorconfig b/.editorconfig index e50b5f2f1..35c9c6ac7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -206,6 +206,9 @@ dotnet_diagnostic.KR1037.severity = none dotnet_diagnostic.NUnit2045.severity = none # IDE0079: Remove unnecessary suppressions dotnet_diagnostic.IDE0079.severity = none +# IDE0005: Remove unnecessary usings. Shown in the IDE only: enforcing it on build would also need +# EnforceCodeStyleInBuild and GenerateDocumentationFile, which the test projects do not set. +dotnet_diagnostic.IDE0005.severity = warning [*.xml] tab_width = 2 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8c4ab3ac8..3f5b5e36b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -9,10 +9,18 @@ 8 true true + + true + true <_SkipUpgradeNetAnalyzersNuGetWarning>true true en;en-US + + + $(NoWarn);CS1591 + 3.4.0 3.3.2 diff --git a/src/integration-testing/log4net-611-lib/DerivedAppender.cs b/src/integration-testing/log4net-611-lib/DerivedAppender.cs index 57e3d4c6d..8e48af8a2 100644 --- a/src/integration-testing/log4net-611-lib/DerivedAppender.cs +++ b/src/integration-testing/log4net-611-lib/DerivedAppender.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using log4net.Appender; using log4net.Core; diff --git a/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs index 7b268952b..47ba1e5f7 100644 --- a/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs +++ b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs @@ -23,7 +23,6 @@ using System.Net; using System.Text; -using log4net.Ext.Mail.Appender; using log4net.Ext.Mail.Appender.Internal; using MailKit.Security; diff --git a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs index 5a7117c06..bb23df02a 100644 --- a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs +++ b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs @@ -24,7 +24,6 @@ using System.Text; using log4net.Core; using log4net.Ext.Mail.Appender; -using log4net.Ext.Mail.Appender.Internal; using log4net.Layout; using MailKit.Security; using MimeKit; diff --git a/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj b/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj index 6507ad71b..2165e083c 100644 --- a/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj +++ b/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj @@ -2,7 +2,7 @@ true net10.0 - NETSDK1138;CS1701 + $(NoWarn);NETSDK1138;CS1701 Library bin\$(Configuration) Debug;Release @@ -11,7 +11,7 @@ false TRACE;DEBUG;$(DefineConstants) - CS8032 + $(NoWarn);CS8032 quackers diff --git a/src/log4net.Tests/Appender/AppenderSkeletonTest.cs b/src/log4net.Tests/Appender/AppenderSkeletonTest.cs index ca41386a0..7b967eac7 100644 --- a/src/log4net.Tests/Appender/AppenderSkeletonTest.cs +++ b/src/log4net.Tests/Appender/AppenderSkeletonTest.cs @@ -49,7 +49,7 @@ public void AddFilter_FirstFilter_SetsFilterHead() } /// - /// Verifies that a second call appends the filter via . + /// Verifies that a second call appends the filter via IFilter.Next. /// [Test] public void AddFilter_SecondFilter_LinksToChain() diff --git a/src/log4net.Tests/Core/LoggingEventTest.cs b/src/log4net.Tests/Core/LoggingEventTest.cs index dfdb33aff..fd11b6083 100644 --- a/src/log4net.Tests/Core/LoggingEventTest.cs +++ b/src/log4net.Tests/Core/LoggingEventTest.cs @@ -19,9 +19,11 @@ using System; using System.Globalization; -using System.IO; using System.Reflection; +#if NET462_OR_GREATER +using System.IO; using System.Runtime.Serialization.Formatters.Binary; +#endif using log4net.Core; using log4net.Util; using NUnit.Framework; @@ -119,7 +121,7 @@ public void DeserializeV2() #endif // NET462_OR_GREATER /// - /// Tests + /// Tests LoggingEvent.ReviseThreadName, which is not publicly visible. /// [Test] public void ReviseThreadNameTest() diff --git a/src/log4net.Tests/Core/ShutdownTest.cs b/src/log4net.Tests/Core/ShutdownTest.cs index 6161e67f5..611247423 100644 --- a/src/log4net.Tests/Core/ShutdownTest.cs +++ b/src/log4net.Tests/Core/ShutdownTest.cs @@ -29,7 +29,8 @@ namespace log4net.Tests.Core; /// -/// +/// Tests shutting log4net down. +/// [TestFixture] public class ShutdownTest { diff --git a/src/log4net.Tests/Hierarchy/XmlHierarchyConfiguratorTest.cs b/src/log4net.Tests/Hierarchy/XmlHierarchyConfiguratorTest.cs index 6fdb5d7da..13b31f29d 100644 --- a/src/log4net.Tests/Hierarchy/XmlHierarchyConfiguratorTest.cs +++ b/src/log4net.Tests/Hierarchy/XmlHierarchyConfiguratorTest.cs @@ -23,7 +23,6 @@ using NUnit.Framework; using log4net.Repository.Hierarchy; -using HierarchyClass = log4net.Repository.Hierarchy.Hierarchy; namespace log4net.Tests.Hierarchy; diff --git a/src/log4net.Tests/log4net.Tests.csproj b/src/log4net.Tests/log4net.Tests.csproj index f9668c7a3..38b006386 100644 --- a/src/log4net.Tests/log4net.Tests.csproj +++ b/src/log4net.Tests/log4net.Tests.csproj @@ -6,7 +6,7 @@ TestHostNetFramework/testhost.exe, which is not part of the Linux/macOS SDK. Building them elsewhere only produces assemblies that cannot be run, so skip the target. --> net10.0 - NETSDK1138;CS1701 + $(NoWarn);NETSDK1138;CS1701 Library bin\$(Configuration) Debug;Release @@ -15,7 +15,7 @@ false TRACE;DEBUG;$(DefineConstants) - CS8032 + $(NoWarn);CS8032 quackers diff --git a/src/log4net/Appender/EventLogAppender.cs b/src/log4net/Appender/EventLogAppender.cs index a8e66c04e..48844cd35 100644 --- a/src/log4net/Appender/EventLogAppender.cs +++ b/src/log4net/Appender/EventLogAppender.cs @@ -23,7 +23,6 @@ using System.Diagnostics; using log4net.Util; -using log4net.Layout; using log4net.Core; namespace log4net.Appender; diff --git a/src/log4net/Core/LoggingEvent.cs b/src/log4net/Core/LoggingEvent.cs index 42aae5696..016473fa2 100644 --- a/src/log4net/Core/LoggingEvent.cs +++ b/src/log4net/Core/LoggingEvent.cs @@ -22,7 +22,9 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +#if NET471_OR_GREATER || NETSTANDARD2_0_OR_GREATER using System.Runtime.InteropServices; +#endif using System.Runtime.Serialization; using System.Security; using System.Security.Principal; diff --git a/src/log4net/Filter/LevelMatchFilter.cs b/src/log4net/Filter/LevelMatchFilter.cs index 80b3eb0bc..49d40ade0 100644 --- a/src/log4net/Filter/LevelMatchFilter.cs +++ b/src/log4net/Filter/LevelMatchFilter.cs @@ -17,8 +17,6 @@ // #endregion -using System; - using log4net.Core; using log4net.Util; diff --git a/src/log4net/Filter/LevelRangeFilter.cs b/src/log4net/Filter/LevelRangeFilter.cs index dc3104a37..bfff986c9 100644 --- a/src/log4net/Filter/LevelRangeFilter.cs +++ b/src/log4net/Filter/LevelRangeFilter.cs @@ -17,8 +17,6 @@ // #endregion -using System; - using log4net.Core; using log4net.Util; diff --git a/src/log4net/Filter/PropertyFilter.cs b/src/log4net/Filter/PropertyFilter.cs index c872b503e..cef49c75f 100644 --- a/src/log4net/Filter/PropertyFilter.cs +++ b/src/log4net/Filter/PropertyFilter.cs @@ -17,8 +17,6 @@ // #endregion -using System; - using log4net.Core; using log4net.Util; diff --git a/src/log4net/Filter/StringMatchFilter.cs b/src/log4net/Filter/StringMatchFilter.cs index 8458fc7ec..e7819913b 100644 --- a/src/log4net/Filter/StringMatchFilter.cs +++ b/src/log4net/Filter/StringMatchFilter.cs @@ -17,7 +17,6 @@ // #endregion -using System; using System.Text.RegularExpressions; using log4net.Core; diff --git a/src/log4net/Layout/ExceptionLayout.cs b/src/log4net/Layout/ExceptionLayout.cs index af0f40196..0121fe7c8 100644 --- a/src/log4net/Layout/ExceptionLayout.cs +++ b/src/log4net/Layout/ExceptionLayout.cs @@ -17,7 +17,6 @@ // #endregion -using System; using System.IO; using log4net.Core; diff --git a/src/log4net/Layout/SimpleLayout.cs b/src/log4net/Layout/SimpleLayout.cs index c25e3a9c4..8722046e8 100644 --- a/src/log4net/Layout/SimpleLayout.cs +++ b/src/log4net/Layout/SimpleLayout.cs @@ -17,7 +17,6 @@ // #endregion -using System; using System.IO; using log4net.Core; diff --git a/src/log4net/Layout/XmlLayoutBase.cs b/src/log4net/Layout/XmlLayoutBase.cs index 3c63b5ae9..c51e42754 100644 --- a/src/log4net/Layout/XmlLayoutBase.cs +++ b/src/log4net/Layout/XmlLayoutBase.cs @@ -17,7 +17,6 @@ // #endregion -using System; using System.IO; using System.Xml; diff --git a/src/log4net/Util/PatternStringConverters/UserNamePatternConverter.cs b/src/log4net/Util/PatternStringConverters/UserNamePatternConverter.cs index 7e7fab529..f53782dd5 100644 --- a/src/log4net/Util/PatternStringConverters/UserNamePatternConverter.cs +++ b/src/log4net/Util/PatternStringConverters/UserNamePatternConverter.cs @@ -20,7 +20,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +#if !NET462_OR_GREATER using System.Runtime.InteropServices; +#endif namespace log4net.Util.PatternStringConverters; diff --git a/src/log4net/Util/WindowsSecurityContext.cs b/src/log4net/Util/WindowsSecurityContext.cs index 3eee0b87b..ce83bf912 100644 --- a/src/log4net/Util/WindowsSecurityContext.cs +++ b/src/log4net/Util/WindowsSecurityContext.cs @@ -19,7 +19,6 @@ #if NET462_OR_GREATER using System; -using System.Runtime.InteropServices; using System.Security; using System.Security.Principal; From 3e23ae9eac63b71ddd109af2f03553c8b5938b7f Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Sun, 16 Aug 2026 23:21:12 +0200 Subject: [PATCH 5/5] test what works under Native AOT (#306) Add log4net.Tests.Aot, which runs the same probes JIT compiled and published with PublishAot and fails when either run differs from the expected list, and run it from CI. Silence log4net's internal messages while probing, because the probes report their own failures and a passing run that prints errors reads as a broken one. --- .github/workflows/build.yaml | 18 +- .gitignore | 3 + src/log4net.Tests.Aot/App.config | 22 ++ src/log4net.Tests.Aot/Probe.cs | 36 +++ src/log4net.Tests.Aot/Probes.cs | 245 ++++++++++++++++++ src/log4net.Tests.Aot/Program.cs | 147 +++++++++++ .../log4net.Tests.Aot.csproj | 24 ++ .../Util/AotCompatibilityTest.cs | 169 ++++++++++++ src/log4net.sln | 6 + .../UnconditionalSuppressMessageAttribute.cs | 71 +++++ src/log4net/Util/SystemInfo.cs | 30 ++- 11 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 src/log4net.Tests.Aot/App.config create mode 100644 src/log4net.Tests.Aot/Probe.cs create mode 100644 src/log4net.Tests.Aot/Probes.cs create mode 100644 src/log4net.Tests.Aot/Program.cs create mode 100644 src/log4net.Tests.Aot/log4net.Tests.Aot.csproj create mode 100644 src/log4net.Tests/Util/AotCompatibilityTest.cs create mode 100644 src/log4net/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 564337a35..0bd4d696d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -56,4 +56,20 @@ jobs: - name: Test run: | - dotnet test ./src/log4net.sln \ No newline at end of file + dotnet test ./src/log4net.sln + + # Runs the same probes twice, JIT compiled and published with Native AOT, so that a + # difference between the two is caused by AOT rather than by the platform. The project + # knows which probes are expected to fail in each mode and exits non-zero when reality + # does not match, in either direction. + - name: AOT probes + shell: pwsh + env: + "log4net.AotEnvironmentProbe": from-environment + run: | + dotnet run --project ./src/log4net.Tests.Aot/log4net.Tests.Aot.csproj -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet publish ./src/log4net.Tests.Aot/log4net.Tests.Aot.csproj -c Release -o ./aot-probes + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "./aot-probes/log4net.Tests.Aot$($IsWindows ? '.exe' : '')" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3afa2a786..f1035f39d 100644 --- a/.gitignore +++ b/.gitignore @@ -254,3 +254,6 @@ validate /graphify-out/.graphify_python /graphify-out/*.sig /graphify-out/20* + +# output of the AOT probe step +/aot-probes/ diff --git a/src/log4net.Tests.Aot/App.config b/src/log4net.Tests.Aot/App.config new file mode 100644 index 000000000..9fc4067c6 --- /dev/null +++ b/src/log4net.Tests.Aot/App.config @@ -0,0 +1,22 @@ + + + + + + + diff --git a/src/log4net.Tests.Aot/Probe.cs b/src/log4net.Tests.Aot/Probe.cs new file mode 100644 index 000000000..cb380545d --- /dev/null +++ b/src/log4net.Tests.Aot/Probe.cs @@ -0,0 +1,36 @@ +#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; + +namespace log4net.Tests.Aot; + +/// +/// One named piece of log4net surface to exercise. +/// +/// the grouping the probe belongs to +/// what the probe exercises +/// the probe itself, which throws to signal failure +internal sealed record Probe(string Area, string Name, Action Run) +{ + /// + /// Identifies the probe in the expected failure lists. + /// + internal string Key => $"{Area}/{Name}"; +} diff --git a/src/log4net.Tests.Aot/Probes.cs b/src/log4net.Tests.Aot/Probes.cs new file mode 100644 index 000000000..c9e976964 --- /dev/null +++ b/src/log4net.Tests.Aot/Probes.cs @@ -0,0 +1,245 @@ +#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; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Xml; + +using log4net.Appender; +using log4net.Config; +using log4net.Core; +using log4net.Filter; +using log4net.Layout; +using log4net.Repository; +using log4net.Repository.Hierarchy; +using log4net.Util; + +namespace log4net.Tests.Aot; + +/// +/// The log4net surface this project exercises. +/// +/// +/// +/// Each probe is run identically whether the assembly was JIT compiled or published with Native +/// AOT, so a difference between the two runs is an AOT effect rather than a platform one. +/// +/// +internal static class Probes +{ + /// + /// Probes that are expected to fail under Native AOT, with the reason. + /// + /// + /// + /// Keep it honest: an unlisted failure and a listed probe that starts passing both fail the run. + /// + /// + internal static readonly Dictionary ExpectedAotFailures = new(StringComparer.Ordinal) + { + ["config/XmlConfigurator.Configure(element)"] + = "appender and layout types are named as strings in XML, so the trimmer removes them", + ["settings/appSettings from app.config"] + = "System.Configuration cannot initialize once trimmed; environment variables stand in", + }; + + /// + /// Probes that are expected to fail when the assembly is JIT compiled. + /// + internal static readonly Dictionary ExpectedJitFailures = new(StringComparer.Ordinal) + { + ["settings/appSettings from environment"] + = "the environment only stands in once the configuration system is known to be unavailable", + }; + + /// + /// Every probe, in the order they are run. + /// + /// the probes + internal static IEnumerable All() + { + MemoryAppender memory = Configure(); + return + [ + .. Core(memory), + .. Configuration(), + .. Each("appender", Appenders(), Activate), + .. Each("layout", Layouts(), layout => Render(layout)), + .. Patterns(), + .. Each("filter", Filters(), Activate), + .. Settings(), + .. Shutdown(), + ]; + } + + /// + /// Wires up the appender the core probes assert against. + /// + private static MemoryAppender Configure() + { + MemoryAppender memory = new() + { + Layout = new PatternLayout("%level %logger %message"), + Threshold = Level.All, + }; + memory.ActivateOptions(); + BasicConfigurator.Configure(memory); + return memory; + } + + private static IEnumerable Core(MemoryAppender memory) + { + yield return new("core", "GetLogger(Type)", () => LogManager.GetLogger(typeof(Probes))); + yield return new("core", "GetLogger(string)", () => LogManager.GetLogger("by.name")); + yield return new("core", "GetRepository()", () => LogManager.GetRepository()); + yield return new("core", "Exists", () => LogManager.Exists("by.name")); + yield return new("core", "GetCurrentLoggers", () => LogManager.GetCurrentLoggers()); + yield return new("core", "CreateRepository(name)", () => LogManager.CreateRepository("probe-repository")); + yield return new("core", "event reaches appender", () => + { + memory.Clear(); + LogManager.GetLogger(typeof(Probes)).Info("hello"); + Require(memory.GetEvents().Length == 1, "the event did not reach the appender"); + }); + yield return new("core", "level lookup by name", () => + { + Hierarchy hierarchy = (Hierarchy)LogManager.GetRepository(); + Require(hierarchy.LevelMap["WARN"] is not null, "WARN is not in the level map"); + }); + yield return new("core", "context properties", () => + { + ThreadContext.Properties["thread"] = 1; + GlobalContext.Properties["global"] = 2; + LogicalThreadContext.Properties["logical"] = 3; + }); + } + + private static IEnumerable Configuration() + { + yield return new("config", "BasicConfigurator.Configure(appender)", () => BasicConfigurator.Configure(new MemoryAppender())); + yield return new("config", "XmlConfigurator.Configure(element)", () => + { + XmlDocument document = new(); + document.LoadXml(""" + + + + + """); + ILoggerRepository repository = LogManager.CreateRepository("xml-repository"); + XmlConfigurator.Configure(repository, document.DocumentElement!); + Require(repository.GetAppenders().Length > 0, "no appenders were created from the XML"); + }); + } + + private static IEnumerable Patterns() + { + yield return new("layout", "PatternLayout with every built-in converter", () => + { + PatternLayout layout = new("%a %c %C %d %F %l %L %m %M %n %p %P %r %t %u %w %x %X %utcdate %exception %stacktrace %stacktracedetail"); + Require(Render(layout).Length > 0, "the layout rendered nothing"); + }); + yield return new("layout", "PatternString", () => + Require(new PatternString("%processid %appdomain %date{yyyy}").Format().Length > 0, "the pattern rendered nothing")); + } + + private static IEnumerable Settings() + { + yield return new("settings", "appSettings from app.config", () => + Require(SystemInfo.GetAppSetting("log4net.AotProbe") == "from-config", "the key was not read from app.config")); + yield return new("settings", "appSettings from environment", () => + Require(SystemInfo.GetAppSetting(Program.EnvironmentProbeKey) == "from-environment", "the key was not read from the environment")); + } + + private static IEnumerable Shutdown() + { + yield return new("shutdown", "Flush", () => LogManager.Flush(1000)); + yield return new("shutdown", "Shutdown", LogManager.Shutdown); + } + + /// + /// One probe per entry, each creating its subject and handing it to . + /// + private static IEnumerable Each(string area, Dictionary> subjects, Action check) + { + foreach (KeyValuePair> subject in subjects) + { + yield return new(area, subject.Key, () => check(subject.Value())); + } + } + + private static Dictionary> Appenders() => new(StringComparer.Ordinal) + { + ["ConsoleAppender"] = () => new ConsoleAppender { Layout = new SimpleLayout() }, + ["MemoryAppender"] = () => new MemoryAppender(), + ["DebugAppender"] = () => new DebugAppender { Layout = new SimpleLayout() }, + ["TraceAppender"] = () => new TraceAppender { Layout = new SimpleLayout() }, + ["ForwardingAppender"] = () => new ForwardingAppender(), + ["BufferingForwardingAppender"] = () => new BufferingForwardingAppender { BufferSize = 2 }, + ["FileAppender"] = () => new FileAppender { File = TempFile("aot-probe-file.log"), Layout = new SimpleLayout() }, + ["RollingFileAppender"] = () => new RollingFileAppender { File = TempFile("aot-probe-roll.log"), Layout = new SimpleLayout() }, + ["UdpAppender"] = () => new UdpAppender { RemoteAddress = IPAddress.Loopback, RemotePort = 9999, Layout = new SimpleLayout() }, + ["AnsiColorTerminalAppender"] = () => new AnsiColorTerminalAppender { Layout = new SimpleLayout() }, + }; + + private static Dictionary> Layouts() => new(StringComparer.Ordinal) + { + ["SimpleLayout"] = () => new SimpleLayout(), + ["PatternLayout"] = () => new PatternLayout("%level %logger %message%newline"), + ["ExceptionLayout"] = () => new ExceptionLayout(), + ["XmlLayout"] = () => new XmlLayout(), + }; + + private static Dictionary> Filters() => new(StringComparer.Ordinal) + { + ["LevelRangeFilter"] = () => new LevelRangeFilter { LevelMin = Level.Debug, LevelMax = Level.Fatal }, + ["LevelMatchFilter"] = () => new LevelMatchFilter { LevelToMatch = Level.Info }, + ["StringMatchFilter"] = () => new StringMatchFilter { StringToMatch = "probe" }, + ["PropertyFilter"] = () => new PropertyFilter { Key = "probe", StringToMatch = "value" }, + ["DenyAllFilter"] = () => new DenyAllFilter(), + }; + + private static string TempFile(string name) => Path.Combine(Path.GetTempPath(), name); + + private static void Activate(object option) => (option as IOptionHandler)?.ActivateOptions(); + + private static string Render(ILayout layout) + { + Activate(layout); + using StringWriter writer = new(); + layout.Format(writer, new(new() + { + Level = Level.Info, + LoggerName = "probe", + Message = "message", + TimeStampUtc = DateTime.UtcNow, + })); + return writer.ToString(); + } + + private static void Require(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } +} diff --git a/src/log4net.Tests.Aot/Program.cs b/src/log4net.Tests.Aot/Program.cs new file mode 100644 index 000000000..5f484cb4a --- /dev/null +++ b/src/log4net.Tests.Aot/Program.cs @@ -0,0 +1,147 @@ +#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; +using System.Collections.Generic; +using System.Reflection; + +using log4net.Util; + +namespace log4net.Tests.Aot; + +/// +/// Records what log4net can and cannot do when an application is published with Native AOT. +/// +/// +/// +/// The build runs this assembly twice, JIT compiled and published with PublishAot, so a +/// difference between the runs is caused by AOT rather than by the platform. The expected +/// differences are listed in . +/// +/// +internal static class Program +{ + /// + /// The environment variable the settings probe expects, set by the build before running. + /// + internal const string EnvironmentProbeKey = "log4net.AotEnvironmentProbe"; + + private static int Main() + { + // The probes report their own failures, so log4net's internal error reporting is only noise + // here, and a passing run that prints errors reads as a broken one. + LogLog.EmitInternalMessages = false; + + bool isAot = !IsGetCallingAssemblySupported(); + string mode = isAot ? "AOT" : "JIT"; + Dictionary expectedFailures + = isAot ? Probes.ExpectedAotFailures : Probes.ExpectedJitFailures; + + Console.WriteLine($"log4net probes, {mode}"); + Console.WriteLine(new string('-', 100)); + + List regressions = []; + List unexpectedPasses = []; + + foreach (Probe probe in Probes.All()) + { + string? failure = Run(probe); + bool expectedToFail = expectedFailures.ContainsKey(probe.Key); + + if (failure is null) + { + Console.WriteLine($" {"ok",-6} {probe.Key}"); + if (expectedToFail) + { + unexpectedPasses.Add(probe.Key); + } + } + else + { + Console.WriteLine($" {"FAIL",-6} {probe.Key}"); + Console.WriteLine($" {failure}"); + if (expectedToFail) + { + Console.WriteLine($" expected under {mode}: {expectedFailures[probe.Key]}"); + } + else + { + regressions.Add($"{probe.Key}: {failure}"); + } + } + } + + Console.WriteLine(new string('-', 100)); + foreach (string regression in regressions) + { + Console.WriteLine($"REGRESSION {regression}"); + } + foreach (string unexpectedPass in unexpectedPasses) + { + Console.WriteLine($"NOW WORKING {unexpectedPass} is listed as an expected {mode} failure but passed. " + + "Remove it from the list and update the Native AOT page in the manual."); + } + + int problems = regressions.Count + unexpectedPasses.Count; + Console.WriteLine(problems == 0 + ? $"{mode}: matches expectations" + : $"{mode}: {problems} probe(s) did not match expectations"); + return problems == 0 ? 0 : 1; + } + + /// + /// Whether this process is running Native AOT. + /// + /// + /// + /// RuntimeFeature.IsDynamicCodeSupported cannot be used: setting + /// PublishAot turns that switch off for an ordinary run of the same project too, so it + /// reports AOT either way. is only unsupported when the + /// application really was compiled ahead of time, which is also the capability log4net keys off. + /// + /// + private static bool IsGetCallingAssemblySupported() + { + try + { + _ = Assembly.GetCallingAssembly(); + return true; + } + catch (PlatformNotSupportedException) + { + return false; + } + } + + private static string? Run(Probe probe) + { + try + { + probe.Run(); + return null; + } + catch (Exception e) when (e is not (OutOfMemoryException or StackOverflowException)) + { + // A probe reports whatever it failed with, because letting the exception escape would lose + // every later probe. The filter mirrors log4net's own Log4NetAssert.IsFatal, which is + // internal and not worth linking in for two type checks. + return $"{e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + } +} diff --git a/src/log4net.Tests.Aot/log4net.Tests.Aot.csproj b/src/log4net.Tests.Aot/log4net.Tests.Aot.csproj new file mode 100644 index 000000000..350814f5f --- /dev/null +++ b/src/log4net.Tests.Aot/log4net.Tests.Aot.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + log4net.Tests.Aot + disable + false + + true + true + + false + + + + + + + + + + + diff --git a/src/log4net.Tests/Util/AotCompatibilityTest.cs b/src/log4net.Tests/Util/AotCompatibilityTest.cs new file mode 100644 index 000000000..a8c1ef23a --- /dev/null +++ b/src/log4net.Tests/Util/AotCompatibilityTest.cs @@ -0,0 +1,169 @@ +#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; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +using log4net.Config; +using log4net.Core; +using log4net.Layout; +using log4net.Util; + +using NUnit.Framework; + +namespace log4net.Tests.Util; + +/// +/// Guards the annotations that keep log4net working when a consumer publishes with +/// PublishAot or PublishTrimmed. +/// +/// +/// +/// Dropping one of these annotations breaks nothing in an ordinary build: the failure only shows up +/// as a inside a consumer's published application. These tests +/// fail on the spot instead. The attribute is matched by full name, because log4net polyfills it as +/// an internal type, which is also how the trimmer recognises it. +/// +/// +[TestFixture] +public class AotCompatibilityTest +{ + private const string DynamicallyAccessedMembers + = "System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute"; + + /// + /// The public entry point a caller passes a repository type to. + /// + [Test] + public void LogManagerCreateRepositoryAnnotatesTheRepositoryType() + => AssertAnnotated(Parameter(typeof(LogManager), nameof(LogManager.CreateRepository), typeof(Type))); + + /// + /// The step between and the repository selector. + /// + [Test] + public void LoggerManagerCreateRepositoryAnnotatesTheRepositoryType() + => AssertAnnotated(Parameter(typeof(LoggerManager), nameof(LoggerManager.CreateRepository), + typeof(Assembly), typeof(Type))); + + /// + /// The interface, so a custom selector keeps the annotation too. + /// + [Test] + public void RepositorySelectorCreateRepositoryAnnotatesTheRepositoryType() + => AssertAnnotated(Parameter(typeof(IRepositorySelector), nameof(IRepositorySelector.CreateRepository), + typeof(Assembly), typeof(Type))); + + /// + /// Where typeof(Hierarchy) enters, and the default repository is created from. + /// + [Test] + public void DefaultRepositorySelectorAnnotatesItsDefaultRepositoryType() + { + ConstructorInfo constructor = typeof(DefaultRepositorySelector).GetConstructor([typeof(Type)]) + ?? throw new InvalidOperationException("DefaultRepositorySelector(Type) no longer exists."); + AssertAnnotated(constructor.GetParameters()[0]); + } + + /// + /// The assembly attribute that lets an application choose its own repository type. + /// + [Test] + public void RepositoryAttributeAnnotatesItsRepositoryType() + => AssertAnnotated(typeof(RepositoryAttribute).GetProperty(nameof(RepositoryAttribute.RepositoryType)) + ?? throw new InvalidOperationException("RepositoryAttribute.RepositoryType no longer exists.")); + + /// + /// What carries a converter type through the registries, where a bare would lose the annotation. + /// + [Test] + public void ConverterInfoAnnotatesItsType() + => AssertAnnotated(typeof(ConverterInfo).GetProperty(nameof(ConverterInfo.Type)) + ?? throw new InvalidOperationException("ConverterInfo.Type no longer exists.")); + + /// + /// The built-in converters are only ever created reflectively, so a lost constructor would + /// surface only once a consumer trims their application. + /// + [Test] + public void BuiltInPatternLayoutConvertersAreConstructible() + => AssertConvertersAreConstructible(typeof(PatternLayout)); + + /// + /// The same guarantee for the registry. + /// + [Test] + public void BuiltInPatternStringConvertersAreConstructible() + => AssertConvertersAreConstructible(typeof(PatternString)); + + /// + /// Every registered name really produces a converter rather than the error a missing + /// constructor would give. + /// + [Test] + public void EveryBuiltInPatternLayoutConverterCanBeCreated() + { + foreach (string name in GlobalRules(typeof(PatternLayout)).Keys) + { + PatternLayout layout = new($"%{name}"); + Assert.DoesNotThrow(layout.ActivateOptions, $"converter [{name}] could not be activated"); + } + } + + private static void AssertConvertersAreConstructible(Type owner) + { + Dictionary rules = GlobalRules(owner); + Assert.That(rules, Is.Not.Empty); + + foreach (KeyValuePair rule in rules) + { + Type converter = rule.Value.Type + ?? throw new InvalidOperationException($"converter [{rule.Key}] has no type"); + Assert.That(converter.GetConstructor(Type.EmptyTypes), Is.Not.Null, + $"converter [{rule.Key}] ({converter.FullName}) has no public parameterless constructor"); + } + } + + private static Dictionary GlobalRules(Type owner) + { + FieldInfo field = owner.GetField("_sGlobalRulesRegistry", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"{owner.Name}._sGlobalRulesRegistry no longer exists - update this test along with it."); + return (Dictionary)field.GetValue(null)!; + } + + private static ParameterInfo Parameter(Type owner, string methodName, params Type[] signature) + { + MethodInfo method = owner.GetMethod(methodName, signature) + ?? throw new InvalidOperationException($"{owner.Name}.{methodName} no longer has the expected signature."); + return method.GetParameters().Single(p => p.ParameterType == typeof(Type)); + } + + private static void AssertAnnotated(ParameterInfo parameter) + => Assert.That(IsAnnotated(parameter.GetCustomAttributesData()), Is.True, + $"parameter [{parameter.Name}] of [{parameter.Member}] lost its {DynamicallyAccessedMembers}"); + + private static void AssertAnnotated(MemberInfo member) + => Assert.That(IsAnnotated(member.GetCustomAttributesData()), Is.True, + $"[{member.DeclaringType?.Name}.{member.Name}] lost its {DynamicallyAccessedMembers}"); + + private static bool IsAnnotated(IEnumerable attributes) + => attributes.Any(attribute => attribute.AttributeType.FullName == DynamicallyAccessedMembers); +} diff --git a/src/log4net.sln b/src/log4net.sln index f0c530615..95a848902 100644 --- a/src/log4net.sln +++ b/src/log4net.sln @@ -60,6 +60,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "log4net.Ext.Mail", "log4net EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "log4net.Ext.Mail.Tests", "log4net.Ext.Mail.Tests\log4net.Ext.Mail.Tests.csproj", "{56A9114E-30BC-0ED0-843E-97613E9E6221}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "log4net.Tests.Aot", "log4net.Tests.Aot\log4net.Tests.Aot.csproj", "{A89A800C-DBC6-4C5D-AD92-0D68B8C47ECD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -106,6 +108,10 @@ Global {56A9114E-30BC-0ED0-843E-97613E9E6221}.Debug|Any CPU.Build.0 = Debug|Any CPU {56A9114E-30BC-0ED0-843E-97613E9E6221}.Release|Any CPU.ActiveCfg = Release|Any CPU {56A9114E-30BC-0ED0-843E-97613E9E6221}.Release|Any CPU.Build.0 = Release|Any CPU + {A89A800C-DBC6-4C5D-AD92-0D68B8C47ECD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A89A800C-DBC6-4C5D-AD92-0D68B8C47ECD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A89A800C-DBC6-4C5D-AD92-0D68B8C47ECD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A89A800C-DBC6-4C5D-AD92-0D68B8C47ECD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/log4net/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs b/src/log4net/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs new file mode 100644 index 000000000..da664ea05 --- /dev/null +++ b/src/log4net/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs @@ -0,0 +1,71 @@ +#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 + +// inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs + +#if !NET6_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Suppresses a trimming or single file warning, unconditionally: unlike +/// it is kept in the +/// assembly, so the trimmer can still see it. +/// +/// +/// +/// Neither net462 nor netstandard2.0 declares this attribute. The trimmer recognizes +/// it by full name, so log4net supplies its own. +/// +/// +/// the category of the suppressed warning +/// the identifier of the suppressed warning +[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)] +internal sealed class UnconditionalSuppressMessageAttribute(string category, string checkId) : Attribute +{ + /// + /// Gets the category of the suppressed warning. + /// + public string Category { get; } = category; + + /// + /// Gets the identifier of the suppressed warning. + /// + public string CheckId { get; } = checkId; + + /// + /// Gets or sets the scope of the suppression. + /// + public string? Scope { get; set; } + + /// + /// Gets or sets the fully qualified name of the suppression target. + /// + public string? Target { get; set; } + + /// + /// Gets or sets why the warning is suppressed. + /// + public string? Justification { get; set; } + + /// + /// Gets or sets an optional argument expanding on the suppression. + /// + public string? MessageId { get; set; } +} +#endif diff --git a/src/log4net/Util/SystemInfo.cs b/src/log4net/Util/SystemInfo.cs index 823c9f047..eb3c656b1 100644 --- a/src/log4net/Util/SystemInfo.cs +++ b/src/log4net/Util/SystemInfo.cs @@ -19,6 +19,7 @@ using System; using System.Configuration; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.IO; using System.Collections; @@ -155,12 +156,36 @@ public static string EntryAssemblyLocation { return _entryAssemblyLocation; } - return _entryAssemblyLocation = Assembly.GetEntryAssembly()?.Location + Assembly entryAssembly = Assembly.GetEntryAssembly() ?? throw new InvalidOperationException($"Unable to determine EntryAssembly location: EntryAssembly is null. Try explicitly setting {nameof(SystemInfo)}.{nameof(EntryAssemblyLocation)}"); + string location = GetAssemblyLocation(entryAssembly); + if (location.Length == 0) + { + // A single file application has no file on disk for the assemblies it embeds, so the + // closest thing to the entry assembly's path is the directory it was published to. + location = Path.Combine(AppContext.BaseDirectory, $"{entryAssembly.GetName().Name}.dll"); + } + return _entryAssemblyLocation = location; } set => _entryAssemblyLocation = value; } + /// + /// Reads . + /// + /// the assembly to locate + /// the path to the assembly, or an empty string when it has none + /// + /// + /// An assembly embedded in a single file application has no path, and + /// returns an empty string for it rather than failing. Callers + /// handle that here, which is what the suppressed warning asks them to do. + /// + /// + [UnconditionalSuppressMessage("SingleFile", "IL3000", + Justification = "The empty string returned by a single file application is handled by the callers.")] + private static string GetAssemblyLocation(Assembly assembly) => assembly.Location; + /// /// Gets the ID of the current thread. /// @@ -369,7 +394,8 @@ public static string AssemblyLocationInfo(Assembly myAssembly) // This call requires FileIOPermission for access to the path // if we don't have permission then we just ignore it and // carry on. - return myAssembly.Location; + string location = GetAssemblyLocation(myAssembly); + return location.Length > 0 ? location : "Single File Application"; } catch (NotSupportedException) {