make log4net usable from a PublishAOT build (#306) - #306
Conversation
Native AOT broke two things in the startup path (fixes #233 partially). 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 failed as well, because System.Configuration is trimmed away. Its catch never saw that: resolving the missing assembly fails on entry to the method, before the try region, so the exception escaped the static constructor as a TypeInitializationException and killed the process. Read the setting through a separate, never inlined method so the failure is raised inside the try block, latch the result so a permanent failure is reported once rather than per lookup, and fall back to environment variables the way the Android branch already does. That fallback also makes log4net.NullText and log4net.NotAvailableText settable under AOT, where they previously could not be configured at all. Note that the fallback applies on .NET Framework too: a malformed app.config now reads settings from the environment instead of returning null. This does not make log4net AOT-clean - repositories, appenders and layouts are still instantiated via Activator.CreateInstance, so an AOT app still fails with MissingMethodException on Hierarchy's constructor.
26841b2 to
9ca8d88
Compare
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.
9ca8d88 to
bf839f7
Compare
fluffynuts
left a comment
There was a problem hiding this comment.
nice work - just a suggestion to consolidate the logic which determines the caller assembly or falls back on the entry assembly.
| /// <seealso cref="Log4NetConfigurationSectionHandler"/> | ||
| public static ICollection Configure() | ||
| => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); | ||
| => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); |
There was a problem hiding this comment.
I see this logic quite a few times throughout - perhaps move to CallerAssembly with a lazy backing field and reference that static property elsewhere (eg CallerAssembly.ResolvedCallerAssembly? Main reason being that it's no longer just an obvious call to Assembly.GetCallingAssembly(), but now includes logic, which is repeated in quite a few places.
There was a problem hiding this comment.
@fluffynuts
Good catch, but this one can't move - though I don't like it either. Assembly.GetCallingAssembly()
returns the caller of the method containing the call, so in a property on CallerAssembly the caller
is log4net itself - every logger would land in log4net's own repository. Two-assembly harness,
called from UserApp:
inline (current PR) -> UserApp <- correct
via property (suggested) -> log4net
The lazy backing field is worse: the first assembly to touch it wins forever, so the result depends on
load order.
The BCL hits this exact problem and needs an internal enum for it - System.Threading.StackCrawlMark
(LookForMyCaller, LookForMyCallersCaller), passed by ref so Assembly.Load can delegate to a
private helper. It's NotPublic and no public API accepts it. It also wouldn't help us: it's
stack-walking machinery, and AOT throws precisely because there is no stack to walk.
Only a Roslyn interceptor would actually remove the repetition - left out here, but I'm happy to open a separate issue.
There was a problem hiding this comment.
Personally, I think [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
is the bigger eyesore.
There was a problem hiding this comment.
yeah, I was also looking at that - but if I were to guess wildly, the attribute as found (when found) in the dotnet runtime, is likely sealed. Otherwise I'd suggest sub-classing with those settings - which still may not even work if the logic doesn't bother to look at attributes' base types (quite likely). I don't think there's much that can be done about it :/
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR makes log4net usable when consumers publish with Native AOT / trimming by guarding unsupported runtime APIs, avoiding repeated configuration-system failures, and annotating reflection-based activation paths so required constructors survive trimming.
Changes:
- Guarded
Assembly.GetCallingAssembly()usage via a cached runtime probe with an entry-assembly fallback. - Improved
SystemInfo.GetAppSetting()behavior under trimmed/missingSystem.Configurationby falling back to environment variables and reducing noise. - Added trimming annotations (
DynamicallyAccessedMembers) and refactored pattern-converter registries to preserve constructors; documented Native AOT constraints in the manual.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/site/antora/modules/ROOT/pages/manual/native-aot.adoc | Adds a Native AOT/trimming manual page with guidance and examples. |
| src/site/antora/modules/ROOT/pages/manual/configuration.adoc | Notes XML config is unavailable under PublishAot and links to Native AOT guidance. |
| src/site/antora/modules/ROOT/nav.adoc | Adds the new Native AOT page to the manual navigation. |
| src/log4net/Util/CallerAssembly.cs | Introduces a runtime probe + fallback assembly for unsupported GetCallingAssembly(). |
| src/log4net/LogManager.cs | Routes calling-assembly-based overloads through the guard/fallback. |
| src/log4net/Config/BasicConfigurator.cs | Uses guarded calling-assembly resolution for repository selection. |
| src/log4net/Config/XmlConfigurator.cs | Uses guarded calling-assembly resolution to select repositories. |
| src/log4net/Util/SystemInfo.cs | Adds config-system detection, env-var fallback, and a non-inlined settings reader. |
| src/log4net/Util/TypeConverters/ConverterRegistry.cs | Annotates converter Type flows to keep parameterless ctors under trimming. |
| src/log4net/Layout/PatternLayout.cs | Refactors built-in converter registry to retain trimming annotations. |
| src/log4net/Util/PatternString.cs | Same registry refactor for PatternString converters. |
| src/log4net/Util/ConverterInfo.cs | Annotates ConverterInfo.Type to preserve constructors. |
| src/log4net/Core/LoggerManager.cs | Annotates repository type activation paths. |
| src/log4net/Core/IRepositorySelector.cs | Annotates repository type parameters in the interface. |
| src/log4net/Core/DefaultRepositorySelector.cs | Propagates repository-type annotations and stores annotated default type. |
| src/log4net/Config/RepositoryAttribute.cs | Annotates repository type property to preserve constructors. |
| src/log4net/Appender/FileAppender.cs | Adds trimming-safe constraints/annotations for default locking model activation. |
| src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs | Adds a polyfill attribute for TFMs lacking it (for trimmer recognition). |
| src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs | Adds a polyfill enum for TFMs lacking it (for trimmer recognition). |
| src/log4net.Tests/log4net.Tests.csproj | Links CallerAssembly.cs into tests. |
| src/log4net.Tests/Util/SystemInfoTest.cs | Adds tests for config-system fallback and detection behavior. |
| src/log4net.Tests/Util/CallerAssemblyTest.cs | Adds tests validating the guarded calling-assembly behavior on JIT. |
| src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml | Adds a changelog entry describing the Native AOT fixes and limitations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| PublicParameterlessConstructor = 0x0001, | ||
|
|
||
| /// <summary> | ||
| /// Specifies all public constructors. | ||
| /// </summary> | ||
| PublicConstructors = 0x0002 | PublicParameterlessConstructor, |
| 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; | ||
| } |
There was a problem hiding this comment.
This also raised my attention. Allow me to test it and provide evidence that this can bite us (or report that I failed to get such scenario).
| if (IsAndroid || _configurationSystemUnavailable) | ||
| return Environment.GetEnvironmentVariable(key); |
| // 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; |
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| private static string? ReadAppSetting(string key) => ConfigurationManager.AppSettings[key]; | ||
|
|
||
| private static bool _configurationSystemUnavailable; |
| foreach (KeyValuePair<string, ConverterInfo> entry in _sGlobalRulesRegistry) | ||
| { | ||
| ConverterInfo converterInfo = new() | ||
| { | ||
| Name = entry.Key, | ||
| Type = entry.Value | ||
| }; | ||
| patternParser.PatternConverters[entry.Key] = converterInfo; | ||
| patternParser.PatternConverters[entry.Key] = entry.Value; | ||
| } |
| <VSTestLogger>quackers</VSTestLogger> | ||
| </PropertyGroup> | ||
| <ItemGroup> | ||
| <Compile Include="..\log4net\Util\CallerAssembly.cs" Link="Util\CallerAssembly.cs" /> |
| /// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped | ||
| /// directly, the same way <see cref="IsAndoid"/> reaches a non-public member. The environment | ||
| /// must stay untouched while the configuration system still works, otherwise a malformed |
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.