This is an automated email from the ASF dual-hosted git repository.

FreeAndNil pushed a commit to branch Feature/security-audit-hardening
in repository https://gitbox.apache.org/repos/asf/logging-log4net.git

commit 3fd97cb511b2a99b3b4b6957ea606a2e5a6ab804
Author: Jan Friedrich <[email protected]>
AuthorDate: Mon Aug 17 23:52:21 2026 +0200

    bound the waits for the file locking mutexes
    
    InterProcessLock.AcquireLock carried a "TODO: add timeout?" and waited
    without one, as did RollingFileAppender when deciding whether to roll. Both
    waits happen while the appender lock is held, so a mutex nobody releases
    suspended every thread logging through the appender.
    
    Both now wait at most LockTimeoutMillis, 10000 by default, with
    Timeout.Infinite restoring the previous behaviour. An event that cannot get
    the file lock is reported and dropped; one that cannot get the rolling lock
    is written to the current file without the roll check, because rolling
    without the lock would race another process renaming the same files.
    
    Two more problems turned up on the way:
    
    AbandonedMutexException was unhandled although it means the wait succeeded
    and this thread owns the mutex. It propagated out of AcquireLock before
    _recursiveWatch was incremented, so ReleaseLock never released it and the
    mutex stayed held for good. That needs no attacker, only a process dying
    mid-write.
    
    AdjustFileBeforeAppend released the rolling mutex in a finally without
    checking that it had been taken. Harmless while the wait could only succeed,
    but a bounded wait makes it throw, so it is guarded now.
    
    The mutex names are left alone. They are derived from the log file path so
    that separate processes agree on them, and making them unpredictable would
    break the cross-process coordination they exist for.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 src/changelog/3.4.0/309-bound-file-lock-waits.xml  | 17 +++++
 src/log4net.Tests/Appender/FileAppenderTest.cs     | 80 ++++++++++++++++++++++
 src/log4net/Appender/FileAppender.cs               | 55 ++++++++++++++-
 src/log4net/Appender/RollingFileAppender.cs        | 64 ++++++++++++++++-
 .../configuration/appenders/fileappender.adoc      | 34 ++++++++-
 5 files changed, 244 insertions(+), 6 deletions(-)

diff --git a/src/changelog/3.4.0/309-bound-file-lock-waits.xml 
b/src/changelog/3.4.0/309-bound-file-lock-waits.xml
new file mode 100644
index 00000000..fb80fad5
--- /dev/null
+++ b/src/changelog/3.4.0/309-bound-file-lock-waits.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+       xmlns="https://logging.apache.org/xml/ns";
+       xsi:schemaLocation="https://logging.apache.org/xml/ns 
https://logging.apache.org/xml/ns/log4j-changelog-0.xsd";
+       type="changed">
+  <issue id="309" link="https://github.com/apache/logging-log4net/pull/309"/>
+  <description format="asciidoc">
+    bound the waits for the named mutexes used by 
`FileAppender.InterProcessLock` and by
+ `RollingFileAppender` when it decides whether to roll. Both waited without a 
timeout while the
+ appender lock was held, so a mutex nobody released suspended every thread 
logging through the
+ appender. The wait now stops after `LockTimeoutMillis`, 10000 by default, 
reporting through the
+ error handler and dropping the event or skipping the roll check; 
`Timeout.Infinite` restores the
+ previous behaviour. `AbandonedMutexException` is handled as the successful 
acquisition it is,
+ rather than leaving the mutex held, and the rolling lock is no longer 
released when it was never
+ taken (audit 1231d72-f015)
+  </description>
+</entry>
diff --git a/src/log4net.Tests/Appender/FileAppenderTest.cs 
b/src/log4net.Tests/Appender/FileAppenderTest.cs
index 8537f80d..7fa095c5 100644
--- a/src/log4net.Tests/Appender/FileAppenderTest.cs
+++ b/src/log4net.Tests/Appender/FileAppenderTest.cs
@@ -30,7 +30,9 @@
 using System.IO;
 using System.Linq;
 using System.Text;
+using System.Threading;
 using System.Threading.Tasks;
+using System.Diagnostics;
 
 namespace log4net.Tests.Appender;
 
@@ -183,4 +185,82 @@ public void 
InterProcessLock_AcquireLock_ReleasesMutexWhenStreamIsNull()
       File.Delete(tempFile);
     }
   }
+
+  /// <summary>
+  /// The wait for the inter process lock happens while the appender lock is 
held, so it has to be
+  /// bounded by default: a lock nobody releases would otherwise suspend all 
logging for good.
+  /// </summary>
+  [Test]
+  public void LockTimeoutMillisDefaultsToAFiniteValue()
+    => Assert.That(new FileAppender.InterProcessLock().LockTimeoutMillis, 
Is.EqualTo(10000));
+
+  /// <summary>
+  /// Timeout.Infinite restores waiting indefinitely and 0 gives up at once; 
any other negative
+  /// value has no meaning and is rejected rather than reinterpreted.
+  /// </summary>
+  [Test]
+  public void LockTimeoutMillisRejectsNegativeValuesExceptInfinite()
+  {
+    FileAppender.InterProcessLock lockingModel = new();
+
+    Assert.That(() => lockingModel.LockTimeoutMillis = -2, 
Throws.TypeOf<ArgumentOutOfRangeException>());
+
+    lockingModel.LockTimeoutMillis = Timeout.Infinite;
+    Assert.That(lockingModel.LockTimeoutMillis, Is.EqualTo(Timeout.Infinite));
+
+    lockingModel.LockTimeoutMillis = 0;
+    Assert.That(lockingModel.LockTimeoutMillis, Is.EqualTo(0));
+  }
+
+  /// <summary>
+  /// When something else holds the lock and does not let go, the event is 
dropped rather than the
+  /// logging thread being blocked forever.
+  /// </summary>
+  [Test]
+  [NonParallelizable]
+  public void AcquireLockGivesUpWhenTheLockIsHeldTooLong()
+  {
+    const string appenderFile = "log4net_lock_timeout_test";
+    string tempFile = Path.GetTempFileName();
+    FileAppender appender = new() { File = appenderFile };
+    FileAppender.InterProcessLock lockingModel = new()
+    {
+      CurrentAppender = appender,
+      LockTimeoutMillis = 200
+    };
+    lockingModel.ActivateOptions();
+    lockingModel.OpenFile(tempFile, false, Encoding.UTF8);
+
+    using ManualResetEventSlim held = new();
+    using ManualResetEventSlim release = new();
+    // A mutex has to be taken and released on one thread, so the holder does 
both.
+    Task holder = Task.Run(() =>
+    {
+      using Mutex contender = new(false, appenderFile);
+      contender.WaitOne();
+      held.Set();
+      release.Wait();
+      contender.ReleaseMutex();
+    });
+
+    try
+    {
+      Assert.That(held.Wait(TimeSpan.FromSeconds(10)), Is.True, "the 
contending thread never took the mutex");
+
+      Stream? stream = null;
+      Stopwatch stopwatch = Stopwatch.StartNew();
+      LogLog.ExecuteWithoutEmittingInternalMessages(() => stream = 
lockingModel.AcquireLock());
+      stopwatch.Stop();
+
+      Assert.That(stream, Is.Null, "the lock was reported as acquired while 
another thread held it");
+      Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(10)));
+    }
+    finally
+    {
+      release.Set();
+      holder.Wait(TimeSpan.FromSeconds(10));
+      lockingModel.OnClose();
+      File.Delete(tempFile);
+    }
+  }
 }
\ No newline at end of file
diff --git a/src/log4net/Appender/FileAppender.cs 
b/src/log4net/Appender/FileAppender.cs
index 993e2974..5887fbfe 100644
--- a/src/log4net/Appender/FileAppender.cs
+++ b/src/log4net/Appender/FileAppender.cs
@@ -576,6 +576,42 @@ public class InterProcessLock : LockingModelBase
     private Mutex? _mutex;
     private Stream? _stream;
     private int _recursiveWatch;
+    private int _lockTimeoutMillis = 10_000;
+
+    /// <summary>
+    /// Gets or sets the time, in milliseconds, to wait for the lock before 
giving up on an event.
+    /// </summary>
+    /// <value>
+    /// A number of milliseconds, 0 to fail immediately when the lock is held, 
or
+    /// <see cref="Timeout.Infinite"/> to wait for as long as it takes.
+    /// </value>
+    /// <remarks>
+    /// <para>
+    /// The wait happens while the appender lock is held, so a lock nobody 
releases would otherwise
+    /// suspend every thread logging through this appender. An event that 
cannot get the lock in
+    /// time is reported and dropped instead.
+    /// </para>
+    /// <para>
+    /// The default value is 10000. Raise it when the file is on storage where 
the lock is
+    /// legitimately slow to obtain, such as a network share.
+    /// </para>
+    /// </remarks>
+    /// <exception cref="ArgumentOutOfRangeException">
+    /// The value specified is negative and is not <see 
cref="Timeout.Infinite"/>.
+    /// </exception>
+    public int LockTimeoutMillis
+    {
+      get => _lockTimeoutMillis;
+      set
+      {
+        if (value < 0 && value != Timeout.Infinite)
+        {
+          throw SystemInfo.CreateArgumentOutOfRangeException(nameof(value), 
value,
+            $"The value specified for LockTimeoutMillis is negative and is not 
{nameof(Timeout)}.{nameof(Timeout.Infinite)}.");
+        }
+        _lockTimeoutMillis = value;
+      }
+    }
 
     /// <summary>
     /// Open the file specified and prepare for logging.
@@ -641,8 +677,23 @@ public override void CloseFile()
     {
       if (_mutex is not null)
       {
-        // TODO: add timeout?
-        _mutex.WaitOne();
+        bool acquired;
+        try
+        {
+          acquired = _mutex.WaitOne(LockTimeoutMillis);
+        }
+        catch (AbandonedMutexException)
+        {
+          // Previous owner died without releasing; the wait succeeded and we 
own the mutex.
+          acquired = true;
+        }
+
+        if (!acquired)
+        {
+          CurrentAppender?.ErrorHandler.Error(
+            $"Timeout after {LockTimeoutMillis}ms waiting for the inter 
process lock on the log file, so the logging event was not written.");
+          return null;
+        }
 
         // increment recursive watch
         _recursiveWatch++;
diff --git a/src/log4net/Appender/RollingFileAppender.cs 
b/src/log4net/Appender/RollingFileAppender.cs
index 55b30078..a15e8f97 100644
--- a/src/log4net/Appender/RollingFileAppender.cs
+++ b/src/log4net/Appender/RollingFileAppender.cs
@@ -518,10 +518,32 @@ protected override void Append(LoggingEvent[] 
loggingEvents)
   protected virtual void AdjustFileBeforeAppend()
   {
     // reuse the file appenders locking model to lock the rolling
+    bool acquired = false;
     try
     {
       // if rolling should be locked, acquire the lock
-      _mutexForRolling?.WaitOne();
+      if (_mutexForRolling is not null)
+      {
+        try
+        {
+          acquired = _mutexForRolling.WaitOne(LockTimeoutMillis);
+        }
+        catch (AbandonedMutexException)
+        {
+          // Previous owner died without releasing; the wait succeeded and we 
own the mutex.
+          acquired = true;
+        }
+
+        if (!acquired)
+        {
+          // Rolling without the lock would race another process renaming the 
same files, so append
+          // to the current file instead of waiting, which would suspend every 
logging thread.
+          ErrorHandler.Error(
+            $"Timeout after {LockTimeoutMillis}ms waiting for the lock on 
rolling {File}, so this event was written without checking whether the file 
should roll.");
+          return;
+        }
+      }
+
       if (_rollDate)
       {
         DateTime n = DateTimeStrategy.Now;
@@ -541,8 +563,11 @@ protected virtual void AdjustFileBeforeAppend()
     }
     finally
     {
-      // if rolling should be locked, release the lock
-      _mutexForRolling?.ReleaseMutex();
+      // Only when the wait succeeded: releasing a mutex this thread does not 
own throws.
+      if (acquired)
+      {
+        _mutexForRolling!.ReleaseMutex();
+      }
     }
   }
 
@@ -1516,6 +1541,39 @@ protected static DateTime NextCheckDate(DateTime 
currentDateTime, RollPoint roll
   /// </summary>
   private Mutex? _mutexForRolling;
 
+  private int _lockTimeoutMillis = 10_000;
+
+  /// <summary>
+  /// Gets or sets the time, in milliseconds, to wait for the rolling lock 
before appending without
+  /// checking whether the file should roll.
+  /// </summary>
+  /// <value>
+  /// A number of milliseconds, 0 to give up immediately when the lock is 
held, or
+  /// <see cref="Timeout.Infinite"/> to wait for as long as it takes.
+  /// </value>
+  /// <remarks>
+  /// <para>
+  /// The wait happens while the appender lock is held, so a lock nobody 
releases would otherwise
+  /// suspend every thread logging through this appender. The default value is 
10000.
+  /// </para>
+  /// </remarks>
+  /// <exception cref="ArgumentOutOfRangeException">
+  /// The value specified is negative and is not <see 
cref="Timeout.Infinite"/>.
+  /// </exception>
+  public int LockTimeoutMillis
+  {
+    get => _lockTimeoutMillis;
+    set
+    {
+      if (value < 0 && value != Timeout.Infinite)
+      {
+        throw SystemInfo.CreateArgumentOutOfRangeException(nameof(value), 
value,
+          $"The value specified for LockTimeoutMillis is negative and is not 
{nameof(Timeout)}.{nameof(Timeout.Infinite)}.");
+      }
+      _lockTimeoutMillis = value;
+    }
+  }
+
   /// <summary>
   /// The 1st of January 1970 in UTC
   /// </summary>
diff --git 
a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/fileappender.adoc
 
b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/fileappender.adoc
index e05d8d3e..a64571d0 100644
--- 
a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/fileappender.adoc
+++ 
b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/fileappender.adoc
@@ -63,4 +63,36 @@ This example shows how to configure the appender to use the 
minimal locking mode
     <conversionPattern value="%date [%thread] %-5level %logger 
%message%newline" />
   </layout>
 </appender>
-----
\ No newline at end of file
+----
+
+[#fileappender-lock-timeout]
+== Waiting for the lock
+
+`InterProcessLock` coordinates several processes writing to one file through a 
named mutex.
+The wait for it happens while the appender lock is held, so a lock nobody 
releases would suspend
+every thread logging through the appender.
+
+The wait is therefore bounded by `lockTimeoutMillis`, 10000 by default.
+An event that cannot get the lock in time is reported through the error 
handler and dropped, and the
+appender carries on with the next one.
+Raise the value when the file is on storage where the lock is legitimately 
slow to obtain, such as a
+network share, or set it to `-1` to wait for as long as it takes.
+
+[source,xml]
+----
+<appender name="FileAppender" type="log4net.Appender.FileAppender">
+  <file value="MyApp.log" />
+  <appendToFile value="true" />
+  <lockingModel type="log4net.Appender.FileAppender+InterProcessLock">
+    <lockTimeoutMillis value="10000" />
+  </lockingModel>
+  <layout type="log4net.Layout.PatternLayout">
+    <conversionPattern value="%date [%thread] %-5level %logger 
%message%newline" />
+  </layout>
+</appender>
+----
+
+`RollingFileAppender` takes a second mutex around the decision to roll and has 
its own
+`lockTimeoutMillis` for it, with the same default.
+An event that cannot get that lock is written to the current file without 
checking whether it should
+roll first, rather than waiting.
\ No newline at end of file

Reply via email to