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
93 changes: 54 additions & 39 deletions src/LogExpert.Core/Classes/Persister/SessionFileValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public static SessionValidationResult ValidateSession (SessionData sessionData,
// Cache drive letters once to avoid repeated expensive DriveInfo.GetDrives() calls
var cachedDriveLetters = GetFixedDriveLetters();

// Enumerate the session directory and its immediate subdirectories once, instead of
// re-enumerating for every missing file. This is shared across all alternative-path lookups.
var sessionDirectories = GetSessionSearchDirectories(sessionData.SessionFilePath);

foreach (var fileName in sessionData.FileNames)
{
var normalizedPath = NormalizeFilePath(fileName);
Expand All @@ -60,7 +64,7 @@ public static SessionValidationResult ValidateSession (SessionData sessionData,
{
result.MissingFiles.Add(fileName);

var alternativePaths = FindAlternativePaths(fileName, sessionData.SessionFilePath, cachedDriveLetters);
var alternativePaths = FindAlternativePaths(fileName, sessionData.SessionFilePath, sessionDirectories, cachedDriveLetters);
result.PossibleAlternatives[fileName] = alternativePaths;
}
}
Expand Down Expand Up @@ -104,6 +108,43 @@ private static bool IsUri (string fileName)
!uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Returns the directories to search for alternative file locations: the session file's directory
/// followed by its immediate subdirectories. Enumerated once per session so that per-missing-file
/// lookups do not repeatedly hit the file system with the same <see cref="Directory.GetDirectories(string)"/> call.
/// </summary>
/// <param name="sessionFilePath">The full path to the session/project file. May be null or empty.</param>
/// <returns>The session directory and its immediate subdirectories, or an empty list if unavailable.</returns>
private static List<string> GetSessionSearchDirectories (string sessionFilePath)
{
if (string.IsNullOrWhiteSpace(sessionFilePath))
{
return [];
}

try
{
var sessionDir = Path.GetDirectoryName(sessionFilePath);
if (string.IsNullOrEmpty(sessionDir) || !Directory.Exists(sessionDir))
{
return [];
}

var directories = new List<string> { sessionDir };
directories.AddRange(Directory.GetDirectories(sessionDir));
return directories;
}
catch (Exception ex) when (ex is ArgumentException or
ArgumentNullException or
PathTooLongException or
UnauthorizedAccessException or
IOException)
{
// Ignore errors when enumerating the session directory
return [];
}
}

/// <summary>
/// Gets the list of fixed drive letters that are ready.
/// Extracted to avoid repeated expensive DriveInfo.GetDrives() calls.
Expand All @@ -116,12 +157,11 @@ private static List<char> GetFixedDriveLetters ()
.Where(d => d.IsReady && d.DriveType == DriveType.Fixed)
.Select(d => d.Name[0])];
}
catch(Exception ex) when (
ex is IOException
or UnauthorizedAccessException
or SecurityException
or DriveNotFoundException
or ArgumentNullException)
catch (Exception ex) when (ex is IOException or
UnauthorizedAccessException or
SecurityException or
DriveNotFoundException or
ArgumentNullException)
{
return [];
}
Expand All @@ -140,10 +180,11 @@ or DriveNotFoundException
/// whitespace.</param>
/// <param name="sessionFilePath">The full path to the project file used as a reference for searching related directories. Can be null or empty if
/// project context is not available.</param>
/// <param name="sessionDirectories">Pre-enumerated session directory and its immediate subdirectories, shared across all missing files.</param>
/// <param name="cachedDriveLetters">Pre-computed list of fixed drive letters to avoid repeated DriveInfo.GetDrives() calls.</param>
/// <returns>A list of strings containing the full paths of files found that match the specified file name in alternative
/// locations. The list will be empty if no matching files are found.</returns>
private static List<string> FindAlternativePaths (string fileName, string sessionFilePath, List<char> cachedDriveLetters)
private static List<string> FindAlternativePaths (string fileName, string sessionFilePath, List<string> sessionDirectories, List<char> cachedDriveLetters)
{
var alternatives = new List<string>();

Expand All @@ -159,37 +200,11 @@ private static List<string> FindAlternativePaths (string fileName, string sessio
return alternatives;
}

// Search in directory of .lxj project file
if (!string.IsNullOrWhiteSpace(sessionFilePath))
{
try
{
var sessionDir = Path.GetDirectoryName(sessionFilePath);
if (!string.IsNullOrEmpty(sessionDir) && Directory.Exists(sessionDir))
{
var candidatePath = Path.Join(sessionDir, baseName);
if (File.Exists(candidatePath))
{
alternatives.Add(candidatePath);
}

// Also check subdirectories (one level deep)
var subdirs = Directory.GetDirectories(sessionDir);
alternatives.AddRange(
subdirs
.Select(subdir => Path.Join(subdir, baseName))
.Where(File.Exists));
}
}
catch (Exception ex) when (ex is ArgumentException or
ArgumentNullException or
PathTooLongException or
UnauthorizedAccessException or
IOException)
{
// Ignore errors when searching in project directory
}
}
// Search in directory of .lxj project file and its immediate subdirectories (pre-enumerated once per session)
alternatives.AddRange(
sessionDirectories
.Select(dir => Path.Join(dir, baseName))
.Where(File.Exists));

// Search in Documents/LogExpert folder
try
Expand Down
19 changes: 17 additions & 2 deletions src/LogExpert.Core/Classes/SysoutPipe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class SysoutPipe : IDisposable

private static readonly Logger _logger = LogManager.GetCurrentClassLogger();

private readonly Process _process;
private readonly StreamReader _sysout;
private StreamWriter _writer;
private bool _disposed;
Expand All @@ -20,10 +21,21 @@ public class SysoutPipe : IDisposable

#region cTor

public SysoutPipe (StreamReader sysout)
public SysoutPipe (Process process)
{
_disposed = false;
_sysout = sysout;

// Hold a strong reference to the process for the lifetime of the pipe. Without it the
// process becomes unrooted as soon as the launcher returns, gets finalized, and reading
// StandardOutput then throws ObjectDisposedException (races on fast-exiting processes).
// `process` is rooted as the constructor argument here, so reading StandardOutput is safe.
_process = process;
_sysout = process.StandardOutput;

// Subscribe here rather than at the call site so the process cannot exit/dispose between
// construction and subscription.
process.Exited += ProcessExitedEventHandler;

FileName = Path.GetTempFileName();
_logger.Info(CultureInfo.InvariantCulture, "sysoutPipe created temp file: {0}", FileName);

Expand Down Expand Up @@ -95,6 +107,9 @@ protected void ReaderThread ()
}

ClosePipe();

// Output is fully drained — release the process handle deterministically.
_process.Dispose();
}

public void Dispose ()
Expand Down
14 changes: 8 additions & 6 deletions src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -886,9 +886,14 @@ public void LoadProjectData_VeryLargeProject_ValidatesEfficiently ()
#region Performance and Stress Tests

[Test]
public void LoadProjectData_ManyMissingFiles_PerformsEfficiently ()
{
// Arrange
public void LoadProjectData_ManyMissingFiles_HandledCorrectly ()
{
// This exercises the many-missing-files path (each missing file triggers an alternative-path
// search). We assert correctness only and deliberately avoid a wall-clock budget: the work is
// filesystem-bound, so timings are dominated by antivirus/EDR scanning, disk-cache state and
// machine load, none of which are code defects. The per-missing-file cost is linear, so there is
// no super-linear blow-up for a timer to catch here; efficiency is guaranteed structurally by
// enumerating the session directory once per session rather than once per missing file.
const int totalFiles = 50;
var fileNames = new List<string>();

Expand All @@ -908,15 +913,12 @@ public void LoadProjectData_ManyMissingFiles_PerformsEfficiently ()
CreateTestProjectFile([.. fileNames]);

// Act
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var result = SessionPersister.LoadSessionData(_projectFile, PluginRegistry.PluginRegistry.Instance);
stopwatch.Stop();

// Assert
Assert.That(result, Is.Not.Null, "Result should not be null");
Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(10), "Should have 10 valid files");
Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(40), "Should have 40 missing files");
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(5000), "Should handle many missing files efficiently");
}

#endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ private static ToolLaunchResult LaunchExternal (ToolLaunchRequest request)

private static (bool flowControl, ToolLaunchResult value, Process process) LaunchProcess (ProcessStartInfo startInfo)
{
using Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true };
Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true };

try
{
Expand Down Expand Up @@ -80,8 +80,9 @@ private ToolLaunchResult LaunchWithSysoutPipe (ToolLaunchRequest request)
}

// TODO: SysoutPipe temp file is never deleted — fire-and-forget lifetime by design.
SysoutPipe pipe = new(process.StandardOutput);
process.Exited += pipe.ProcessExitedEventHandler;
// SysoutPipe takes ownership of the process (keeps it alive, reads StandardOutput,
// subscribes to Exited, and disposes it once output is drained).
SysoutPipe pipe = new(process);

return new ToolLaunchResult
{
Expand Down
32 changes: 16 additions & 16 deletions src/PluginRegistry/PluginHashGenerator.Generated.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,35 @@ public static partial class PluginValidator
{
/// <summary>
/// Gets pre-calculated SHA256 hashes for built-in plugins.
/// Generated: 2026-06-09 07:11:53 UTC
/// Generated: 2026-06-09 13:03:15 UTC
/// Configuration: Release
/// Plugin count: 21
/// </summary>
public static Dictionary<string, string> GetBuiltInPluginHashes()
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["AutoColumnizer.dll"] = "F06B072F21BED58FC98DC9FD89C081BB0786DD7AB9326562C4ED2D687598EF6B",
["AutoColumnizer.dll"] = "780F403272D7C79E0C799616FFEFE1570C303D386BB7D34E2B297E095D1066A2",
["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
["CsvColumnizer.dll"] = "E5020059F5ADC71066E519EB94DF5C2979D3135FAC6E2923B50C3FBB62822987",
["CsvColumnizer.dll (x86)"] = "E5020059F5ADC71066E519EB94DF5C2979D3135FAC6E2923B50C3FBB62822987",
["DefaultPlugins.dll"] = "DA4B2B5CFBF0B0FF928C3A1155F94E26622509DADEBDB03D25C25CC8238CC1D0",
["FlashIconHighlighter.dll"] = "DE097E728DB95A93703DBA54E3D69AADC4B9F1567C89CD2AAF5B942BD4D363E4",
["GlassfishColumnizer.dll"] = "4A6A43B08AF6808AEC6727B3EAA5D287775FDA16D0443BB113A8ABA2AD426F0A",
["JsonColumnizer.dll"] = "36325E7C57B08F57DD42EC666AE9072CBC0B0BD407680063CFCBF3696A1D1429",
["JsonCompactColumnizer.dll"] = "B2301944FB40C4ECC1CD9591AEF49666D394A0D1EC0C08B713D057B0E2569297",
["Log4jXmlColumnizer.dll"] = "B10511AED306EAC7B875E8AE91CDC2A906FC1B1EF407CD908AAC8530DF52F4D6",
["LogExpert.Resources.dll"] = "F4ACED3693B4A21BAEE1840FCD039134D78B758C2935561D4B42AF2D1354D6EE",
["CsvColumnizer.dll"] = "E6120973F204B4512E248D104D760E507C1AC494E3F66BF9F4C88B94C11169D1",
["CsvColumnizer.dll (x86)"] = "E6120973F204B4512E248D104D760E507C1AC494E3F66BF9F4C88B94C11169D1",
["DefaultPlugins.dll"] = "EC05656FA992A46D98BCB04B19153F444B8EEFEA6522CCD9F85A0A0FACA42DA3",
["FlashIconHighlighter.dll"] = "7E354E0048D68188F99DC721D9B92C75651DAD733A363A536322B1811C739A1A",
["GlassfishColumnizer.dll"] = "6D02D6E30BFD11FC0964E6EAAA159EB886D293F9299E014EAAAE42F19BC3FE44",
["JsonColumnizer.dll"] = "C6711DBECE8EC611B9B24B576D9B07F9D49C933954F376072E6CD52DAC202E46",
["JsonCompactColumnizer.dll"] = "2AEBDD1187D2149FC25B06AD159084FBF2B41A32E3207F71A5BCA21C53672E03",
["Log4jXmlColumnizer.dll"] = "2F882BD56E8A7005C8DA584A4E34DEEF13CCF6583F5202A97A086BF507F18B28",
["LogExpert.Resources.dll"] = "2D08895DAD17CCFBC70256980741418D5858F04477A8BB90542022463B1BF1AE",
["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93",
["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93",
["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D",
["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D",
["RegexColumnizer.dll"] = "D8665A762EECCB8516C8D51A0D7099D53C8C515A66DB6D88AA86F60484E6CBB1",
["SftpFileSystem.dll"] = "D35385D7B4A20946A1CF987C277682FD096FD93A3BB90A4E7944D41556DEED77",
["SftpFileSystem.dll (x86)"] = "0C9AA4F229A76476257288E683D41830A09AC0E3945A717823193B579A7B3EEB",
["SftpFileSystem.Resources.dll"] = "EF5A68C3448B3BEDD796BE2BDA85BE7E9242D6A1F63C207D95305BE325E48FCF",
["SftpFileSystem.Resources.dll (x86)"] = "EF5A68C3448B3BEDD796BE2BDA85BE7E9242D6A1F63C207D95305BE325E48FCF",
["RegexColumnizer.dll"] = "C1EE05EAE2F01F6614DF0F9EDA1FC5D12DF7C02766E264A626DD1232805C5D3A",
["SftpFileSystem.dll"] = "32AA0BC7184E281D29E8F1BD9244A73AB908DCC236E233A8CD51CE33526BFC87",
["SftpFileSystem.dll (x86)"] = "AA1F476F483D745E8139F090C194D100FBCC7D38840D7C5B1DC19671B2729FF0",
["SftpFileSystem.Resources.dll"] = "8A559F44F7D74204BA54818A8373024A76E1C632062D22F7BBA6CD1A9821B40F",
["SftpFileSystem.Resources.dll (x86)"] = "8A559F44F7D74204BA54818A8373024A76E1C632062D22F7BBA6CD1A9821B40F",

};
}
Expand Down