Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/Sentinel.NLogViewer.App/Models/StartListeningResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Sentinel.NLogViewer.App.Models;

/// <summary>
/// Result of starting the UDP listener(s).
/// </summary>
public sealed class StartListeningResult
{
/// <summary>
/// Whether at least one listener was started successfully.
/// </summary>
public bool AnyStarted { get; }

/// <summary>
/// Aggregated error message for failed addresses; when port is in use, may include process name and PID.
/// </summary>
public string ErrorMessage { get; }

/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="anyStarted">True if at least one listener started.</param>
/// <param name="errorMessage">Error message for failures (e.g. address already in use, process name/PID).</param>
public StartListeningResult(bool anyStarted, string errorMessage)
{
AnyStarted = anyStarted;
ErrorMessage = errorMessage ?? string.Empty;
}
}
12 changes: 12 additions & 0 deletions app/Sentinel.NLogViewer.App/Resources/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,17 @@
<data name="Status_Stopped" xml:space="preserve">
<value>Stopped</value>
</data>
<data name="Status_Error" xml:space="preserve">
<value>Error</value>
</data>
<data name="Status_SomePortsFailed" xml:space="preserve">
<value>(Some ports could not be opened.)</value>
</data>
<data name="Error_StartingListener" xml:space="preserve">
<value>Error starting listener</value>
</data>
<data name="Error_StartingListenerCaption" xml:space="preserve">
<value>Error starting listener</value>
</data>
</root>

138 changes: 138 additions & 0 deletions app/Sentinel.NLogViewer.App/Services/PortProcessResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;

namespace Sentinel.NLogViewer.App.Services;

/// <summary>
/// Resolves the process (name and PID) that is using a given port on Windows.
/// </summary>
public static class PortProcessResolver
{
/// <summary>
/// Tries to get the process name and PID that is using the given port.
/// Only supported on Windows; uses netstat output parsing.
/// </summary>
/// <param name="port">Port number (e.g. 4000).</param>
/// <param name="udp">True for UDP, false for TCP.</param>
/// <returns>Process name and PID if found; (null, null) on non-Windows, parse failure, or when process has exited.</returns>
public static (string? processName, int? pid) TryGetProcessUsingPort(int port, bool udp = true)
{
if (!OperatingSystem.IsWindows())
return (null, null);

try
{
var pid = TryGetPidFromNetStat(port, udp);
if (pid == null)
return (null, null);

string? processName = null;
try
{
using var process = Process.GetProcessById(pid.Value);
processName = process.ProcessName;
}
catch (ArgumentException)
{
// Process may have exited or access denied
}

return (processName, pid);
}
catch
{
return (null, null);
}
}

private static int? TryGetPidFromNetStat(int port, bool udp)
{
try
{
var netstatPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.System),
"netstat.exe");
if (!File.Exists(netstatPath))
netstatPath = "netstat.exe";

using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = netstatPath,
Arguments = "-a -n -o",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
// Netstat outputs in the console/OEM code page on Windows
StandardOutputEncoding = GetConsoleOutputEncoding(),
StandardErrorEncoding = GetConsoleOutputEncoding()
};
process.Start();
var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
process.WaitForExit(5000);

var protocol = udp ? "UDP" : "TCP";
// Match local address containing :port (e.g. 0.0.0.0:4000 or [::]:4000)
var portPattern = $":{port}\\b";
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);

foreach (var line in lines)
{
var trimmed = line.TrimStart();
if (!trimmed.StartsWith(protocol, StringComparison.OrdinalIgnoreCase))
continue;
if (!Regex.IsMatch(line, portPattern))
continue;

// Last column is PID; extract last integer on the line
var pid = ExtractLastPidFromLine(line);
if (pid != null)
return pid;
}
}
catch
{
// Ignore
}

return null;
}

/// <summary>
/// Extracts the last integer (PID) from a netstat line. Handles variable spacing.
/// </summary>
private static int? ExtractLastPidFromLine(string line)
{
var tokens = Regex.Split(line.Trim(), @"\s+");
for (var i = tokens.Length - 1; i >= 0; i--)
{
if (string.IsNullOrEmpty(tokens[i]))
continue;
if (int.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out var pid) && pid > 0)
return pid;
break;
}
return null;
}

private static Encoding GetConsoleOutputEncoding()
{
if (!OperatingSystem.IsWindows())
return Encoding.UTF8;
try
{
// Netstat outputs in the console (OEM) code page on Windows
var oemCodePage = CultureInfo.CurrentCulture.TextInfo.OEMCodePage;
return Encoding.GetEncoding(oemCodePage);
}
catch
{
return Encoding.Default;
}
}
}
56 changes: 50 additions & 6 deletions app/Sentinel.NLogViewer.App/Services/UdpLogReceiverService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ namespace Sentinel.NLogViewer.App.Services;
/// </summary>
public class UdpLogReceiverService(Log4JEventParser xmlParser) : IDisposable
{
private const int WSAEADDRINUSE = 10048;

private readonly List<UdpClient> _udpClients = new();
private readonly List<CancellationTokenSource> _cancellationTokens = new();
private readonly Log4JEventParser _xmlParser = xmlParser ?? throw new ArgumentNullException(nameof(xmlParser));
Expand All @@ -23,34 +25,76 @@ public class UdpLogReceiverService(Log4JEventParser xmlParser) : IDisposable
public IObservable<LogEvent> Log4JEventObservable => _log4JEventObservable;
private readonly Subject<LogEvent> _log4JEventObservable = new();

public void StartListening(List<string> addresses)
/// <summary>
/// Starts listening on the given UDP addresses. Returns a result indicating success and any error messages.
/// </summary>
/// <param name="addresses">List of addresses in format udp://host:port (e.g. udp://0.0.0.0:4000).</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <returns>Result with AnyStarted and aggregated ErrorMessage (e.g. including process name/PID when port is in use).</returns>
public async Task<StartListeningResult> StartListeningAsync(IReadOnlyList<string> addresses, CancellationToken cancellationToken = default)
{
StopListening();
var errors = new List<string>();

foreach (var address in addresses)
{
if (cancellationToken.IsCancellationRequested)
break;
var uri = new Uri(address);

try
{
var uri = new Uri(address);
if (uri.Scheme != "udp")
continue;

var port = uri.Port;
var udpClient = new UdpClient(port);
_udpClients.Add(udpClient);

var cts = new CancellationTokenSource();
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_cancellationTokens.Add(cts);

// Start receiving on this port
Task.Run(() => ReceiveLoop(udpClient, port, cts.Token), cts.Token);
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse || ex.NativeErrorCode == WSAEADDRINUSE)
{
var msg = $"Error starting listener on {address}: {ex.Message}";
var portForResolver = GetPortFromAddress(address);
if (portForResolver != null)
{
var (processName, pid) = PortProcessResolver.TryGetProcessUsingPort(portForResolver.Value, udp: true);
if (pid != null)
msg += processName != null
? $"\n\nProcess using port {uri.Port}: {processName} (PID: {pid})"
: $"\n\nPort {uri.Port} in use by process PID: {pid}";
}
errors.Add(msg);
System.Diagnostics.Debug.WriteLine(msg);
}
catch (Exception ex)
{
// Log error but continue with other ports
System.Diagnostics.Debug.WriteLine($"Error starting listener on {address}: {ex.Message}");
var msg = $"Error starting listener on {address}: {ex.Message}";
errors.Add(msg);
System.Diagnostics.Debug.WriteLine(msg);
}
}

var anyStarted = _udpClients.Count > 0;
var errorMessage = errors.Count > 0 ? string.Join(Environment.NewLine, errors) : string.Empty;
return await Task.FromResult(new StartListeningResult(anyStarted, errorMessage));
}

private static int? GetPortFromAddress(string address)
{
try
{
var uri = new Uri(address);
return uri.Port;
}
catch
{
return null;
}
}

public void StopListening()
Expand Down
41 changes: 32 additions & 9 deletions app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public MainViewModel(
LogTabs = new ObservableCollection<LogTabViewModel>();

// Initialize commands
StartListeningCommand = new RelayCommand(StartListening, () => !_isListening);
StartListeningCommand = new AsyncRelayCommand(StartListeningAsync, () => !_isListening);
StopListeningCommand = new RelayCommand(StopListening, () => _isListening);
OpenFileCommand = new RelayCommand(OpenFile);
OpenSettingsCommand = new RelayCommand(OpenSettings);
Expand Down Expand Up @@ -153,7 +153,7 @@ private set
{
_isListening = value;
OnPropertyChanged();
((RelayCommand)StartListeningCommand).RaiseCanExecuteChanged();
((AsyncRelayCommand)StartListeningCommand).RaiseCanExecuteChanged();
((RelayCommand)StopListeningCommand).RaiseCanExecuteChanged();
}
}
Expand Down Expand Up @@ -269,25 +269,48 @@ private void LoadConfiguration()
Task.Run(async () =>
{
await Task.Delay(500); // Small delay to ensure UI is ready
System.Windows.Application.Current.Dispatcher.Invoke(StartListening);
System.Windows.Application.Current.Dispatcher.Invoke(() => _ = StartListeningAsync());
});
}
}

private void StartListening()
/// <summary>
/// Starts the UDP listener asynchronously. On failure (e.g. port in use), shows a MessageBox and keeps the listener inactive.
/// </summary>
private async Task StartListeningAsync()
{
try
{
var config = _configService.LoadConfiguration();
_udpReceiverService.StartListening(config.Ports);
IsListening = true;
ListeningStatus = _localizationService.GetString("Status_Listening", "Listening");
StatusMessage = _localizationService.GetString("Status_ListeningOnPorts", $"Listening on {config.Ports.Count} port(s)");
var result = await _udpReceiverService.StartListeningAsync(config.Ports);

if (result.AnyStarted)
{
IsListening = true;
ListeningStatus = _localizationService.GetString("Status_Listening", "Listening");
StatusMessage = _localizationService.GetString("Status_ListeningOnPorts", $"Listening on {config.Ports.Count} port(s)");
if (!string.IsNullOrEmpty(result.ErrorMessage))
{
// Partial failure: some ports failed
StatusMessage += " " + _localizationService.GetString("Status_SomePortsFailed", "(Some ports could not be opened.)");
}
}
else
{
IsListening = false;
ListeningStatus = _localizationService.GetString("Status_Error", "Error");
StatusMessage = result.ErrorMessage;
var caption = _localizationService.GetString("Error_StartingListenerCaption", "Error starting listener");
MessageBox.Show(result.ErrorMessage, caption, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
catch (Exception ex)
{
StatusMessage = _localizationService.GetString("Error_StartingListener", $"Error starting listener: {ex.Message}");
IsListening = false;
ListeningStatus = _localizationService.GetString("Status_Error", "Error");
StatusMessage = _localizationService.GetString("Error_StartingListener", $"Error starting listener: {ex.Message}");
var caption = _localizationService.GetString("Error_StartingListenerCaption", "Error starting listener");
MessageBox.Show(ex.Message, caption, MessageBoxButton.OK, MessageBoxImage.Error);
}
}

Expand Down
Loading
Loading