Stop network appenders stalling and losing events - #314
Open
FreeAndNil wants to merge 12 commits into
Open
Conversation
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
An appender sends while it holds the appender lock, so a slow sink stalls the logging call and every thread queued behind it. BackgroundSender hands the work to one thread with a bounded queue: the caller waits at most the enqueue timeout. It avoids what the RemoteSyslogAppender pump gets wrong. The queue is bounded, the whole pump body is guarded so a fault cannot pass unobserved, Close drains under one deadline and then cancels the send in flight, and drops are counted and reported. Flush(timeout) can answer honestly because its marker travels in the queue. Nothing the pump thread calls may throw, the error handler included, since an escaping exception there would take the process down. No appender uses it yet.
FreeAndNil
force-pushed
the
Feature/314-background-sender
branch
from
September 1, 2026 19:13
484f05f to
28e48b0
Compare
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
The next release adds public API, so it is a minor one. scripts/update-version.ps1 assumes the old version is the released one, so it also set Log4NetPackageVersion and the examples version to 3.4.1; both belong at 3.4.0. package-lock.json is not covered by the script at all.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
Tests had no access to IsFatal or the EnsureNotNull family and hand-rolled the checks instead. Linking it needs NotNullAttribute and ValidatedNotNullAttribute too, or the compiler reports CS0122.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
SmtpClient.Timeout defaults to 100 seconds and the appender never set it. The mail goes out under the appender lock, so a server that accepts the connection and then stops answering suspended every thread logging through the appender for two minutes. There is no value meaning "wait forever": SmtpClient rejects a negative timeout and treats 0 as "do not wait", so SendTimeoutMillis rejects both.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
An appender sends while it holds the appender lock, so a slow sink stalls the logging call and every thread queued behind it. BackgroundSender hands the work to one thread with a bounded queue: the caller waits at most the enqueue timeout. It avoids what the RemoteSyslogAppender pump gets wrong. The queue is bounded, the whole pump body is guarded so a fault cannot pass unobserved, Close drains under one deadline and then cancels the send in flight, and drops are counted and reported. Flush(timeout) can answer honestly because its marker travels in the queue. Nothing the pump thread calls may throw, the error handler included, since an escaping exception there would take the process down. No appender uses it yet.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
The next release adds public API, so it is a minor one. scripts/update-version.ps1 assumes the old version is the released one, so it also set Log4NetPackageVersion and the examples version to 3.4.1; both belong at 3.4.0. package-lock.json is not covered by the script at all.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
Tests had no access to IsFatal or the EnsureNotNull family and hand-rolled the checks instead. Linking it needs NotNullAttribute and ValidatedNotNullAttribute too, or the compiler reports CS0122.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
SmtpClient.Timeout defaults to 100 seconds and the appender never set it. The mail goes out under the appender lock, so a server that accepts the connection and then stops answering suspended every thread logging through the appender for two minutes. There is no value meaning "wait forever": SmtpClient rejects a negative timeout and treats 0 as "do not wait", so SendTimeoutMillis rejects both.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
MailKit waits 100 seconds per operation by default and the mail goes out under the appender lock, so an unresponsive server suspended every thread logging through it. SendTimeoutMillis is a deadline for the whole send, passed as a CancellationToken: a per operation timeout still permits a multiple of itself overall. Measured, a server delaying 2s per step finished in 14.2s against a 3s per operation timeout, and in 3.1s against a 3s deadline. Disconnect keeps no token, as it runs in the finally and would otherwise replace the failure that got us there.
FreeAndNil
force-pushed
the
Feature/314-background-sender
branch
from
September 1, 2026 19:41
e0af0e1 to
869b7fb
Compare
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
The mail went out under the appender lock, so the thread that logged, and every thread behind it, waited for the SMTP server. It is now handed to a BackgroundSender holding at most SendQueueSize mails (500), and a logging call waits at most EnqueueTimeoutMillis (5000) for room. Behaviour changes: failures reach the error handler after the logging call has returned, queued mail is lost if the process is killed, and Flush now honours its timeout. A failed send no longer reports the queue-pressure message as well, which was wrong: nothing was dropped for lack of room.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
Flush(int) returned true unconditionally. For a lossy appender, flushing does nothing at all and every buffered event stays in the buffer, so the answer was simply untrue. The timeout stays unused and is now documented as such: IFlushable already says it only applies to appenders that send asynchronously.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
The appender sends through the connection its pump owns and never touched the one inherited from UdpAppender, so that socket existed only to bind localPort a second time and be closed again at shutdown. Connecting also moved inside the guard, with a message of its own. It sat outside, so a failure ended the pump unobserved and every later event queued behind a sender that was no longer running.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
The appender's own pump held an unbounded queue, so a syslog server that stopped accepting datagrams grew it until the process ran out of memory. Shutdown waited five seconds and then abandoned a drain that had no limit of its own. It now holds SendQueueSize datagrams (500), a logging call waits at most EnqueueTimeoutMillis (5000) for room, losses are counted, and Flush honours its timeout: this appender sends asynchronously, so unlike a buffering one the timeout means something here.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
Retrying after a rolled back transaction was added in 3.4.0 to save the events around the one the database rejected. A batch that fails outright, a missing table or permission, then cost one round trip per event: measured 21 for a batch of 20, so 513 for a full buffer where there had been 1. It gives up after five consecutive failures. Failures spread through a batch do not count towards that, so a single rejected event still costs only itself. SendBuffer contains per-event failures and returns normally, so the retry loop had no way to tell one from a success. A private flag gives it one.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
Flush(bool) only moves the buffer into the queue; Flush(int) waits for the sender. The test used the first and asserted immediately, so it passed on Linux and failed on macOS and Windows. RequiresALayout asserted that nothing was sent without waiting either, which an asynchronous send makes true regardless.
FreeAndNil
added a commit
that referenced
this pull request
Sep 1, 2026
Flush(bool) only moves the buffer into the queue; Flush(int) waits for the sender. The test used the first and asserted immediately, so it passed on Linux and failed on macOS and Windows. RequiresALayout asserted that nothing was sent without waiting either, which an asynchronous send makes true regardless.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
An appender sends while it holds the appender lock, so a slow sink stalls the logging call and every thread queued behind it. BackgroundSender hands the work to one thread with a bounded queue: the caller waits at most the enqueue timeout. It avoids what the RemoteSyslogAppender pump gets wrong. The queue is bounded, the whole pump body is guarded so a fault cannot pass unobserved, Close drains under one deadline and then cancels the send in flight, and drops are counted and reported. Flush(timeout) can answer honestly because its marker travels in the queue. Nothing the pump thread calls may throw, the error handler included, since an escaping exception there would take the process down. No appender uses it yet.
FreeAndNil
force-pushed
the
Feature/314-background-sender
branch
from
September 2, 2026 04:32
61c46dc to
b5cdd54
Compare
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The next release adds public API, so it is a minor one. scripts/update-version.ps1 assumes the old version is the released one, so it also set Log4NetPackageVersion and the examples version to 3.4.1; both belong at 3.4.0. package-lock.json is not covered by the script at all.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Tests had no access to IsFatal or the EnsureNotNull family and hand-rolled the checks instead. Linking it needs NotNullAttribute and ValidatedNotNullAttribute too, or the compiler reports CS0122.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
SmtpClient.Timeout defaults to 100 seconds and the appender never set it. The mail goes out under the appender lock, so a server that accepts the connection and then stops answering suspended every thread logging through the appender for two minutes. There is no value meaning "wait forever": SmtpClient rejects a negative timeout and treats 0 as "do not wait", so SendTimeoutMillis rejects both.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
MailKit waits 100 seconds per operation by default and the mail goes out under the appender lock, so an unresponsive server suspended every thread logging through it. SendTimeoutMillis is a deadline for the whole send, passed as a CancellationToken: a per operation timeout still permits a multiple of itself overall. Measured, a server delaying 2s per step finished in 14.2s against a 3s per operation timeout, and in 3.1s against a 3s deadline. Disconnect keeps no token, as it runs in the finally and would otherwise replace the failure that got us there.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The mail went out under the appender lock, so the thread that logged, and every thread behind it, waited for the SMTP server. It is now handed to a BackgroundSender holding at most SendQueueSize mails (500), and a logging call waits at most EnqueueTimeoutMillis (5000) for room. Behaviour changes: failures reach the error handler after the logging call has returned, queued mail is lost if the process is killed, and Flush now honours its timeout. A failed send no longer reports the queue-pressure message as well, which was wrong: nothing was dropped for lack of room.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Flush(int) returned true unconditionally. For a lossy appender, flushing does nothing at all and every buffered event stays in the buffer, so the answer was simply untrue. The timeout stays unused and is now documented as such: IFlushable already says it only applies to appenders that send asynchronously.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The appender sends through the connection its pump owns and never touched the one inherited from UdpAppender, so that socket existed only to bind localPort a second time and be closed again at shutdown. Connecting also moved inside the guard, with a message of its own. It sat outside, so a failure ended the pump unobserved and every later event queued behind a sender that was no longer running.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The appender's own pump held an unbounded queue, so a syslog server that stopped accepting datagrams grew it until the process ran out of memory. Shutdown waited five seconds and then abandoned a drain that had no limit of its own. It now holds SendQueueSize datagrams (500), a logging call waits at most EnqueueTimeoutMillis (5000) for room, losses are counted, and Flush honours its timeout: this appender sends asynchronously, so unlike a buffering one the timeout means something here.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Retrying after a rolled back transaction was added in 3.4.0 to save the events around the one the database rejected. A batch that fails outright, a missing table or permission, then cost one round trip per event: measured 21 for a batch of 20, so 513 for a full buffer where there had been 1. It gives up after five consecutive failures. Failures spread through a batch do not count towards that, so a single rejected event still costs only itself. SendBuffer contains per-event failures and returns normally, so the retry loop had no way to tell one from a success. A private flag gives it one.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Tests had no access to IsFatal or the EnsureNotNull family and hand-rolled the checks instead. Linking it needs NotNullAttribute and ValidatedNotNullAttribute too, or the compiler reports CS0122.
FreeAndNil
force-pushed
the
Feature/314-background-sender
branch
from
September 2, 2026 19:25
1f4b874 to
147d343
Compare
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
SmtpClient.Timeout defaults to 100 seconds and the appender never set it. The mail goes out under the appender lock, so a server that accepts the connection and then stops answering suspended every thread logging through the appender for two minutes. There is no value meaning "wait forever": SmtpClient rejects a negative timeout and treats 0 as "do not wait", so SendTimeoutMillis rejects both.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
MailKit waits 100 seconds per operation by default and the mail goes out under the appender lock, so an unresponsive server suspended every thread logging through it. SendTimeoutMillis is a deadline for the whole send, passed as a CancellationToken: a per operation timeout still permits a multiple of itself overall. Measured, a server delaying 2s per step finished in 14.2s against a 3s per operation timeout, and in 3.1s against a 3s deadline. Disconnect keeps no token, as it runs in the finally and would otherwise replace the failure that got us there.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The mail went out under the appender lock, so the thread that logged, and every thread behind it, waited for the SMTP server. It is now handed to a BackgroundSender holding at most SendQueueSize mails (500), and a logging call waits at most EnqueueTimeoutMillis (5000) for room. Behaviour changes: failures reach the error handler after the logging call has returned, queued mail is lost if the process is killed, and Flush now honours its timeout. A failed send no longer reports the queue-pressure message as well, which was wrong: nothing was dropped for lack of room.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Flush(int) returned true unconditionally. For a lossy appender, flushing does nothing at all and every buffered event stays in the buffer, so the answer was simply untrue. The timeout stays unused and is now documented as such: IFlushable already says it only applies to appenders that send asynchronously.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The appender sends through the connection its pump owns and never touched the one inherited from UdpAppender, so that socket existed only to bind localPort a second time and be closed again at shutdown. Connecting also moved inside the guard, with a message of its own. It sat outside, so a failure ended the pump unobserved and every later event queued behind a sender that was no longer running.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
The appender's own pump held an unbounded queue, so a syslog server that stopped accepting datagrams grew it until the process ran out of memory. Shutdown waited five seconds and then abandoned a drain that had no limit of its own. It now holds SendQueueSize datagrams (500), a logging call waits at most EnqueueTimeoutMillis (5000) for room, losses are counted, and Flush honours its timeout: this appender sends asynchronously, so unlike a buffering one the timeout means something here.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Retrying after a rolled back transaction was added in 3.4.0 to save the events around the one the database rejected. A batch that fails outright, a missing table or permission, then cost one round trip per event: measured 21 for a batch of 20, so 513 for a full buffer where there had been 1. It gives up after five consecutive failures. Failures spread through a batch do not count towards that, so a single rejected event still costs only itself. SendBuffer contains per-event failures and returns normally, so the retry loop had no way to tell one from a success. A private flag gives it one.
FreeAndNil
added a commit
that referenced
this pull request
Sep 2, 2026
Flush(bool) only moves the buffer into the queue; Flush(int) waits for the sender. The test used the first and asserted immediately, so it passed on Linux and failed on macOS and Windows. RequiresALayout asserted that nothing was sent without waiting either, which an asynchronous send makes true regardless.
FreeAndNil
marked this pull request as ready for review
September 2, 2026 19:26
An appender sends while it holds the appender lock, so a slow sink stalls the logging call and every thread queued behind it. BackgroundSender hands the work to one thread with a bounded queue: the caller waits at most the enqueue timeout. It avoids what the RemoteSyslogAppender pump gets wrong. The queue is bounded, the whole pump body is guarded so a fault cannot pass unobserved, Close drains under one deadline and then cancels the send in flight, and drops are counted and reported. Flush(timeout) can answer honestly because its marker travels in the queue. Nothing the pump thread calls may throw, the error handler included, since an escaping exception there would take the process down. No appender uses it yet.
The next release adds public API, so it is a minor one. scripts/update-version.ps1 assumes the old version is the released one, so it also set Log4NetPackageVersion and the examples version to 3.4.1; both belong at 3.4.0. package-lock.json is not covered by the script at all.
Tests had no access to IsFatal or the EnsureNotNull family and hand-rolled the checks instead. Linking it needs NotNullAttribute and ValidatedNotNullAttribute too, or the compiler reports CS0122.
SmtpClient.Timeout defaults to 100 seconds and the appender never set it. The mail goes out under the appender lock, so a server that accepts the connection and then stops answering suspended every thread logging through the appender for two minutes. There is no value meaning "wait forever": SmtpClient rejects a negative timeout and treats 0 as "do not wait", so SendTimeoutMillis rejects both.
MailKit waits 100 seconds per operation by default and the mail goes out under the appender lock, so an unresponsive server suspended every thread logging through it. SendTimeoutMillis is a deadline for the whole send, passed as a CancellationToken: a per operation timeout still permits a multiple of itself overall. Measured, a server delaying 2s per step finished in 14.2s against a 3s per operation timeout, and in 3.1s against a 3s deadline. Disconnect keeps no token, as it runs in the finally and would otherwise replace the failure that got us there.
The mail went out under the appender lock, so the thread that logged, and every thread behind it, waited for the SMTP server. It is now handed to a BackgroundSender holding at most SendQueueSize mails (500), and a logging call waits at most EnqueueTimeoutMillis (5000) for room. Behaviour changes: failures reach the error handler after the logging call has returned, queued mail is lost if the process is killed, and Flush now honours its timeout. A failed send no longer reports the queue-pressure message as well, which was wrong: nothing was dropped for lack of room.
Flush(int) returned true unconditionally. For a lossy appender, flushing does nothing at all and every buffered event stays in the buffer, so the answer was simply untrue. The timeout stays unused and is now documented as such: IFlushable already says it only applies to appenders that send asynchronously.
The appender sends through the connection its pump owns and never touched the one inherited from UdpAppender, so that socket existed only to bind localPort a second time and be closed again at shutdown. Connecting also moved inside the guard, with a message of its own. It sat outside, so a failure ended the pump unobserved and every later event queued behind a sender that was no longer running.
The appender's own pump held an unbounded queue, so a syslog server that stopped accepting datagrams grew it until the process ran out of memory. Shutdown waited five seconds and then abandoned a drain that had no limit of its own. It now holds SendQueueSize datagrams (500), a logging call waits at most EnqueueTimeoutMillis (5000) for room, losses are counted, and Flush honours its timeout: this appender sends asynchronously, so unlike a buffering one the timeout means something here.
Retrying after a rolled back transaction was added in 3.4.0 to save the events around the one the database rejected. A batch that fails outright, a missing table or permission, then cost one round trip per event: measured 21 for a batch of 20, so 513 for a full buffer where there had been 1. It gives up after five consecutive failures. Failures spread through a batch do not count towards that, so a single rejected event still costs only itself. SendBuffer contains per-event failures and returns normally, so the retry loop had no way to tell one from a success. A private flag gives it one.
Flush(bool) only moves the buffer into the queue; Flush(int) waits for the sender. The test used the first and asserted immediately, so it passed on Linux and failed on macOS and Windows. RequiresALayout asserted that nothing was sent without waiting either, which an asynchronous send makes true regardless.
FreeAndNil
force-pushed
the
Feature/314-background-sender
branch
from
September 2, 2026 21:32
147d343 to
46affe1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups from the second security scan, on top of #313.
Adds
log4net.Util.BackgroundSender: one sending thread, a bounded queue,shutdown under a single deadline, counted losses, and a
Flushthat can answerhonestly. The MailKit
SmtpAppenderandRemoteSyslogAppenderuse it.SmtpAppenders bound one send to 15s. MailKit's own timeout is peroperation, not a budget, so the MailKit one uses a
CancellationTokendeadline:measured 14.2s overall against a 3s per-operation timeout.
RemoteSyslogAppenderreplaces its own pump, whose queue was unbounded and whoseconnect sat outside the guard, so a failure there ended it unobserved. It also
stops opening a second UDP socket it never used.
AdoNetAppender: the 3.4.0 per-event retry cost 513 round trips for a batch thatfails outright, measured. Capped at five consecutive failures.
Flush(int)now reports false when events are still buffered, which is what alossy appender always is.
BackgroundSenderis public but [EditorBrowsable(Never)] - because log4net.Ext.Mail is deliberately not strong named and so cannot be a friend assembly.Behaviour changes worth a look: mail failures reach the error handler after the
logging call has returned, queued mail is lost if the process is killed, and
Flushreturns false where it returned true.Also renames the unreleased 3.4.1 to 3.5.0, since this adds public API.