From 6ebcda0355202c0898c6ce93bc8def163203c5d8 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Fri, 4 Sep 2026 07:36:25 +0200 Subject: [PATCH 1/3] listen on loopback by default in TelnetAppender #318 - The appender streams the rendered log to any client that connects, with no authentication and no encryption, and defaulted to every interface on port 23. - It now defaults to 127.0.0.1. Watching from another machine is opt-in: set listenAddress to 0.0.0.0 or ::. Every documented example already used loopback. - SocketHandler(port, sendTimeoutMillis) defaults the same way. Deliberate default flip: a deployment relying on the implicit all-interfaces bind loses remote access until it sets listenAddress. audit da18b6fd-f012 --- .../3.5.0/318-telnet-loopback-default.xml | 13 ++++++++++ .../Appender/TelnetAppenderTest.cs | 17 +++++++++---- src/log4net/Appender/TelnetAppender.cs | 24 ++++++++++--------- .../appenders/telnetappender.adoc | 20 +++++++++------- 4 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 src/changelog/3.5.0/318-telnet-loopback-default.xml diff --git a/src/changelog/3.5.0/318-telnet-loopback-default.xml b/src/changelog/3.5.0/318-telnet-loopback-default.xml new file mode 100644 index 00000000..2361c159 --- /dev/null +++ b/src/changelog/3.5.0/318-telnet-loopback-default.xml @@ -0,0 +1,13 @@ + + + + `TelnetAppender` now listens on `127.0.0.1` by default instead of every + interface. The stream is unauthenticated and unencrypted, so any host that could reach the port + could read the application's log, and every documented example already used loopback. Watching the + log from another machine is now opt-in: set `listenAddress` to `0.0.0.0` or `::` to restore the old + behaviour. The `SocketHandler(port, sendTimeoutMillis)` constructor defaults the same way (audit + da18b6fd-f012, implemented by @FreeAndNil) + diff --git a/src/log4net.Tests/Appender/TelnetAppenderTest.cs b/src/log4net.Tests/Appender/TelnetAppenderTest.cs index c0df92b4..9b0bb3b2 100644 --- a/src/log4net.Tests/Appender/TelnetAppenderTest.cs +++ b/src/log4net.Tests/Appender/TelnetAppenderTest.cs @@ -239,12 +239,21 @@ public void SendTimeoutMillisRejectsNegativeValuesButAllowsZero() } /// - /// The appender accepts connections on every interface unless told otherwise, which is the - /// behaviour it has always had. + /// The stream is unauthenticated, so an appender nobody configured an address for must not be + /// reachable from another machine. /// [Test] - public void ListenAddressDefaultsToEveryInterface() - => Assert.That(new TelnetAppender().ListenAddress, Is.EqualTo(IPAddress.Any)); + public void ListenAddressDefaultsToLoopback() + => Assert.That(new TelnetAppender().ListenAddress, Is.EqualTo(IPAddress.Loopback)); + + /// + /// Remote monitoring is still available, it just has to be asked for. + /// + [TestCase("::", TestName = "EveryIPv6InterfaceCanBeAskedFor")] + [TestCase("0.0.0.0", TestName = "EveryIPv4InterfaceCanBeAskedFor")] + public void EveryInterfaceCanBeAskedFor(string address) + => Assert.That(new TelnetAppender { ListenAddress = IPAddress.Parse(address) }.ListenAddress, + Is.EqualTo(IPAddress.Parse(address))); /// /// Binding to the loopback address has to keep the port unreachable from other machines, which diff --git a/src/log4net/Appender/TelnetAppender.cs b/src/log4net/Appender/TelnetAppender.cs index 903cd0d5..b38a9373 100644 --- a/src/log4net/Appender/TelnetAppender.cs +++ b/src/log4net/Appender/TelnetAppender.cs @@ -37,10 +37,11 @@ namespace log4net.Appender; /// /// The TelnetAppender accepts socket connections and streams logging messages back to the client. /// The output is provided in a telnet-friendly way so that a log can be monitored over a TCP/IP socket. -/// This allows simple remote monitoring of application logging. /// /// -/// The default is 23 (the telnet port). +/// The default is 23 (the telnet port) and the default +/// is , so monitoring from another +/// machine has to be turned on deliberately. /// /// /// This appender is a diagnostic tool for trusted networks. As with any other appender @@ -57,20 +58,20 @@ public class TelnetAppender : AppenderSkeleton private SocketHandler? _handler; private int _listeningPort = 23; private int _sendTimeoutMillis = 5_000; - private IPAddress _listenAddress = IPAddress.Any; + private IPAddress _listenAddress = IPAddress.Loopback; /// /// Gets or sets the address to listen on. /// /// - /// The local address to accept connections on. The default is , every - /// interface of the machine. + /// The local address to accept connections on. The default is , + /// the machine the application runs on. /// /// /// - /// Set this to to accept connections only from the machine the - /// application runs on, which is what the diagnostic use this appender is meant for usually - /// needs. + /// The stream is unauthenticated and unencrypted, so reaching it from another machine is opt-in: + /// set this to or for that, and keep + /// untrusted parties away from the port. /// /// /// The value specified is . @@ -183,7 +184,7 @@ public override void ActivateOptions() try { LogLog.Debug(_declaringType, $"Creating SocketHandler to listen on [{_listenAddress}]:[{_listeningPort}]"); - _handler = new SocketHandler(_listenAddress, _listeningPort, _sendTimeoutMillis); + _handler = new(_listenAddress, _listeningPort, _sendTimeoutMillis); } catch (Exception ex) { @@ -325,11 +326,12 @@ public SocketHandler(int port) /// block before that client is disconnected, or 0 to block indefinitely /// /// - /// Creates a socket handler on the specified local server port. + /// Creates a socket handler on the specified local server port, listening on + /// . /// /// public SocketHandler(int port, int sendTimeoutMillis) - : this(IPAddress.Any, port, sendTimeoutMillis) + : this(IPAddress.Loopback, port, sendTimeoutMillis) { } /// diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc index 6751ca0c..adc29c1e 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc @@ -51,10 +51,12 @@ The default is `23`, the telnet port. `listenAddress`:: The local address to accept connections on. -The default is `0.0.0.0`, every interface of the machine. +The default is `127.0.0.1`, the machine the application runs on, which is what diagnostic use +usually needs. + -Set it to `127.0.0.1` to accept connections only from the machine the application runs on, which is -what diagnostic use usually needs. +The stream is unauthenticated and unencrypted, so watching it from another machine is opt-in: set +this to `0.0.0.0` for every IPv4 interface, or `::` for every IPv6 one, and keep untrusted parties +away from the port. An IPv6 address may be given instead, and the listening socket follows its family. `sendTimeoutMillis`:: @@ -82,17 +84,17 @@ The appender therefore performs no authentication of its own. [WARNING] ==== -The connection is *unauthenticated* and *unencrypted*, and by default the appender listens on *all -network interfaces*. +The connection is *unauthenticated* and *unencrypted*. There is no option to require a credential or to enable TLS. Any client that can reach the port receives the full rendered log stream, including whatever the layout renders: user names, session identifiers, request parameters, stack traces. -Keeping untrusted parties away from the port is the operator's responsibility, exactly as it is -for a log file: +The appender listens on `127.0.0.1` only, so that stays on the local machine until you widen +`listenAddress`, and keeping untrusted parties away from the port is then the operator's +responsibility, exactly as it is for a log file: -* Set `listenAddress` to `127.0.0.1` unless clients on other machines really have to connect. -* Only enable this appender on a trusted network. +* Widen `listenAddress` only if clients on other machines really have to connect. +* Only do so on a trusted network. * Restrict access to the port with a host firewall or network policy. * Prefer it for local or short-lived diagnostics rather than as a permanent logging destination. ==== From 916e67123203bad24ba24576cbfd590d3b23aa03 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Fri, 4 Sep 2026 09:01:23 +0200 Subject: [PATCH 2/3] share one RecordingErrorHandler across the appender tests Three copies, two identical and one collecting into a joined string instead of a list. Now one internal helper beside SimpleTelnetClient and UdpMock, exposing Messages; the ANSI test asserts on that instead of its own Message property. --- .../Appender/AnsiColorTerminalAppenderTest.cs | 18 +------- .../Internal/RecordingErrorHandler.cs | 44 +++++++++++++++++++ .../Appender/RemoteSyslogAppenderTest.cs | 16 ------- .../Appender/SmtpAppenderTest.cs | 18 +------- 4 files changed, 47 insertions(+), 49 deletions(-) create mode 100644 src/log4net.Tests/Appender/Internal/RecordingErrorHandler.cs diff --git a/src/log4net.Tests/Appender/AnsiColorTerminalAppenderTest.cs b/src/log4net.Tests/Appender/AnsiColorTerminalAppenderTest.cs index 222dce26..36806f03 100644 --- a/src/log4net.Tests/Appender/AnsiColorTerminalAppenderTest.cs +++ b/src/log4net.Tests/Appender/AnsiColorTerminalAppenderTest.cs @@ -22,6 +22,7 @@ using log4net.Appender; using log4net.Core; +using log4net.Tests.Appender.Internal; using log4net.Layout; using NUnit.Framework; @@ -82,23 +83,8 @@ public void TheResetCodesGoBeforeATrailingLineBreak(string message, string expec Console.SetOut(previous); } - Assert.That(errorHandler.Message, Is.Empty, "the event must not be dropped"); + Assert.That(errorHandler.Messages, Is.Empty, "the event must not be dropped"); Assert.That(captured.ToString(), Is.EqualTo(expected)); } - /// Collects what the appender reports, so a dropped event is visible. - private sealed class RecordingErrorHandler : IErrorHandler - { - /// Everything reported so far. - internal string Message { get; private set; } = string.Empty; - - /// - public void Error(string message) => Message += message + '\n'; - - /// - public void Error(string message, Exception e) => Message += message + '\n'; - - /// - public void Error(string message, Exception? e, ErrorCode errorCode) => Message += message + '\n'; - } } diff --git a/src/log4net.Tests/Appender/Internal/RecordingErrorHandler.cs b/src/log4net.Tests/Appender/Internal/RecordingErrorHandler.cs new file mode 100644 index 00000000..d851b586 --- /dev/null +++ b/src/log4net.Tests/Appender/Internal/RecordingErrorHandler.cs @@ -0,0 +1,44 @@ +#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 log4net.Core; + +namespace log4net.Tests.Appender.Internal; + +/// +/// Collects what an appender reports instead of letting it reach the console, so a test can +/// assert on it and a provoked error adds no noise to the suite output. +/// +internal sealed class RecordingErrorHandler : IErrorHandler +{ + /// Reported messages, in the order they were reported. + internal List Messages { get; } = []; + + /// + public void Error(string message) => Messages.Add(message); + + /// + public void Error(string message, Exception e) => Messages.Add(message); + + /// + public void Error(string message, Exception? e, ErrorCode errorCode) => Messages.Add(message); +} diff --git a/src/log4net.Tests/Appender/RemoteSyslogAppenderTest.cs b/src/log4net.Tests/Appender/RemoteSyslogAppenderTest.cs index 149d191b..9eb299a2 100644 --- a/src/log4net.Tests/Appender/RemoteSyslogAppenderTest.cs +++ b/src/log4net.Tests/Appender/RemoteSyslogAppenderTest.cs @@ -51,22 +51,6 @@ private sealed class RemoteAppender : RemoteSyslogAppender internal System.Net.Sockets.UdpClient? InheritedClient => Client; } - /// Collects reported errors instead of letting them reach the console. - private sealed class RecordingErrorHandler : IErrorHandler - { - /// Reported messages. - internal List Messages { get; } = []; - - /// - public void Error(string message, Exception? e, ErrorCode errorCode) => Messages.Add(message); - - /// - public void Error(string message, Exception e) => Messages.Add(message); - - /// - public void Error(string message) => Messages.Add(message); - } - private const int FlushTimeoutMillis = 30_000; /// diff --git a/src/log4net.Tests/Appender/SmtpAppenderTest.cs b/src/log4net.Tests/Appender/SmtpAppenderTest.cs index 259cfd9c..3489567a 100644 --- a/src/log4net.Tests/Appender/SmtpAppenderTest.cs +++ b/src/log4net.Tests/Appender/SmtpAppenderTest.cs @@ -18,7 +18,6 @@ #endregion using System; -using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Sockets; @@ -27,9 +26,9 @@ using log4net.Appender; using log4net.Config; -using log4net.Core; using log4net.Layout; using log4net.Repository; +using log4net.Tests.Appender.Internal; using log4net.Util; using NUnit.Framework; @@ -120,19 +119,4 @@ public void AnUnresponsiveServerDoesNotStallTheLoggingCall() } } - /// Collects reported errors instead of letting them reach the console. - private sealed class RecordingErrorHandler : IErrorHandler - { - /// Reported messages. - internal List Messages { get; } = []; - - /// - public void Error(string message, Exception? e, ErrorCode errorCode) => Messages.Add(message); - - /// - public void Error(string message, Exception e) => Messages.Add(message); - - /// - public void Error(string message) => Messages.Add(message); - } } From 4f0e906e9460b0fb02b375e6921ec518da9fbd8f Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Fri, 4 Sep 2026 09:09:40 +0200 Subject: [PATCH 3/3] write to Telnet clients from a background thread #318 - Clients were written to serially under the appender lock, so one that stopped reading blocked every logging thread for sendTimeoutMillis. - Events are queued now, sendQueueSize 500, enqueueTimeoutMillis 50. - A full queue drops from the telnet stream, never from the log. - While the queue stays full, enqueueTimeoutMillis caps logging at 20 events/s; set it to 0 to drop immediately instead of waiting. audit da18b6fd-f014 --- .../3.5.0/318-telnet-background-send.xml | 15 ++ .../Appender/Internal/SimpleTelnetClient.cs | 18 +++ .../Appender/TelnetAppenderTest.cs | 139 ++++++++++++++++++ src/log4net/Appender/TelnetAppender.cs | 92 +++++++++++- .../appenders/telnetappender.adoc | 23 ++- 5 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 src/changelog/3.5.0/318-telnet-background-send.xml diff --git a/src/changelog/3.5.0/318-telnet-background-send.xml b/src/changelog/3.5.0/318-telnet-background-send.xml new file mode 100644 index 00000000..67a48770 --- /dev/null +++ b/src/changelog/3.5.0/318-telnet-background-send.xml @@ -0,0 +1,15 @@ + + + + Write to `TelnetAppender` clients from a background thread. Clients + were written to under the appender lock, so one that stopped reading blocked every thread that + logs for `sendTimeoutMillis`, and the writes are serial, so 20 connected clients cost 20 times + that on a single event. Events are queued now, bounded by the new `sendQueueSize` (500), and a + logging call waits at most `enqueueTimeoutMillis` (50) for room before the event is dropped from + the telnet stream, counted and reported. Only the telnet view is lost, never the log. While the + queue stays full, `enqueueTimeoutMillis` caps logging at 20 events per second; set it to 0 to drop + immediately instead of waiting (audit da18b6fd-f014, implemented by @FreeAndNil) + diff --git a/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs b/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs index bc13c8c9..02b933ee 100644 --- a/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs +++ b/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs @@ -40,6 +40,24 @@ internal sealed class SimpleTelnetClient( private readonly TcpClient _client = new(); private volatile bool _disposing; + /// + /// Connects, reads the welcome message and then stops reading, so this client's receive window + /// fills and writes to it block. The opposite of , for testing that a client + /// which stops reading cannot hold up the threads that log. + /// + internal void ConnectAndStopReading() + { + // The kernel clamps this to its minimum, 2304 bytes on Linux, so asking for less buys nothing: + // measured, 1, 128, 512 and 1024 all block the writer after the same 960 KB, while 4096 needs + // 1748 KB and 16384 needs 2364 KB. The rest of the threshold is the writer's own send buffer, + // which is not reachable from here. + _client.ReceiveBufferSize = 1_024; + _client.ReceiveTimeout = 30_000; + _client.Connect(new IPEndPoint(IPAddress.Loopback, port)); + // Reading one byte of the welcome message proves the appender accepted the connection. + _client.GetStream().Read(new byte[1], 0, 1); + } + /// /// Runs the client (in a task) /// diff --git a/src/log4net.Tests/Appender/TelnetAppenderTest.cs b/src/log4net.Tests/Appender/TelnetAppenderTest.cs index 9b0bb3b2..bd5f2572 100644 --- a/src/log4net.Tests/Appender/TelnetAppenderTest.cs +++ b/src/log4net.Tests/Appender/TelnetAppenderTest.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Sockets; @@ -30,6 +31,7 @@ using log4net.Layout; using log4net.Repository; using log4net.Tests.Appender.Internal; +using log4net.Util; using NUnit.Framework; namespace log4net.Tests.Appender; @@ -255,6 +257,143 @@ public void EveryInterfaceCanBeAskedFor(string address) => Assert.That(new TelnetAppender { ListenAddress = IPAddress.Parse(address) }.ListenAddress, Is.EqualTo(IPAddress.Parse(address))); + /// + /// Clients are written to from a background thread, so the queue has to be bounded and the wait + /// for room short: it is the only delay a connected client can impose on the application. + /// + [Test] + public void SendQueueDefaults() + { + TelnetAppender appender = new(); + + Assert.That(appender.SendQueueSize, Is.EqualTo(500)); + Assert.That(appender.EnqueueTimeoutMillis, Is.EqualTo(50)); + } + + /// + /// A queue of no size cannot hold anything, and a negative wait has no meaning. + /// + [Test] + public void SendQueueSettingsRejectMeaninglessValues() + { + TelnetAppender appender = new(); + + Assert.That(() => appender.SendQueueSize = 0, Throws.TypeOf()); + Assert.That(() => appender.EnqueueTimeoutMillis = -1, Throws.TypeOf()); + + appender.EnqueueTimeoutMillis = 0; + Assert.That(appender.EnqueueTimeoutMillis, Is.EqualTo(0)); + } + + /// + /// A client that connects and then stops reading fills its receive window, and writing to it + /// used to block the logging thread for the whole send timeout, once per client. Logging must + /// now return promptly however badly the client behaves. + /// + [Test] + [NonParallelizable] + public void SlowReadersDoNotDelayLogging() + { + const int deadReaderCount = 4; + const int sendTimeoutMillis = 2_000; + // A loopback write blocks once roughly 1 MB is outstanding against the client's 1 KB receive + // buffer, measured, so send comfortably past that. + const int eventCount = 400; + + int port = FindFreeTcpPort(); + TelnetAppender appender = new() + { + Port = port, + ListenAddress = IPAddress.Loopback, + Layout = new PatternLayout("%message%newline"), + SendTimeoutMillis = sendTimeoutMillis + }; + appender.ActivateOptions(); + + List deadReaders = []; + try + { + for (int i = 0; i < deadReaderCount; i++) + { + SimpleTelnetClient deadReader = new(_ => { }, port); + deadReaders.Add(deadReader); + deadReader.ConnectAndStopReading(); + } + + string message = new('x', 4_096); + Stopwatch stopwatch = Stopwatch.StartNew(); + LogLog.ExecuteWithoutEmittingInternalMessages(() => + { + for (int i = 0; i < eventCount; i++) + { + appender.DoAppend(CreateEvent(message)); + } + }); + stopwatch.Stop(); + + // Writing synchronously costs SendTimeoutMillis per stalled client before it is evicted, + // serially and under the appender lock: 8s here, and 100s with the 20-client cap and the + // default timeout. Queueing costs the enqueue wait at worst. + Assert.That(stopwatch.Elapsed, + Is.LessThan(TimeSpan.FromMilliseconds(deadReaderCount * sendTimeoutMillis / 2)), + "logging blocked behind clients that stopped reading"); + } + finally + { + // Let the pump finish before Close waits for a drain. + foreach (SimpleTelnetClient deadReader in deadReaders) + { + deadReader.Dispose(); + } + LogLog.ExecuteWithoutEmittingInternalMessages(appender.Close); + } + } + + /// + /// A queue that cannot keep up drops, rather than growing or making the logging thread wait. + /// The loss costs the telnet stream only, so it is counted and reported once instead of per + /// event, which would be a denial of service of its own. + /// + [Test] + [NonParallelizable] + public void AFullQueueDropsAndReportsOnce() + { + int port = FindFreeTcpPort(); + RecordingErrorHandler errorHandler = new(); + TelnetAppender appender = new() + { + Port = port, + ListenAddress = IPAddress.Loopback, + Layout = new PatternLayout("%message%newline"), + // A queue this small fills as soon as the client stops reading, and nothing waits for room. + SendQueueSize = 4, + EnqueueTimeoutMillis = 0, + ErrorHandler = errorHandler + }; + appender.ActivateOptions(); + + using SimpleTelnetClient deadReader = new(_ => { }, port); + try + { + deadReader.ConnectAndStopReading(); + + for (int i = 0; i < 200; i++) + { + appender.DoAppend(CreateEvent(new string('x', 4_096))); + } + + Assert.That(errorHandler.Messages.FindAll(m => m.IndexOf("was dropped", StringComparison.Ordinal) >= 0), + Has.Count.EqualTo(1), "the drop must be reported exactly once, however many events are lost"); + } + finally + { + appender.Close(); + } + } + + private static LoggingEvent CreateEvent(string message) + => new(new LoggingEventData { Level = Level.Info, Message = message, LoggerName = "TelnetTest" }); + /// /// Binding to the loopback address has to keep the port unreachable from other machines, which /// is what an operator asking for it wants. diff --git a/src/log4net/Appender/TelnetAppender.cs b/src/log4net/Appender/TelnetAppender.cs index b38a9373..110a4908 100644 --- a/src/log4net/Appender/TelnetAppender.cs +++ b/src/log4net/Appender/TelnetAppender.cs @@ -24,6 +24,7 @@ using System.Text; using System.IO; using System.Linq; +using System.Threading; using log4net.Appender.Internal; using log4net.Core; using log4net.Util; @@ -55,11 +56,67 @@ namespace log4net.Appender; /// Nicko Cadell public class TelnetAppender : AppenderSkeleton { + private const int CloseTimeoutMillis = 5_000; + private SocketHandler? _handler; + private BackgroundSender? _sender; private int _listeningPort = 23; private int _sendTimeoutMillis = 5_000; + private int _sendQueueSize = 500; + private int _enqueueTimeoutMillis = 50; private IPAddress _listenAddress = IPAddress.Loopback; + /// + /// Gets or sets how many rendered events may wait to be written to the clients. + /// + /// A positive number of events. The default is 500. + /// + /// + /// Clients are written to from a background thread, so that a client which stops reading cannot + /// hold up the threads that log. Events queue up while that thread works, and are dropped once + /// the queue is full, which costs the telnet stream but never the log. + /// + /// + /// The value specified is not positive. + public int SendQueueSize + { + get => _sendQueueSize; + set + { + if (value <= 0) + { + throw SystemInfo.CreateArgumentOutOfRangeException(nameof(value), value, + "The value specified for SendQueueSize is not positive."); + } + _sendQueueSize = value; + } + } + + /// + /// Gets or sets how long, in milliseconds, a logging call may wait for room in the send queue. + /// + /// A number of milliseconds, or 0 to drop immediately. The default is 50. + /// + /// + /// This is the only delay a connected client can impose on the application. While the queue + /// stays full it caps logging at 20 events per second; 0 drops immediately instead of waiting. + /// + /// + /// The value specified is negative. + public int EnqueueTimeoutMillis + { + get => _enqueueTimeoutMillis; + set + { + if (value < 0) + { + throw SystemInfo.CreateArgumentOutOfRangeException(nameof(value), value, + "The value specified for EnqueueTimeoutMillis is negative."); + } + _enqueueTimeoutMillis = value; + } + } + /// /// Gets or sets the address to listen on. /// @@ -166,10 +223,21 @@ protected override void OnClose() { base.OnClose(); + // Drain on a deadline, so a client that stopped reading cannot hold up shutdown. + if (_sender is BackgroundSender sender) + { + _sender = null; + sender.Close(CloseTimeoutMillis); + sender.Dispose(); + } + _handler?.Dispose(); _handler = null; } + /// + public override bool Flush(int millisecondsTimeout) => _sender?.Flush(millisecondsTimeout) ?? true; + /// /// This appender requires a to be set. /// @@ -185,6 +253,7 @@ public override void ActivateOptions() { LogLog.Debug(_declaringType, $"Creating SocketHandler to listen on [{_listenAddress}]:[{_listeningPort}]"); _handler = new(_listenAddress, _listeningPort, _sendTimeoutMillis); + _sender = new(nameof(TelnetAppender), SendQueueSize, SendToClients, Report); } catch (Exception ex) { @@ -193,6 +262,26 @@ public override void ActivateOptions() } } + /// + /// Writes one rendered event to every client, on the background thread. + /// + private void SendToClients(string message, CancellationToken cancellationToken) => _handler?.Send(message); + + /// + /// Reports a send failure through the appender's error handler. + /// + private void Report(string message, Exception? exception) + { + if (exception is null) + { + ErrorHandler.Error(message); + } + else + { + ErrorHandler.Error(message, exception); + } + } + /// /// Writes the logging event to each connected client. /// @@ -201,7 +290,8 @@ protected override void Append(LoggingEvent loggingEvent) { if (_handler is not null && _handler.HasConnections) { - _handler.Send(RenderLoggingEvent(loggingEvent)); + // Queued, not written: a client that stops reading must not hold up the logging thread. + _sender?.TryEnqueue(RenderLoggingEvent(loggingEvent), EnqueueTimeoutMillis); } } diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc index adc29c1e..9e01eade 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/telnetappender.adoc @@ -64,11 +64,24 @@ How long, in milliseconds, a write to a client may block before that client is t and disconnected. The default is `5000`. + -Clients are written to synchronously while the appender lock is held, so a client that connects -and then stops reading lets TCP flow control fill its receive window. -A finite timeout bounds how long that client can hold up the threads that log through this -appender. -Setting the value to `0` restores blocking indefinitely and is not recommended. +Clients are written to from a background thread, so this no longer delays the application: it is +how long a client that stopped reading holds up the stream before it is dropped. +`0` blocks indefinitely and is not recommended. + +`sendQueueSize`:: +How many rendered events may wait to be written to the clients. +The default is `500`. ++ +A full queue drops the event from the telnet stream, counted and reported once. +Only the telnet view is lost; every other appender still receives the event. + +`enqueueTimeoutMillis`:: +How long, in milliseconds, a logging call may wait for room in the send queue. +The default is `50`. ++ +This is the only delay a client can impose on the application. +While the queue stays full it caps logging at 20 events per second; `0` drops immediately instead +of waiting. [#telnetappender-trust] == Intended use and trust model