Skip to content

Commit 9ca8d88

Browse files
committed
make log4net usable from a PublishAOT build (#306)
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.
1 parent 9a64f95 commit 9ca8d88

8 files changed

Lines changed: 282 additions & 24 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#region Apache License
2+
//
3+
// Licensed to the Apache Software Foundation (ASF) under one or more
4+
// contributor license agreements. See the NOTICE file distributed with
5+
// this work for additional information regarding copyright ownership.
6+
// The ASF licenses this file to you under the Apache License, Version 2.0
7+
// (the "License"); you may not use this file except in compliance with
8+
// the License. You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
#endregion
19+
20+
using System.Reflection;
21+
using System.Runtime.CompilerServices;
22+
using log4net.Util;
23+
using NUnit.Framework;
24+
25+
namespace log4net.Tests.Util;
26+
27+
/// <summary>
28+
/// Tests for <see cref="CallerAssembly"/>, the guard that keeps the
29+
/// <see cref="Assembly.GetCallingAssembly()"/> based overloads usable under Native AOT.
30+
/// </summary>
31+
/// <remarks>
32+
/// <para>
33+
/// The AOT half of the behaviour cannot be covered here - these tests always run on a JIT
34+
/// runtime, where <see cref="CallerAssembly.IsSupported"/> is <see langword="true"/> and
35+
/// <see cref="CallerAssembly.Fallback"/> is never consulted. What they do cover is that the
36+
/// guard stays inert on a JIT runtime, so that no call site silently starts attributing
37+
/// loggers to the entry assembly instead of the caller.
38+
/// </para>
39+
/// </remarks>
40+
[TestFixture]
41+
public class CallerAssemblyTest
42+
{
43+
/// <summary>
44+
/// The probe recognises a runtime that does implement
45+
/// <see cref="Assembly.GetCallingAssembly()"/>, so the guard stays out of the way everywhere
46+
/// except Native AOT. A false negative here would silently move every logger to the entry
47+
/// assembly's repository.
48+
/// </summary>
49+
[Test]
50+
public void IsSupportedOnAJitRuntime() => Assert.That(CallerAssembly.IsSupported, Is.True);
51+
52+
/// <summary>
53+
/// There is always a replacement assembly to attribute a call to, even though the entry
54+
/// assembly is <see langword="null"/> in a host without a managed entry point.
55+
/// </summary>
56+
[Test]
57+
public void FallbackIsAvailable() => Assert.That(CallerAssembly.Fallback, Is.Not.Null);
58+
59+
/// <summary>
60+
/// The guard has to leave <see cref="Assembly.GetCallingAssembly()"/> in the method whose
61+
/// caller is wanted, so a call from this assembly still resolves to this assembly.
62+
/// </summary>
63+
[Test]
64+
public void GuardedCallStillReportsTheCallersAssembly()
65+
=> Assert.That(GuardedCallingAssembly(), Is.SameAs(typeof(CallerAssemblyTest).Assembly));
66+
67+
/// <summary>
68+
/// Stands in for a public log4net entry point. Inlining is suppressed because it would
69+
/// hand <see cref="Assembly.GetCallingAssembly()"/> a different frame - the same effect
70+
/// that makes the release build of <see cref="SystemInfoTest"/> unable to assert on an
71+
/// exact assembly.
72+
/// </summary>
73+
[MethodImpl(MethodImplOptions.NoInlining)]
74+
private static Assembly GuardedCallingAssembly()
75+
=> CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback;
76+
}

src/log4net.Tests/Util/SystemInfoTest.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,68 @@ public void EqualsIgnoringCase_DifferentStrings_false()
171171
[Platform(Include = "Win,Linux,MacOsX")]
172172
public void IsAndoid()
173173
=> Assert.That(typeof(SystemInfo).GetProperty("IsAndroid", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null), Is.False);
174+
175+
/// <summary>
176+
/// <see cref="SystemInfo.GetAppSetting"/> falls back to environment variables once the
177+
/// configuration system has failed - which is what happens under Native AOT, where
178+
/// System.Configuration is trimmed away.
179+
/// </summary>
180+
/// <remarks>
181+
/// <para>
182+
/// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped
183+
/// directly, the same way <see cref="IsAndoid"/> reaches a non-public member. The environment
184+
/// must stay untouched while the configuration system still works, otherwise a malformed
185+
/// <c>app.config</c> would silently change where every setting comes from.
186+
/// </para>
187+
/// </remarks>
188+
[Test]
189+
[NonParallelizable]
190+
public void GetAppSettingFallsBackToTheEnvironmentOnceConfigurationIsUnavailable()
191+
{
192+
const string Key = "log4net.Tests.AppSettingFallback";
193+
const string Value = "from-the-environment";
194+
195+
FieldInfo latch = AppSettingsUnavailableLatch();
196+
bool originalLatch = (bool)latch.GetValue(null)!;
197+
Environment.SetEnvironmentVariable(Key, Value);
198+
try
199+
{
200+
latch.SetValue(null, false);
201+
Assert.That(SystemInfo.GetAppSetting(Key), Is.Null);
202+
203+
latch.SetValue(null, true);
204+
Assert.That(SystemInfo.GetAppSetting(Key), Is.EqualTo(Value));
205+
}
206+
finally
207+
{
208+
latch.SetValue(null, originalLatch);
209+
Environment.SetEnvironmentVariable(Key, null);
210+
}
211+
}
212+
213+
/// <summary>
214+
/// A key that is missing from the environment as well reads as <see langword="null"/>, so the
215+
/// fallback leaves callers with the same "no such setting" answer they get from a working
216+
/// configuration system.
217+
/// </summary>
218+
[Test]
219+
[NonParallelizable]
220+
public void GetAppSettingReturnsNullForAnUnsetEnvironmentVariable()
221+
{
222+
FieldInfo latch = AppSettingsUnavailableLatch();
223+
bool originalLatch = (bool)latch.GetValue(null)!;
224+
try
225+
{
226+
latch.SetValue(null, true);
227+
Assert.That(SystemInfo.GetAppSetting("log4net.Tests.NoSuchSettingAnywhere"), Is.Null);
228+
}
229+
finally
230+
{
231+
latch.SetValue(null, originalLatch);
232+
}
233+
}
234+
235+
private static FieldInfo AppSettingsUnavailableLatch()
236+
=> typeof(SystemInfo).GetField("_appSettingsUnavailable", BindingFlags.Static | BindingFlags.NonPublic)
237+
?? throw new InvalidOperationException("SystemInfo._appSettingsUnavailable no longer exists - update this test along with it.");
174238
}

src/log4net.Tests/log4net.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<VSTestLogger>quackers</VSTestLogger>
2020
</PropertyGroup>
2121
<ItemGroup>
22+
<Compile Include="..\log4net\Util\CallerAssembly.cs" Link="Util\CallerAssembly.cs" />
2223
<Compile Include="..\log4net\Diagnostics\CodeAnalysis\CallerArgumentExpressionAttribute.cs" Link="Diagnostics\CodeAnalysis\CallerArgumentExpressionAttribute.cs" />
2324
<Compile Include="..\log4net\Diagnostics\CodeAnalysis\IsExternalInit.cs" Link="Diagnostics\CodeAnalysis\IsExternalInit.cs" />
2425
</ItemGroup>

src/log4net/Config/BasicConfigurator.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ public static class BasicConfigurator
7373
/// layout style.
7474
/// </para>
7575
/// </remarks>
76-
public static ICollection Configure() => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly()));
76+
public static ICollection Configure()
77+
=> Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback));
7778

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

91-
ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
92+
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
9293

9394
using (new LogLog.LogReceivedAdapter(configurationMessages))
9495
{

src/log4net/Config/XmlConfigurator.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ private static void InternalConfigure(ILoggerRepository repository, Func<XmlElem
140140
/// </remarks>
141141
/// <seealso cref="Log4NetConfigurationSectionHandler"/>
142142
public static ICollection Configure()
143-
=> Configure(LogManager.GetRepository(Assembly.GetCallingAssembly()));
143+
=> Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback));
144144

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

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

161161
using (new LogLog.LogReceivedAdapter(configurationMessages))
162162
{
@@ -222,9 +222,11 @@ public static ICollection Configure(FileInfo configFile)
222222
{
223223
List<LogLog> configurationMessages = [];
224224

225+
Assembly repositoryAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback;
226+
225227
using (new LogLog.LogReceivedAdapter(configurationMessages))
226228
{
227-
InternalConfigure(LogManager.GetRepository(Assembly.GetCallingAssembly()), configFile);
229+
InternalConfigure(LogManager.GetRepository(repositoryAssembly), configFile);
228230
}
229231

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

251-
ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
253+
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
252254
using (new LogLog.LogReceivedAdapter(configurationMessages))
253255
{
254256
InternalConfigure(repository, configUri);
@@ -277,7 +279,7 @@ public static ICollection Configure(Stream configStream)
277279
{
278280
List<LogLog> configurationMessages = [];
279281

280-
ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
282+
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
281283
using (new LogLog.LogReceivedAdapter(configurationMessages))
282284
{
283285
InternalConfigure(repository, configStream);
@@ -644,7 +646,7 @@ public static ICollection ConfigureAndWatch(FileInfo configFile)
644646
{
645647
List<LogLog> configurationMessages = [];
646648

647-
ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly());
649+
ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
648650

649651
using (new LogLog.LogReceivedAdapter(configurationMessages))
650652
{

src/log4net/LogManager.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ public static class LogManager
7171
/// </remarks>
7272
/// <param name="name">The fully qualified logger name to look for.</param>
7373
/// <returns>The logger found, or <see langword="null"/> if no logger could be found.</returns>
74-
public static ILog? Exists(string name) => Exists(Assembly.GetCallingAssembly(), name);
74+
public static ILog? Exists(string name)
75+
=> Exists(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name);
7576

7677
/// <overloads>Get the currently defined loggers.</overloads>
7778
/// <summary>
@@ -81,7 +82,8 @@ public static class LogManager
8182
/// <para>The root logger is <b>not</b> included in the returned array.</para>
8283
/// </remarks>
8384
/// <returns>All the defined loggers.</returns>
84-
public static ILog[] GetCurrentLoggers() => GetCurrentLoggers(Assembly.GetCallingAssembly());
85+
public static ILog[] GetCurrentLoggers()
86+
=> GetCurrentLoggers(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
8587

8688
/// <overloads>Get or create a logger.</overloads>
8789
/// <summary>
@@ -101,7 +103,8 @@ public static class LogManager
101103
/// </remarks>
102104
/// <param name="name">The name of the logger to retrieve.</param>
103105
/// <returns>The logger with the name specified.</returns>
104-
public static ILog GetLogger(string name) => GetLogger(Assembly.GetCallingAssembly(), name);
106+
public static ILog GetLogger(string name)
107+
=> GetLogger(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name);
105108

106109
/// <summary>
107110
/// Returns the named logger if it exists.
@@ -211,7 +214,7 @@ public static ILog GetLogger(Assembly repositoryAssembly, string name)
211214
/// <param name="type">The full name of <paramref name="type"/> will be used as the name of the logger to retrieve.</param>
212215
/// <returns>The logger with the name specified.</returns>
213216
public static ILog GetLogger(Type type)
214-
=> GetLogger(Assembly.GetCallingAssembly(), type.EnsureNotNull().FullName!);
217+
=> GetLogger(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, type.EnsureNotNull().FullName!);
215218

216219
/// <summary>
217220
/// Shorthand for <see cref="GetLogger(string)"/>.
@@ -277,7 +280,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type)
277280
/// and again to a nested appender.
278281
/// </para>
279282
/// </remarks>
280-
public static void ShutdownRepository() => ShutdownRepository(Assembly.GetCallingAssembly());
283+
public static void ShutdownRepository()
284+
=> ShutdownRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
281285

282286
/// <summary>
283287
/// Shuts down the repository for the repository specified.
@@ -339,7 +343,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type)
339343
/// message disabling is set to its default "off" value.
340344
/// </para>
341345
/// </remarks>
342-
public static void ResetConfiguration() => ResetConfiguration(Assembly.GetCallingAssembly());
346+
public static void ResetConfiguration()
347+
=> ResetConfiguration(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
343348

344349
/// <summary>
345350
/// Resets all values contained in this repository instance to their defaults.
@@ -384,7 +389,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type)
384389
/// </para>
385390
/// </remarks>
386391
/// <returns>The <see cref="ILoggerRepository"/> instance for the default repository.</returns>
387-
public static ILoggerRepository GetRepository() => GetRepository(Assembly.GetCallingAssembly());
392+
public static ILoggerRepository GetRepository()
393+
=> GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback);
388394

389395
/// <summary>
390396
/// Returns the default <see cref="ILoggerRepository"/> instance.
@@ -427,7 +433,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type)
427433
/// the same repository instance.
428434
/// </para>
429435
/// </remarks>
430-
public static ILoggerRepository CreateRepository(Type repositoryType) => CreateRepository(Assembly.GetCallingAssembly(), repositoryType);
436+
public static ILoggerRepository CreateRepository(Type repositoryType)
437+
=> CreateRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, repositoryType);
431438

432439
/// <summary>
433440
/// Creates a repository with the specified name.
@@ -499,7 +506,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type)
499506
/// <returns><see langword="true"/> if all logging events were flushed successfully, else <see langword="false"/>.</returns>
500507
public static bool Flush(int millisecondsTimeout)
501508
{
502-
if (LoggerManager.GetRepository(Assembly.GetCallingAssembly()) is not IFlushable flushableRepository)
509+
Assembly callerAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback;
510+
if (LoggerManager.GetRepository(callerAssembly) is not IFlushable flushableRepository)
503511
{
504512
return false;
505513
}

src/log4net/Util/CallerAssembly.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#region Apache License
2+
//
3+
// Licensed to the Apache Software Foundation (ASF) under one or more
4+
// contributor license agreements. See the NOTICE file distributed with
5+
// this work for additional information regarding copyright ownership.
6+
// The ASF licenses this file to you under the Apache License, Version 2.0
7+
// (the "License"); you may not use this file except in compliance with
8+
// the License. You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
#endregion
19+
20+
using System;
21+
using System.Reflection;
22+
23+
namespace log4net.Util;
24+
25+
/// <summary>
26+
/// Support for the <see cref="Assembly.GetCallingAssembly()"/> calls that select the
27+
/// <see cref="Repository.ILoggerRepository"/> of the caller.
28+
/// </summary>
29+
/// <remarks>
30+
/// <para>
31+
/// Native AOT does not implement <see cref="Assembly.GetCallingAssembly()"/> - it throws
32+
/// <see cref="PlatformNotSupportedException"/> unconditionally, because the stack frames it
33+
/// would have to walk no longer exist after compilation. Callers therefore have to test
34+
/// <see cref="IsSupported"/> and use <see cref="Fallback"/> instead.
35+
/// </para>
36+
/// <para>
37+
/// The test cannot be hidden behind a helper that calls <see cref="Assembly.GetCallingAssembly()"/>
38+
/// itself: the calling assembly of such a helper is log4net, not the assembly that called log4net.
39+
/// <see cref="Assembly.GetCallingAssembly()"/> has to stay in the public method whose caller is
40+
/// wanted, so this class only supplies the flag and the replacement value.
41+
/// </para>
42+
/// </remarks>
43+
internal static class CallerAssembly
44+
{
45+
/// <summary>
46+
/// Whether <see cref="Assembly.GetCallingAssembly()"/> works on the current runtime.
47+
/// </summary>
48+
internal static bool IsSupported { get; } = Probe();
49+
50+
/// <summary>
51+
/// The assembly to attribute a call to when <see cref="IsSupported"/> is <see langword="false"/>.
52+
/// </summary>
53+
/// <remarks>
54+
/// <para>
55+
/// The entry assembly is the closest available stand-in: an application published with
56+
/// Native AOT is self-contained, so its loggers would almost always have ended up in the
57+
/// entry assembly's repository anyway. Hosts without a managed entry point fall back to
58+
/// log4net itself, which yields the default repository.
59+
/// </para>
60+
/// </remarks>
61+
internal static Assembly Fallback { get; } = Assembly.GetEntryAssembly() ?? typeof(CallerAssembly).Assembly;
62+
63+
private static bool Probe()
64+
{
65+
try
66+
{
67+
return Assembly.GetCallingAssembly() is not null;
68+
}
69+
catch (PlatformNotSupportedException)
70+
{
71+
return false;
72+
}
73+
}
74+
}

0 commit comments

Comments
 (0)