Skip to content

Commit 9def5a3

Browse files
authored
Merge pull request #602 from LogExperts/bugfixes_toolservice_and_unittests
bugfixes and updates to unit tests
2 parents bd1d695 + 3c073c2 commit 9def5a3

5 files changed

Lines changed: 99 additions & 66 deletions

File tree

src/LogExpert.Core/Classes/Persister/SessionFileValidator.cs

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ public static SessionValidationResult ValidateSession (SessionData sessionData,
3535
// Cache drive letters once to avoid repeated expensive DriveInfo.GetDrives() calls
3636
var cachedDriveLetters = GetFixedDriveLetters();
3737

38+
// Enumerate the session directory and its immediate subdirectories once, instead of
39+
// re-enumerating for every missing file. This is shared across all alternative-path lookups.
40+
var sessionDirectories = GetSessionSearchDirectories(sessionData.SessionFilePath);
41+
3842
foreach (var fileName in sessionData.FileNames)
3943
{
4044
var normalizedPath = NormalizeFilePath(fileName);
@@ -60,7 +64,7 @@ public static SessionValidationResult ValidateSession (SessionData sessionData,
6064
{
6165
result.MissingFiles.Add(fileName);
6266

63-
var alternativePaths = FindAlternativePaths(fileName, sessionData.SessionFilePath, cachedDriveLetters);
67+
var alternativePaths = FindAlternativePaths(fileName, sessionData.SessionFilePath, sessionDirectories, cachedDriveLetters);
6468
result.PossibleAlternatives[fileName] = alternativePaths;
6569
}
6670
}
@@ -104,6 +108,43 @@ private static bool IsUri (string fileName)
104108
!uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase);
105109
}
106110

111+
/// <summary>
112+
/// Returns the directories to search for alternative file locations: the session file's directory
113+
/// followed by its immediate subdirectories. Enumerated once per session so that per-missing-file
114+
/// lookups do not repeatedly hit the file system with the same <see cref="Directory.GetDirectories(string)"/> call.
115+
/// </summary>
116+
/// <param name="sessionFilePath">The full path to the session/project file. May be null or empty.</param>
117+
/// <returns>The session directory and its immediate subdirectories, or an empty list if unavailable.</returns>
118+
private static List<string> GetSessionSearchDirectories (string sessionFilePath)
119+
{
120+
if (string.IsNullOrWhiteSpace(sessionFilePath))
121+
{
122+
return [];
123+
}
124+
125+
try
126+
{
127+
var sessionDir = Path.GetDirectoryName(sessionFilePath);
128+
if (string.IsNullOrEmpty(sessionDir) || !Directory.Exists(sessionDir))
129+
{
130+
return [];
131+
}
132+
133+
var directories = new List<string> { sessionDir };
134+
directories.AddRange(Directory.GetDirectories(sessionDir));
135+
return directories;
136+
}
137+
catch (Exception ex) when (ex is ArgumentException or
138+
ArgumentNullException or
139+
PathTooLongException or
140+
UnauthorizedAccessException or
141+
IOException)
142+
{
143+
// Ignore errors when enumerating the session directory
144+
return [];
145+
}
146+
}
147+
107148
/// <summary>
108149
/// Gets the list of fixed drive letters that are ready.
109150
/// Extracted to avoid repeated expensive DriveInfo.GetDrives() calls.
@@ -116,12 +157,11 @@ private static List<char> GetFixedDriveLetters ()
116157
.Where(d => d.IsReady && d.DriveType == DriveType.Fixed)
117158
.Select(d => d.Name[0])];
118159
}
119-
catch(Exception ex) when (
120-
ex is IOException
121-
or UnauthorizedAccessException
122-
or SecurityException
123-
or DriveNotFoundException
124-
or ArgumentNullException)
160+
catch (Exception ex) when (ex is IOException or
161+
UnauthorizedAccessException or
162+
SecurityException or
163+
DriveNotFoundException or
164+
ArgumentNullException)
125165
{
126166
return [];
127167
}
@@ -140,10 +180,11 @@ or DriveNotFoundException
140180
/// whitespace.</param>
141181
/// <param name="sessionFilePath">The full path to the project file used as a reference for searching related directories. Can be null or empty if
142182
/// project context is not available.</param>
183+
/// <param name="sessionDirectories">Pre-enumerated session directory and its immediate subdirectories, shared across all missing files.</param>
143184
/// <param name="cachedDriveLetters">Pre-computed list of fixed drive letters to avoid repeated DriveInfo.GetDrives() calls.</param>
144185
/// <returns>A list of strings containing the full paths of files found that match the specified file name in alternative
145186
/// locations. The list will be empty if no matching files are found.</returns>
146-
private static List<string> FindAlternativePaths (string fileName, string sessionFilePath, List<char> cachedDriveLetters)
187+
private static List<string> FindAlternativePaths (string fileName, string sessionFilePath, List<string> sessionDirectories, List<char> cachedDriveLetters)
147188
{
148189
var alternatives = new List<string>();
149190

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

162-
// Search in directory of .lxj project file
163-
if (!string.IsNullOrWhiteSpace(sessionFilePath))
164-
{
165-
try
166-
{
167-
var sessionDir = Path.GetDirectoryName(sessionFilePath);
168-
if (!string.IsNullOrEmpty(sessionDir) && Directory.Exists(sessionDir))
169-
{
170-
var candidatePath = Path.Join(sessionDir, baseName);
171-
if (File.Exists(candidatePath))
172-
{
173-
alternatives.Add(candidatePath);
174-
}
175-
176-
// Also check subdirectories (one level deep)
177-
var subdirs = Directory.GetDirectories(sessionDir);
178-
alternatives.AddRange(
179-
subdirs
180-
.Select(subdir => Path.Join(subdir, baseName))
181-
.Where(File.Exists));
182-
}
183-
}
184-
catch (Exception ex) when (ex is ArgumentException or
185-
ArgumentNullException or
186-
PathTooLongException or
187-
UnauthorizedAccessException or
188-
IOException)
189-
{
190-
// Ignore errors when searching in project directory
191-
}
192-
}
203+
// Search in directory of .lxj project file and its immediate subdirectories (pre-enumerated once per session)
204+
alternatives.AddRange(
205+
sessionDirectories
206+
.Select(dir => Path.Join(dir, baseName))
207+
.Where(File.Exists));
193208

194209
// Search in Documents/LogExpert folder
195210
try

src/LogExpert.Core/Classes/SysoutPipe.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public class SysoutPipe : IDisposable
1212

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

15+
private readonly Process _process;
1516
private readonly StreamReader _sysout;
1617
private StreamWriter _writer;
1718
private bool _disposed;
@@ -20,10 +21,21 @@ public class SysoutPipe : IDisposable
2021

2122
#region cTor
2223

23-
public SysoutPipe (StreamReader sysout)
24+
public SysoutPipe (Process process)
2425
{
2526
_disposed = false;
26-
_sysout = sysout;
27+
28+
// Hold a strong reference to the process for the lifetime of the pipe. Without it the
29+
// process becomes unrooted as soon as the launcher returns, gets finalized, and reading
30+
// StandardOutput then throws ObjectDisposedException (races on fast-exiting processes).
31+
// `process` is rooted as the constructor argument here, so reading StandardOutput is safe.
32+
_process = process;
33+
_sysout = process.StandardOutput;
34+
35+
// Subscribe here rather than at the call site so the process cannot exit/dispose between
36+
// construction and subscription.
37+
process.Exited += ProcessExitedEventHandler;
38+
2739
FileName = Path.GetTempFileName();
2840
_logger.Info(CultureInfo.InvariantCulture, "sysoutPipe created temp file: {0}", FileName);
2941

@@ -95,6 +107,9 @@ protected void ReaderThread ()
95107
}
96108

97109
ClosePipe();
110+
111+
// Output is fully drained — release the process handle deterministically.
112+
_process.Dispose();
98113
}
99114

100115
public void Dispose ()

src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -886,9 +886,14 @@ public void LoadProjectData_VeryLargeProject_ValidatesEfficiently ()
886886
#region Performance and Stress Tests
887887

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

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

910915
// Act
911-
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
912916
var result = SessionPersister.LoadSessionData(_projectFile, PluginRegistry.PluginRegistry.Instance);
913-
stopwatch.Stop();
914917

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

922924
#endregion

src/LogExpert.UI/Services/ToolLaunchService/ToolLaunchService.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ private static ToolLaunchResult LaunchExternal (ToolLaunchRequest request)
4444

4545
private static (bool flowControl, ToolLaunchResult value, Process process) LaunchProcess (ProcessStartInfo startInfo)
4646
{
47-
using Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true };
47+
Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true };
4848

4949
try
5050
{
@@ -80,8 +80,9 @@ private ToolLaunchResult LaunchWithSysoutPipe (ToolLaunchRequest request)
8080
}
8181

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

8687
return new ToolLaunchResult
8788
{

src/PluginRegistry/PluginHashGenerator.Generated.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,35 @@ public static partial class PluginValidator
1010
{
1111
/// <summary>
1212
/// Gets pre-calculated SHA256 hashes for built-in plugins.
13-
/// Generated: 2026-06-09 07:11:53 UTC
13+
/// Generated: 2026-06-09 13:03:15 UTC
1414
/// Configuration: Release
1515
/// Plugin count: 21
1616
/// </summary>
1717
public static Dictionary<string, string> GetBuiltInPluginHashes()
1818
{
1919
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
2020
{
21-
["AutoColumnizer.dll"] = "F06B072F21BED58FC98DC9FD89C081BB0786DD7AB9326562C4ED2D687598EF6B",
21+
["AutoColumnizer.dll"] = "780F403272D7C79E0C799616FFEFE1570C303D386BB7D34E2B297E095D1066A2",
2222
["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
2323
["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
24-
["CsvColumnizer.dll"] = "E5020059F5ADC71066E519EB94DF5C2979D3135FAC6E2923B50C3FBB62822987",
25-
["CsvColumnizer.dll (x86)"] = "E5020059F5ADC71066E519EB94DF5C2979D3135FAC6E2923B50C3FBB62822987",
26-
["DefaultPlugins.dll"] = "DA4B2B5CFBF0B0FF928C3A1155F94E26622509DADEBDB03D25C25CC8238CC1D0",
27-
["FlashIconHighlighter.dll"] = "DE097E728DB95A93703DBA54E3D69AADC4B9F1567C89CD2AAF5B942BD4D363E4",
28-
["GlassfishColumnizer.dll"] = "4A6A43B08AF6808AEC6727B3EAA5D287775FDA16D0443BB113A8ABA2AD426F0A",
29-
["JsonColumnizer.dll"] = "36325E7C57B08F57DD42EC666AE9072CBC0B0BD407680063CFCBF3696A1D1429",
30-
["JsonCompactColumnizer.dll"] = "B2301944FB40C4ECC1CD9591AEF49666D394A0D1EC0C08B713D057B0E2569297",
31-
["Log4jXmlColumnizer.dll"] = "B10511AED306EAC7B875E8AE91CDC2A906FC1B1EF407CD908AAC8530DF52F4D6",
32-
["LogExpert.Resources.dll"] = "F4ACED3693B4A21BAEE1840FCD039134D78B758C2935561D4B42AF2D1354D6EE",
24+
["CsvColumnizer.dll"] = "E6120973F204B4512E248D104D760E507C1AC494E3F66BF9F4C88B94C11169D1",
25+
["CsvColumnizer.dll (x86)"] = "E6120973F204B4512E248D104D760E507C1AC494E3F66BF9F4C88B94C11169D1",
26+
["DefaultPlugins.dll"] = "EC05656FA992A46D98BCB04B19153F444B8EEFEA6522CCD9F85A0A0FACA42DA3",
27+
["FlashIconHighlighter.dll"] = "7E354E0048D68188F99DC721D9B92C75651DAD733A363A536322B1811C739A1A",
28+
["GlassfishColumnizer.dll"] = "6D02D6E30BFD11FC0964E6EAAA159EB886D293F9299E014EAAAE42F19BC3FE44",
29+
["JsonColumnizer.dll"] = "C6711DBECE8EC611B9B24B576D9B07F9D49C933954F376072E6CD52DAC202E46",
30+
["JsonCompactColumnizer.dll"] = "2AEBDD1187D2149FC25B06AD159084FBF2B41A32E3207F71A5BCA21C53672E03",
31+
["Log4jXmlColumnizer.dll"] = "2F882BD56E8A7005C8DA584A4E34DEEF13CCF6583F5202A97A086BF507F18B28",
32+
["LogExpert.Resources.dll"] = "2D08895DAD17CCFBC70256980741418D5858F04477A8BB90542022463B1BF1AE",
3333
["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93",
3434
["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93",
3535
["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D",
3636
["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D",
37-
["RegexColumnizer.dll"] = "D8665A762EECCB8516C8D51A0D7099D53C8C515A66DB6D88AA86F60484E6CBB1",
38-
["SftpFileSystem.dll"] = "D35385D7B4A20946A1CF987C277682FD096FD93A3BB90A4E7944D41556DEED77",
39-
["SftpFileSystem.dll (x86)"] = "0C9AA4F229A76476257288E683D41830A09AC0E3945A717823193B579A7B3EEB",
40-
["SftpFileSystem.Resources.dll"] = "EF5A68C3448B3BEDD796BE2BDA85BE7E9242D6A1F63C207D95305BE325E48FCF",
41-
["SftpFileSystem.Resources.dll (x86)"] = "EF5A68C3448B3BEDD796BE2BDA85BE7E9242D6A1F63C207D95305BE325E48FCF",
37+
["RegexColumnizer.dll"] = "C1EE05EAE2F01F6614DF0F9EDA1FC5D12DF7C02766E264A626DD1232805C5D3A",
38+
["SftpFileSystem.dll"] = "32AA0BC7184E281D29E8F1BD9244A73AB908DCC236E233A8CD51CE33526BFC87",
39+
["SftpFileSystem.dll (x86)"] = "AA1F476F483D745E8139F090C194D100FBCC7D38840D7C5B1DC19671B2729FF0",
40+
["SftpFileSystem.Resources.dll"] = "8A559F44F7D74204BA54818A8373024A76E1C632062D22F7BBA6CD1A9821B40F",
41+
["SftpFileSystem.Resources.dll (x86)"] = "8A559F44F7D74204BA54818A8373024A76E1C632062D22F7BBA6CD1A9821B40F",
4242

4343
};
4444
}

0 commit comments

Comments
 (0)