diff --git a/CONTEXT.md b/CONTEXT.md index dde30b65..88332402 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,9 +16,12 @@ meaning; do not redefine them locally. - **Action Entry** (`ActionEntry`) — Plugin name + parameters bound to the Plugin trigger of a Highlight Entry. - **Tail trigger path** — The single code path in `LogWindow.CheckFilterAndHighlight` - that evaluates highlight entries against *newly appended* lines. Triggers - that have user-perceivable side effects (currently: Audio Alert) fire **only** - on this path; bulk/scanner paths intentionally skip them. + (one unified loop since the Filter Engine extraction) that evaluates + highlight entries against *newly appended* lines. Triggers that have + user-perceivable side effects (currently: Audio Alert) fire **only** on + this path; bulk/scanner paths intentionally skip them — enforced by + construction, since the bulk `HighlightBookmarkScanner` has no access to + the side-effecting triggers. ## Audio alerts @@ -88,6 +91,25 @@ bare "session file" when you mean the workspace (that's a **Session**). ## Filtering +- **Filter Engine** (`IFilterEngine`) — A Core module that executes one full + **Filter Run** over a log file. Two engines implement the seam — the + **Serial** and the **Parallel** engine (`SerialFilterEngine`, + `ParallelFilterEngine`, the latter wrapping `FilterStarter`'s + chunk-and-merge) — selected by the `MultiThreadFilter` preference and held + to an identical, equivalence-test-pinned contract: sorted-ascending + duplicate-free output, `FilterParams` and line count snapshotted at entry, + failures and cancellation narrated as outcomes, never thrown or shown by + the engine. +- **Filter Run** (`FilterRun`) — One execution of a Filter Engine and its + result: result lines, hit lines, the spread history (handed to the tail + path to continue from), and an outcome — Completed, Cancelled (partial + lists stand), or Failed (carries the exception; the Log Window narrates it + on the status line). +- **Filter Accumulator** (`FilterAccumulator`) — The single owner of the + per-hit accumulation recipe (hit → spread expansion → append to + results/history → trim history). Used inside the Serial engine, per line by + the Log Window's tail filter path, and per hit by Filter Pipes — all three + adopt existing lists, so a Filter Run's history is continued in place. - **Filter Spread** — Context expansion around a filter hit: **Back Spread** (`FilterParams.SpreadBefore`) lines before and **Fore Spread** (`FilterParams.SpreadBehind`) lines after the hit are included in the @@ -102,7 +124,9 @@ bare "session file" when you mean the workspace (that's a **Session**). *Avoid*: "spread" alone when ambiguous (say **Back Spread** / **Fore Spread**), "additional filter results" (the old internal name — use -**Filter Spread**). +**Filter Spread**), "multi-threaded filter" as a concept name (it is a +preference selecting the Parallel **Filter Engine**), "FilterFx" (legacy +delegate name, deleted). ## Log Search diff --git a/src/LogExpert.Core/Classes/Filter/Filter.cs b/src/LogExpert.Core/Classes/Filter/Filter.cs index f2b611bf..c765cede 100644 --- a/src/LogExpert.Core/Classes/Filter/Filter.cs +++ b/src/LogExpert.Core/Classes/Filter/Filter.cs @@ -35,22 +35,21 @@ public Filter (ColumnizerCallback callback) public List FilterResultLines { get; } public List LastFilterLinesList { get; } public List FilterHitList { get; } - public bool ShouldCancel { get; set; } #endregion #region Public methods - public int DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback) + public int DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback, CancellationToken cancellationToken) { - return DoFilter(filterParams, startLine, maxCount, FilterResultLines, LastFilterLinesList, FilterHitList, progressCallback); + return DoFilter(filterParams, startLine, maxCount, FilterResultLines, LastFilterLinesList, FilterHitList, progressCallback, cancellationToken); } #endregion #region Private Methods - private int DoFilter (FilterParams filterParams, int startLine, int maxCount, List filterResultLines, List lastFilterLinesList, List filterHitList, ProgressCallback progressCallback) + private int DoFilter (FilterParams filterParams, int startLine, int maxCount, List filterResultLines, List lastFilterLinesList, List filterHitList, ProgressCallback progressCallback, CancellationToken cancellationToken) { var lineNum = startLine; var count = 0; @@ -60,7 +59,7 @@ private int DoFilter (FilterParams filterParams, int startLine, int maxCount, Li { filterParams.Reset(); - while ((count++ < maxCount || filterParams.IsInRange) && !ShouldCancel) + while ((count++ < maxCount || filterParams.IsInRange) && !cancellationToken.IsCancellationRequested) { if (lineNum >= _callback.GetLineCount()) { diff --git a/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs b/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs new file mode 100644 index 00000000..ffc93d5b --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs @@ -0,0 +1,60 @@ +namespace LogExpert.Core.Classes.Filter; + +/// +/// The single home of the per-hit filter accumulation recipe: hit → → +/// append to results/history → . Used internally by the serial +/// Filter Engine and per line by the Log Window's tail filter path, so the recipe exists exactly once. +/// Not thread-safe — callers that share an instance with concurrent readers (the tail path's GUI) +/// synchronize around it. +/// +public class FilterAccumulator +{ + public FilterAccumulator () : this([], [], []) + { + } + + /// + /// Adopts existing lists as the accumulator's state — the tail filter path wraps the Log + /// Window's canonical result/hit/history lists (and Filter Pipes their per-pipe history) so a + /// full run's output, including its spread history, is continued in place rather than copied. + /// + public FilterAccumulator (IList resultLines, IList hitLines, IList history) + { + ArgumentNullException.ThrowIfNull(resultLines); + ArgumentNullException.ThrowIfNull(hitLines); + ArgumentNullException.ThrowIfNull(history); + + ResultLines = resultLines; + HitLines = hitLines; + History = history; + } + + /// Filter result lines: every hit plus its spread context, in accumulation order. + public IList ResultLines { get; } + + /// The hit lines only, without spread. + public IList HitLines { get; } + + /// The trailing dedup window (spread history) recent expansions were checked against. + public IList History { get; } + + /// + /// Records a filter hit: adds it to and appends its spread expansion + /// (deduplicated against , clamped to the file's line range) to + /// . Returns the expansion so callers that emit it (Filter Pipes) + /// need not diff the lists. + /// + public IList AddHit (int lineNum, int spreadBefore, int spreadBehind, int lineCount) + { + HitLines.Add(lineNum); + var expanded = FilterSpread.Expand(lineNum, spreadBefore, spreadBehind, lineCount, History); + foreach (var line in expanded) + { + ResultLines.Add(line); + History.Add(line); + } + + FilterSpread.TrimHistory(History); + return expanded; + } +} diff --git a/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs b/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs deleted file mode 100644 index 5da892ee..00000000 --- a/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Globalization; - -using LogExpert.Core.Interfaces; - -using NLog; - -namespace LogExpert.Core.Classes.Filter; - -public class FilterCancelHandler (FilterStarter filterStarter) : IBackgroundProcessCancelHandler -{ - private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); - #region Fields - - private readonly FilterStarter _filterStarter = filterStarter; - - #endregion - #region cTor - - #endregion - - #region Public methods - - public void EscapePressed () - { - _logger.Info(CultureInfo.InvariantCulture, "FilterCancelHandler called."); - _filterStarter.CancelFilter(); - } - - #endregion -} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Filter/FilterRun.cs b/src/LogExpert.Core/Classes/Filter/FilterRun.cs new file mode 100644 index 00000000..0f517334 --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/FilterRun.cs @@ -0,0 +1,27 @@ +namespace LogExpert.Core.Classes.Filter; + +/// How a Filter Run ended. +public enum FilterRunOutcome +{ + /// All lines in the run's range were filtered. + Completed, + + /// The run observed cancellation; the lists hold the partial results accumulated so far. + Cancelled, + + /// The filter condition threw; carries the exception. + /// The caller narrates — the engine never rethrows. + Failed, +} + +/// +/// The result of one Filter Run. and are sorted +/// ascending without duplicates (the engine contract); is the spread-dedup +/// window state in accumulation order, handed to the tail filter path to continue from. +/// +public sealed record FilterRun ( + IReadOnlyList ResultLines, + IReadOnlyList HitLines, + IReadOnlyList History, + FilterRunOutcome Outcome, + Exception Error = null); diff --git a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs index be2f03cc..444cbf4a 100644 --- a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs +++ b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs @@ -19,13 +19,10 @@ public class FilterStarter private readonly List _filterReadyList; private readonly SortedDictionary _filterResultDict; - private readonly List _filterWorkerList; - private readonly SortedDictionary _lastFilterLinesDict; private ProgressCallback _progressCallback; private int _progressLineCount; - private bool _shouldStop; #endregion @@ -38,15 +35,10 @@ public FilterStarter (ColumnizerCallback callback, int minThreads) LastFilterLinesList = []; FilterHitList = []; _filterReadyList = []; - _filterWorkerList = []; _filterHitDict = []; _filterResultDict = []; _lastFilterLinesDict = []; - ThreadCount = Environment.ProcessorCount * 4; ThreadCount = minThreads; - ThreadPool.GetMinThreads(out _, out var completion); - _ = ThreadPool.SetMinThreads(minThreads, completion); - ThreadPool.GetMaxThreads(out _, out _); } #endregion @@ -65,7 +57,7 @@ public FilterStarter (ColumnizerCallback callback, int minThreads) #region Public methods - public async Task DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback) + public async Task DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback, CancellationToken cancellationToken) { FilterResultLines.Clear(); LastFilterLinesList.Clear(); @@ -74,8 +66,6 @@ public async Task DoFilter (FilterParams filterParams, int startLine, int maxCou _filterReadyList.Clear(); _filterResultDict.Clear(); _lastFilterLinesDict.Clear(); - _filterWorkerList.Clear(); - _shouldStop = false; var interval = maxCount / ThreadCount; @@ -106,7 +96,7 @@ public async Task DoFilter (FilterParams filterParams, int startLine, int maxCou var capturedStartLine = workStartLine; var capturedInterval = interval; - tasks.Add(Task.Run(() => DoWork(filterParams, capturedStartLine, capturedInterval, ThreadProgressCallback))); + tasks.Add(Task.Run(() => DoWork(filterParams, capturedStartLine, capturedInterval, ThreadProgressCallback, cancellationToken))); workStartLine += interval; } @@ -114,23 +104,6 @@ public async Task DoFilter (FilterParams filterParams, int startLine, int maxCou MergeResults(); } - /// - /// Requests the FilterStarter to stop all filter threads. Call this from another thread (e.g. GUI). The function returns - /// immediately without waiting for filter end. - /// - public void CancelFilter () - { - _shouldStop = true; - lock (_filterWorkerList) - { - _logger.Info(CultureInfo.InvariantCulture, "Filter cancel requested. Stopping all {0} threads.", _filterWorkerList.Count); - foreach (var filter in _filterWorkerList) - { - filter.ShouldCancel = true; - } - } - } - #endregion #region Private Methods @@ -141,22 +114,17 @@ private void ThreadProgressCallback (int lineCount) _progressCallback(count); } - private Filter DoWork (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback) + private Filter DoWork (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback, CancellationToken cancellationToken) { _logger.Info(CultureInfo.InvariantCulture, "Started Filter worker [{0}] for line {1}", Environment.CurrentManagedThreadId, startLine); // Give every thread own copies of ColumnizerCallback and FilterParams, because the state of the objects changes while filtering var threadFilterParams = filterParams.CloneWithCurrentColumnizer(); Filter filter = new(_callback.Clone()); - lock (_filterWorkerList) - { - _filterWorkerList.Add(filter); - } - if (!_shouldStop) + if (!cancellationToken.IsCancellationRequested) { - - _ = filter.DoFilter(threadFilterParams, startLine, maxCount, progressCallback); + _ = filter.DoFilter(threadFilterParams, startLine, maxCount, progressCallback, cancellationToken); _logger.Info(CultureInfo.InvariantCulture, "Filter worker [{0}] for line {1} has completed.", Environment.CurrentManagedThreadId, startLine); lock (_filterReadyList) diff --git a/src/LogExpert.Core/Classes/Filter/IFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/IFilterEngine.cs new file mode 100644 index 00000000..d3ee56ae --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/IFilterEngine.cs @@ -0,0 +1,26 @@ +using LogExpert.Core.Callback; + +namespace LogExpert.Core.Classes.Filter; + +/// +/// A Filter Engine executes one full Filter Run over a log file and returns a . +/// Two engines implement the seam — and , +/// selected by the MultiThreadFilter preference — and are held to an identical contract: +/// +/// and are sorted ascending with no duplicates. +/// is snapshotted at entry — mutating the caller's instance mid-run has no effect. +/// The run covers [0, LineCount-at-entry); lines appended during the run belong to the tail path. +/// The engine never throws for filter errors and never touches UI: failures, like cancellation, +/// are reported as the run's . +/// +/// The dual-engine equivalence test table pins serial and parallel to byte-identical output. +/// +public interface IFilterEngine +{ + /// + /// Runs the filter over all lines available at entry. Progress is a cumulative scanned-line + /// count through the sink; cancellation yields with the + /// partial lists accumulated so far. + /// + FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress progress = null); +} diff --git a/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs new file mode 100644 index 00000000..8177c85e --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs @@ -0,0 +1,81 @@ +using LogExpert.Core.Callback; + +using NLog; + +namespace LogExpert.Core.Classes.Filter; + +/// +/// The multi-threaded Filter Engine: wraps 's chunk-and-merge machinery +/// (the file is split into ProcessorCount + 2 intervals, each filtered on its own worker with a +/// cloned , results merged sorted and deduplicated). Worker faults surface +/// as — never as a rethrow — and cancellation is forwarded to +/// the workers through the token. See for the shared contract. +/// +public class ParallelFilterEngine : IFilterEngine +{ + /// Chunk count beyond the processor count — today's effective FilterStarter value. + private const int CHUNK_FACTOR = 2; + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + private readonly int _chunkCount; + + public ParallelFilterEngine () : this(Environment.ProcessorCount + CHUNK_FACTOR) + { + } + + /// Fixed chunk count — used by tests to make chunk boundaries deterministic. + public ParallelFilterEngine (int chunkCount) + { + _chunkCount = chunkCount; + } + + public FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress progress = null) + { + ArgumentNullException.ThrowIfNull(filterParams); + ArgumentNullException.ThrowIfNull(callback); + + // Snapshot: the caller may mutate its instance while the run executes (workers clone + // again per thread, but their source must already be private to this run). + var snapshot = filterParams.CloneWithCurrentColumnizer(); + snapshot.Reset(); + + // Snapshot: lines appended during the run belong to the tail path. + var lineCount = callback.GetLineCount(); + + FilterStarter filterStarter = new(callback, _chunkCount); + + try + { + filterStarter + .DoFilter(snapshot, 0, lineCount, count => progress?.Report(count), cancellationToken) + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + _logger.Error(ex, "Exception while filtering (parallel engine)"); + return Snapshot(filterStarter, FilterRunOutcome.Failed, ex); + } + + var outcome = cancellationToken.IsCancellationRequested + ? FilterRunOutcome.Cancelled + : FilterRunOutcome.Completed; + + return Snapshot(filterStarter, outcome); + } + + /// + /// Materializes the run result. FilterStarter's merge already yields sorted-ascending, + /// deduplicated lists, satisfying the engine contract. + /// + private static FilterRun Snapshot (FilterStarter filterStarter, FilterRunOutcome outcome, Exception error = null) + { + return new FilterRun( + [.. filterStarter.FilterResultLines], + [.. filterStarter.FilterHitList], + [.. filterStarter.LastFilterLinesList], + outcome, + error); + } +} diff --git a/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs new file mode 100644 index 00000000..f6d246bc --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs @@ -0,0 +1,91 @@ +using LogExpert.Core.Callback; + +using NLog; + +namespace LogExpert.Core.Classes.Filter; + +/// +/// The single-threaded Filter Engine: one sequential pass over the file, accumulating through +/// . Ported from the Log Window's private filter loop; behaviour +/// changes versus that loop are the engine contract (params + line-count snapshot at entry, +/// Failed outcome instead of a MessageBox) — see . +/// +public class SerialFilterEngine : IFilterEngine +{ + private const int PROGRESS_REPORT_MODULO = 1000; + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + public FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress progress = null) + { + ArgumentNullException.ThrowIfNull(filterParams); + ArgumentNullException.ThrowIfNull(callback); + + // Snapshot: the caller may mutate its instance (filter panel edits) while the run executes. + var snapshot = filterParams.CloneWithCurrentColumnizer(); + snapshot.Reset(); + + // Snapshot: lines appended during the run belong to the tail path. + var lineCount = callback.GetLineCount(); + + var accumulator = new FilterAccumulator(); + var outcome = FilterRunOutcome.Completed; + + try + { + for (var lineNum = 0; lineNum < lineCount; lineNum++) + { + var line = callback.GetLogLineMemory(lineNum); + if (line == null) + { + break; + } + + callback.SetLineNum(lineNum); + if (Util.TestFilterCondition(snapshot, line, callback)) + { + accumulator.AddHit(lineNum, snapshot.SpreadBefore, snapshot.SpreadBehind, lineCount); + } + + // Checked after the line is processed, matching the original loop: a cancel + // requested mid-line still keeps that line's hit in the partial result. + if (cancellationToken.IsCancellationRequested) + { + outcome = FilterRunOutcome.Cancelled; + break; + } + + if ((lineNum + 1) % PROGRESS_REPORT_MODULO == 0) + { + progress?.Report(lineNum + 1); + } + } + } + catch (Exception ex) + { + _logger.Error(ex, "Exception while filtering (serial engine)"); + return Snapshot(accumulator, FilterRunOutcome.Failed, ex); + } + + return Snapshot(accumulator, outcome); + } + + /// + /// Materializes the run result under the engine contract: results and hits sorted ascending, + /// duplicates removed; history kept in accumulation order (it is dedup-window state, not output). + /// + private static FilterRun Snapshot (FilterAccumulator accumulator, FilterRunOutcome outcome, Exception error = null) + { + return new FilterRun( + Normalize(accumulator.ResultLines), + Normalize(accumulator.HitLines), + [.. accumulator.History], + outcome, + error); + } + + private static List Normalize (IList lines) + { + var normalized = new List(new SortedSet(lines)); + return normalized; + } +} diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index c73c3534..9ea090e3 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -1497,7 +1497,7 @@ public static string Lockfinder_Trace_RmEndSessionNativeMethodsRmEndSessionHandl } /// - /// Looks up a localized string similar to Error during {0} value {1}, min {2}, max {3}, visible {4}: {5}. + /// Looks up a localized string similar to Error during value {0}, min {1}, max {2}, visible {3}: {4}. /// public static string LogExpert_Common_Error_5Parameters_ErrorDuring0Value1Min2Max3Visible45 { get { @@ -1657,7 +1657,7 @@ public static string LogExpert_Common_UI_Message_ClipboardInUse { return ResourceManager.GetString("LogExpert_Common_UI_Message_ClipboardInUse", resourceCulture); } } - + /// /// Looks up a localized string similar to Deserialize. /// @@ -3405,6 +3405,15 @@ public static string LogWindow_UI_SelectLine_SearchResultNotFound { } } + /// + /// Looks up a localized string similar to Filter failed: {0}. + /// + public static string LogWindow_UI_StatusLineError_FilterFailed { + get { + return ResourceManager.GetString("LogWindow_UI_StatusLineError_FilterFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid regular expression. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 3af85639..adc90283 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -527,6 +527,9 @@ Nicht gefunden: {0} + + Filter fehlgeschlagen: {0} + Ungültige Regular Expression diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 9e7e5919..b6b804cd 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -293,6 +293,9 @@ Freeze left columns until here ({0}) + + Filter failed: {0} + Invalid regular expression diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 732b85fb..846b0563 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -199,6 +199,9 @@ 在此处冻结左侧列 ({0}) + + 筛选失败:{0} + 无效的正则表达式 diff --git a/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs b/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs new file mode 100644 index 00000000..51d0809b --- /dev/null +++ b/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs @@ -0,0 +1,58 @@ +using LogExpert.Core.Classes.Filter; + +using NUnit.Framework; + +namespace LogExpert.Tests.Filter; + +[TestFixture] +public class FilterAccumulatorTests +{ + /// + /// The accumulator is the single home of the per-hit recipe (hit → Expand → append → + /// TrimHistory). Expected values are the worked example pinned by + /// ' accumulation-recipe guard: hits 5, 7 and 50 with + /// spread 2/2 in a 100-line file. + /// + /// + /// The tail filter path continues a full run: the accumulator adopts the window's canonical + /// lists, so a history seeded by the run (here: the run already emitted 3..7 for a hit at 5) + /// dedups subsequent tail hits exactly as if one accumulation had never stopped. + /// + [Test] + public void AdoptedLists_HistorySeededByFullRun_DedupsContinuationHits () + { + List resultLines = [3, 4, 5, 6, 7]; + List hitLines = [5]; + List history = [3, 4, 5, 6, 7]; + var accumulator = new FilterAccumulator(resultLines, hitLines, history); + + var expansion = accumulator.AddHit(7, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + + Assert.Multiple(() => + { + // Hit 7 contributes only 8,9 — 5..7 were already taken by the seeded run. + Assert.That(expansion, Is.EqualTo(new[] { 8, 9 }), "AddHit returns the hit's expansion (Filter Pipes write it out)"); + Assert.That(resultLines, Is.EqualTo(new[] { 3, 4, 5, 6, 7, 8, 9 }), "the adopted list instance is the accumulator's state"); + Assert.That(hitLines, Is.EqualTo(new[] { 5, 7 })); + }); + } + + [Test] + public void AddHit_OverlappingHitSequence_ProducesPinnedResultHitAndHistoryLists () + { + var accumulator = new FilterAccumulator(); + + accumulator.AddHit(5, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + accumulator.AddHit(7, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + accumulator.AddHit(50, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + + Assert.Multiple(() => + { + // Hit 5 contributes 3..7; hit 7 contributes only 8,9 (5..7 already taken); hit 50 contributes 48..52. + Assert.That(accumulator.ResultLines, Is.EqualTo(new[] { 3, 4, 5, 6, 7, 8, 9, 48, 49, 50, 51, 52 })); + Assert.That(accumulator.HitLines, Is.EqualTo(new[] { 5, 7, 50 })); + Assert.That(accumulator.History, Is.EqualTo(accumulator.ResultLines), + "history below the window size mirrors the result lines"); + }); + } +} diff --git a/src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs b/src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs new file mode 100644 index 00000000..fe919371 --- /dev/null +++ b/src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs @@ -0,0 +1,305 @@ +using ColumnizerLib; + +using LogExpert.Core.Callback; +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Interfaces; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Filter; + +/// +/// The Filter Engine contract: every fixture runs through every engine and must produce +/// byte-identical, sorted-ascending, duplicate-free ResultLines/HitLines and the same Outcome. +/// Serial and parallel are held to each other by the same table. +/// +[TestFixture] +public class FilterEngineEquivalenceTests +{ + private static IEnumerable Engines () + { + yield return new SerialFilterEngine(); + // Fixed chunk count so the chunk-boundary fixtures land deterministically on every machine. + yield return new ParallelFilterEngine(chunkCount: 4); + } + + [TestCaseSource(nameof(Engines))] + public void Run_PlainTextHitsWithSpread_ReturnsCompletedSortedResults (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ERROR1", "ok2", "ok3", "ok4", "ERROR5", "ok6"); + var filterParams = ParamsOf("ERROR", spreadBefore: 1, spreadBehind: 1); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + // Hit 1 contributes 0..2, hit 5 contributes 4..6. + Assert.That(run.ResultLines, Is.EqualTo(new[] { 0, 1, 2, 4, 5, 6 })); + Assert.That(run.HitLines, Is.EqualTo(new[] { 1, 5 })); + }); + } + + /// + /// 20 lines, chunk count 4 → parallel chunk boundaries at 5, 10, 15. Hits at 4 and 5 sit on + /// either side of a boundary with spread 2 — each parallel worker starts with an empty dedup + /// window, so the overlap (lines 3..7) is emitted by both workers and must be merged away to + /// match the serial single-pass output. + /// + [TestCaseSource(nameof(Engines))] + public void Run_SpreadOverlapStraddlingChunkBoundary_MatchesSerialOutput (IFilterEngine engine) + { + var lines = Enumerable.Range(0, 20).Select(i => i is 4 or 5 ? $"ERROR{i}" : $"ok{i}").ToArray(); + var callback = CallbackOf(lines); + var filterParams = ParamsOf("ERROR", spreadBefore: 2, spreadBehind: 2); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + // Hit 4 contributes 2..6, hit 5 contributes only 7 — one deduplicated span. + Assert.That(run.ResultLines, Is.EqualTo(new[] { 2, 3, 4, 5, 6, 7 })); + Assert.That(run.HitLines, Is.EqualTo(new[] { 4, 5 })); + }); + } + + /// + /// Range filter (begin/end markers) spanning parallel chunk boundaries: begin at 2, end at 12, + /// chunks of 5. The worker owning the begin marker must overrun its chunk while in range; the + /// mid-range workers see no marker and contribute nothing; the merge yields the serial range. + /// + [TestCaseSource(nameof(Engines))] + public void Run_RangeFilterSpanningChunkBoundary_MatchesSerialOutput (IFilterEngine engine) + { + var lines = Enumerable.Range(0, 20) + .Select(i => i == 2 ? "BEGIN here" : i == 12 ? "END here" : $"ok{i}") + .ToArray(); + var callback = CallbackOf(lines); + var filterParams = ParamsOf("BEGIN"); + filterParams.IsRangeSearch = true; + filterParams.RangeSearchText = "END"; + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + // Every line from the begin marker through the end marker, inclusive. + Assert.That(run.HitLines, Is.EqualTo(Enumerable.Range(2, 11))); + Assert.That(run.ResultLines, Is.EqualTo(Enumerable.Range(2, 11))); + }); + } + + /// + /// A throwing filter condition (column-restrict with no columnizer — the realistic + /// "columnizer changed mid-flight" fault) is a narrated outcome, never a rethrow. This is the + /// fixture that pins the parallel path's crash fix: today's MultiThreadedFilter would take the + /// app down on this input. + /// + [TestCaseSource(nameof(Engines))] + public void Run_ThrowingFilterCondition_ReturnsFailedWithError (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ok1"); + var filterParams = ParamsOf("ERROR"); + filterParams.ColumnRestrict = true; + filterParams.ColumnList.Add(0); + filterParams.CurrentColumnizer = null; + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Failed)); + Assert.That(run.Error, Is.Not.Null); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_CancelledToken_ReturnsCancelledWithoutThrowing (IFilterEngine engine) + { + var callback = CallbackOf("ERROR0", "ERROR1", "ERROR2"); + var filterParams = ParamsOf("ERROR"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var run = engine.Run(filterParams, callback, cts.Token); + + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Cancelled)); + } + + /// + /// Serial-only pin: the cancel check sits after the line is processed (matching the original + /// loop), so a cancel requested before the first line still keeps that line's hit in the + /// partial result. + /// + [Test] + public void Run_Serial_CancelObservedAfterLineIsProcessed_KeepsFirstLinesHit () + { + var callback = CallbackOf("ERROR0", "ERROR1", "ERROR2"); + var filterParams = ParamsOf("ERROR"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var run = new SerialFilterEngine().Run(filterParams, callback, cts.Token); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Cancelled)); + Assert.That(run.HitLines, Is.EqualTo(new[] { 0 }), "partial results stand on cancel"); + }); + } + + /// + /// The engine snapshots FilterParams at entry: a caller mutating the shared instance while the + /// run executes (filter-panel edits) cannot affect the run. The mutation is injected through the + /// reader so it happens mid-run, synchronously. + /// + [TestCaseSource(nameof(Engines))] + public void Run_ParamsMutatedMidRun_OutcomeUnaffected (IFilterEngine engine) + { + var filterParams = ParamsOf("ERROR"); + var callback = CallbackOf(["ok0", "ERROR1", "ok2", "ERROR3"], + onLineRead: _ => filterParams.SearchText = "NEVERMATCHES"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.That(run.HitLines, Is.EqualTo(new[] { 1, 3 }), + "the snapshot taken at entry governs the whole run"); + } + + /// + /// The engine covers [0, LineCount-at-entry): lines appended while the run executes are not + /// filtered — they belong to the tail path. Pins the deliberate serial change (the old loop + /// chased EOF). + /// + [TestCaseSource(nameof(Engines))] + public void Run_LinesAppendedMidRun_AreNotFiltered (IFilterEngine engine) + { + var lines = new List { "ERROR0", "ok1", "ok2" }; + var callback = CallbackOf(lines, + onLineRead: _ => + { + if (lines.Count == 3) + { + lines.Add("ERROR3"); + lines.Add("ERROR4"); + } + }); + var filterParams = ParamsOf("ERROR"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.That(run.HitLines, Is.EqualTo(new[] { 0 }), + "lines appended after Run entry belong to the tail path"); + } + + [TestCaseSource(nameof(Engines))] + public void Run_HitAtLineZeroWithBackSpread_ClampsAtStartAndKeepsLineZero (IFilterEngine engine) + { + var callback = CallbackOf("ERROR0", "ok1", "ok2"); + var filterParams = ParamsOf("ERROR", spreadBefore: 2, spreadBehind: 1); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.ResultLines, Is.EqualTo(new[] { 0, 1 }), "line 0 is a valid hit; back spread clamps at the file start"); + Assert.That(run.HitLines, Is.EqualTo(new[] { 0 })); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_HitAtLastLineWithForeSpread_ClampsAtEndOfFile (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ok1", "ERROR2"); + var filterParams = ParamsOf("ERROR", spreadBefore: 1, spreadBehind: 2); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.ResultLines, Is.EqualTo(new[] { 1, 2 }), "fore spread clamps at the last line"); + Assert.That(run.HitLines, Is.EqualTo(new[] { 2 })); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_NoHits_ReturnsCompletedEmptyLists (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ok1", "ok2"); + var filterParams = ParamsOf("ERROR"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + Assert.That(run.ResultLines, Is.Empty); + Assert.That(run.HitLines, Is.Empty); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_EmptyFile_ReturnsCompletedEmptyLists (IFilterEngine engine) + { + var callback = CallbackOf(); + var filterParams = ParamsOf("ERROR"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + Assert.That(run.ResultLines, Is.Empty); + Assert.That(run.HitLines, Is.Empty); + }); + } + + #region Fixture helpers + + private static ColumnizerCallback CallbackOf (params string[] lines) + { + return CallbackOf(lines.ToList(), onLineRead: null); + } + + private static ColumnizerCallback CallbackOf (IList lines, Action onLineRead) + { + var reader = new Mock(); + _ = reader.Setup(r => r.LineCount).Returns(() => lines.Count); // live — grows when a fixture appends + + var window = new Mock(); + _ = window.Setup(w => w.LogFileReader).Returns(reader.Object); + _ = window.Setup(w => w.GetLineMemory(It.IsAny())) + .Returns((int lineNum) => + { + onLineRead?.Invoke(lineNum); + return lineNum >= 0 && lineNum < lines.Count ? LineOf(lines[lineNum]) : null!; + }); + + return new ColumnizerCallback(window.Object); + } + + private static ILogLineMemory LineOf (string text) + { + var mock = new Mock(); + _ = mock.Setup(l => l.FullLine).Returns(text.AsMemory()); + return mock.Object; + } + + private static FilterParams ParamsOf (string searchText, int spreadBefore = 0, int spreadBehind = 0) + { + var filterParams = new FilterParams + { + SearchText = searchText, + SpreadBefore = spreadBefore, + SpreadBehind = spreadBehind, + }; + filterParams.Init(); + return filterParams; + } + + #endregion +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 9a831067..7ef5a109 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -154,7 +154,10 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private SortedList _rowHeightList = []; private int _selectedCol; // set by context menu event for column headers only private bool _shouldCallTimeSync; - private bool _shouldCancel; + /// Window-lifetime cancellation: per-job token sources link to it, so closing the + /// window cancels every running job. Live jobs are also registered in the cancel-handler + /// registry, which ESC and reload fan out through. + private readonly CancellationTokenSource _windowCts = new(); private bool _isClosing; // set once CloseLogWindow starts tearing down; suppresses grid CellValueNeeded callbacks private bool _shouldTimestampDisplaySyncingCancel; private bool _showAdvanced; @@ -684,12 +687,9 @@ private void OnButtonSizeChanged (object sender, EventArgs e) private delegate void SelectLineFx (int line, bool triggerSyncCall); - private Action, List, List> FilterFxAction; - //private delegate void FilterFx(FilterParams filterParams, List filterResultLines, List lastFilterResultLines, List filterHitList); private delegate void SetColumnizerFx (ILogLineMemoryColumnizer columnizer); - private delegate void WriteFilterToTabFinishedFx (FilterPipe pipe, string namePrefix, PersistenceData persistenceData); private delegate void SetBookmarkFx (int lineNum, string comment); @@ -2662,12 +2662,11 @@ private void RestoreFilterTabs (PersistenceData persistenceData) { var persistFilterParams = data.FilterParams; ReInitFilterParams(persistFilterParams); - List filterResultList = []; - //List lastFilterResultList = new List(); - List filterHitList = []; - Filter(persistFilterParams, filterResultList, _lastFilterLinesList, filterHitList); + // Each restored tab runs its own filter with fresh spread history — its content no + // longer depends on the window's shared history state at restore time. + var filterRun = new SerialFilterEngine().Run(persistFilterParams, new ColumnizerCallback(this), CancellationToken.None); FilterPipe pipe = new(persistFilterParams.Clone(), this); - WritePipeToTab(pipe, filterResultList, data.PersistenceData.TabName, data.PersistenceData); + WritePipeToTab(pipe, [.. filterRun.ResultLines], data.PersistenceData.TabName, data.PersistenceData); } } @@ -2708,7 +2707,7 @@ private void EnterLoadFileStatus () SendProgressBarUpdate(); _isLoading = true; - _shouldCancel = true; + FireCancelHandlers(); // reload cancels the jobs of the old content, not the window lifetime _searchCts?.Cancel(); ClearFilterList(); ClearBookmarkList(); @@ -2748,7 +2747,7 @@ private void LogfileDead () _statusEventArgs.LineCount = 0; _statusEventArgs.CurrentLineNum = 0; SendStatusLineUpdate(); - _shouldCancel = true; + FireCancelHandlers(); // a dead file may respawn — cancel its jobs, keep the window lifetime _searchCts?.Cancel(); ClearFilterList(); ClearBookmarkList(); @@ -2941,7 +2940,6 @@ private void LoadingFinished () StatusLineText(string.Empty); _logFileReader.FileSizeChanged += OnFileSizeChanged; _isLoading = false; - _shouldCancel = false; dataGridView.SuspendLayout(); dataGridView.RowCount = _logFileReader.LineCount; dataGridView.CurrentCellChanged += OnDataGridViewCurrentCellChanged; @@ -3170,127 +3168,81 @@ private void UpdateGrid (LogEventArgs logEventArgs) private void CheckFilterAndHighlight (LogEventArgs e) { + // The Tail trigger path (CONTEXT.md): the one loop that evaluates highlight entries against + // newly appended lines. Side-effecting triggers (Audio Alert, Set Bookmark, Stop Tail) fire + // here and only here — the bulk scanner (HighlightBookmarkScanner) has no access to them. + var doFilter = filterTailCheckBox.Checked || _filterPipeList.Count > 0; var noLed = true; + var firstStopTail = true; + var filterLineAdded = false; - if (filterTailCheckBox.Checked || _filterPipeList.Count > 0) + var startLine = e.PrevLineCount; + if (e.IsRollover) { - var filterStart = e.PrevLineCount; - if (e.IsRollover) + ShiftFilterLines(e.RolloverOffset); + startLine -= e.RolloverOffset; + } + + ColumnizerCallback callback = new(this); + + for (var i = startLine; i < e.LineCount; ++i) + { + var line = _logFileReader.GetLogLineMemory(i); + if (line == null) { - ShiftFilterLines(e.RolloverOffset); - filterStart -= e.RolloverOffset; + // End of available lines — stop scanning, but still flush the work already done below. + break; } - var firstStopTail = true; - ColumnizerCallback callback = new(this); - var filterLineAdded = false; - for (var i = filterStart; i < e.LineCount; ++i) + if (doFilter) { - var line = _logFileReader.GetLogLineMemory(i); - if (line == null) - { - return; - } - if (filterTailCheckBox.Checked) { callback.SetLineNum(i); if (Util.TestFilterCondition(_filterParams, line, callback)) { - //AddFilterLineFx addFx = new AddFilterLineFx(AddFilterLine); - //this.Invoke(addFx, new object[] { i, true }); filterLineAdded = true; - AddFilterLine(i, false, _filterParams, _filterResultList, _lastFilterLinesList, _filterHitList); + AddFilterLine(i, false); } } - //ProcessFilterPipeFx pipeFx = new ProcessFilterPipeFx(ProcessFilterPipes); - //pipeFx.BeginInvoke(i, null, null); ProcessFilterPipes(i); + } - var matchingList = FindMatchingHighlightEntries(line); - LaunchHighlightPlugins(matchingList, i); - var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); - SafeTriggerAudioAlert(matchingList); - if (setBookmark) - { - var capturedLineNum = i; - var capturedComment = bookmarkComment; - _ = BeginInvoke(() => SetBookmarkFromTrigger(capturedLineNum, capturedComment)); - } + var matchingList = FindMatchingHighlightEntries(line); + LaunchHighlightPlugins(matchingList, i); + var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); + SafeTriggerAudioAlert(matchingList); + if (setBookmark) + { + var capturedLineNum = i; + var capturedComment = bookmarkComment; + _ = BeginInvoke(() => SetBookmarkFromTrigger(capturedLineNum, capturedComment)); + } - if (stopTail && _guiStateArgs.FollowTail) - { - var wasFollow = _guiStateArgs.FollowTail; - FollowTailChanged(false, true); - if (firstStopTail && wasFollow) - { - //_ = Invoke(new SelectLineFx(SelectAndEnsureVisible), [i, false]); - var capturedLineNum = i; - _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); - firstStopTail = false; - } - } + if (stopTail && _guiStateArgs.FollowTail) + { + FollowTailChanged(false, true); - if (!suppressLed) + // Scroll to the triggering line once per batch, even if follow-tail gets + // re-enabled while the batch is still being processed. + if (firstStopTail) { - noLed = false; + var capturedLineNum = i; + _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); + firstStopTail = false; } } - if (filterLineAdded) + if (!suppressLed) { - //AddFilterLineGuiUpdateFx addFx = new AddFilterLineGuiUpdateFx(AddFilterLineGuiUpdate); - //this.Invoke(addFx); - TriggerFilterLineGuiUpdate(); + noLed = false; } } - else - { - var firstStopTail = true; - var startLine = e.PrevLineCount; - if (e.IsRollover) - { - ShiftFilterLines(e.RolloverOffset); - startLine -= e.RolloverOffset; - } - - for (var i = startLine; i < e.LineCount; ++i) - { - var line = _logFileReader.GetLogLineMemory(i); - if (line != null) - { - var matchingList = FindMatchingHighlightEntries(line); - LaunchHighlightPlugins(matchingList, i); - var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); - SafeTriggerAudioAlert(matchingList); - if (setBookmark) - { - var capturedLineNum = i; - var capturedComment = bookmarkComment; - _ = BeginInvoke(() => SetBookmarkFromTrigger(capturedLineNum, capturedComment)); - //_ = fx.BeginInvoke(i, bookmarkComment, null, null); - } - - if (stopTail && _guiStateArgs.FollowTail) - { - var wasFollow = _guiStateArgs.FollowTail; - FollowTailChanged(false, true); - if (firstStopTail && wasFollow) - { - //_ = Invoke(new SelectLineFx(SelectAndEnsureVisible), [i, false]); - var capturedLineNum = i; - _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); - firstStopTail = false; - } - } - if (!suppressLed) - { - noLed = false; - } - } - } + if (filterLineAdded) + { + TriggerFilterLineGuiUpdate(); } if (!noLed) @@ -4113,16 +4065,6 @@ private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll) try { _shouldCallTimeSync = triggerSyncCall; - var wasCancelled = _shouldCancel; - _shouldCancel = false; - _isSearching = false; - StatusLineText(string.Empty); - _guiStateArgs.MenuEnabled = true; - - if (wasCancelled) - { - return; - } if (lineNum < 0) { @@ -4445,7 +4387,6 @@ private async void FilterSearch (string text) //ConfigManager.SaveFilterParams(this.filterParams); ConfigManager.Settings.FilterParams = _filterParams; // wozu eigentlich? sinnlos seit MDI? - _shouldCancel = false; _isSearching = true; StatusLineText(Resources.LogWindow_UI_StatusLineText_FilterSearch_Filtering); btnfilterSearch.Enabled = false; @@ -4457,120 +4398,88 @@ private async void FilterSearch (string text) _progressEventArgs.Visible = true; SendProgressBarUpdate(); - var settings = ConfigManager.Settings; - - FilterFxAction = settings.Preferences.MultiThreadFilter ? MultiThreadedFilter : Filter; - var filterFxActionTask = Task.Run(() => FilterFxAction(_filterParams, _filterResultList, _lastFilterLinesList, _filterHitList)).ConfigureAwait(false); + IFilterEngine engine = ConfigManager.Settings.Preferences.MultiThreadFilter + ? new ParallelFilterEngine() + : new SerialFilterEngine(); - await filterFxActionTask; - FilterComplete(); - - //fx.BeginInvoke(_filterParams, _filterResultList, _lastFilterLinesList, _filterHitList, FilterComplete, null); - //This needs to be invoked, because there is a potential CrossThreadException - _ = BeginInvoke(CheckForFilterDirty); - } - - private void MultiThreadedFilter (FilterParams filterParams, List filterResultLines, List lastFilterLinesList, List filterHitList) - { ColumnizerCallback callback = new(this); + var progress = new SynchronousProgress(UpdateProgressBar); - FilterStarter fs = new(callback, Environment.ProcessorCount + 2) - { - FilterHitList = _filterHitList, - FilterResultLines = _filterResultList, - LastFilterLinesList = _lastFilterLinesList - }; - - var cancelHandler = new FilterCancelHandler(fs); + // ESC reaches the run through the cancel-handler registry; the engines observe the token. + using var filterCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); + var cancelHandler = new FilterRunCancelHandler(filterCts); OnRegisterCancelHandler(cancelHandler); - long startTime = Environment.TickCount; - - fs.DoFilter(filterParams, 0, _logFileReader.LineCount, FilterProgressCallback).GetAwaiter().GetResult(); - - long endTime = Environment.TickCount; - - //_logger.Debug($"Multi threaded filter duration: {endTime - startTime} ms.")); - - OnDeRegisterCancelHandler(cancelHandler); - StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_Filter_FilterDurationMs, endTime - startTime)); - } - private void FilterProgressCallback (int lineCount) - { - UpdateProgressBar(lineCount); - } - - [SupportedOSPlatform("windows")] - private void Filter (FilterParams filterParams, List filterResultLines, List lastFilterLinesList, List filterHitList) - { long startTime = Environment.TickCount; + FilterRun filterRun; try { - filterParams.Reset(); - var lineNum = 0; - //AddFilterLineFx addFx = new AddFilterLineFx(AddFilterLine); - ColumnizerCallback callback = new(this); - while (true) - { - var line = _logFileReader.GetLogLineMemory(lineNum); - if (line == null) - { - break; - } + filterRun = await Task.Run(() => engine.Run(_filterParams, callback, filterCts.Token, progress)).ConfigureAwait(false); + } + finally + { + OnDeRegisterCancelHandler(cancelHandler); + } - callback.LineNum = lineNum; - if (Util.TestFilterCondition(filterParams, line, callback)) - { - AddFilterLine(lineNum, false, filterParams, filterResultLines, lastFilterLinesList, filterHitList); - } + long endTime = Environment.TickCount; - lineNum++; - if (lineNum % PROGRESS_BAR_MODULO == 0) - { - UpdateProgressBar(lineNum); - } + lock (_filterResultList) + { + _filterHitList.AddRange(filterRun.HitLines); + _filterResultList.AddRange(filterRun.ResultLines); + _lastFilterLinesList.AddRange(filterRun.History); + FilterSpread.TrimHistory(_lastFilterLinesList); + } - if (_shouldCancel) - { - break; - } - } + // Closing the window cancels the run (linked token), so this continuation also fires + // mid-close — with the handle already destroyed, narration and BeginInvoke would throw. + // _isClosing (set before the cancel) is the discriminator, NOT IsHandleCreated: a session- + // restored background tab runs this with its own handle not yet created, marshaling + // through the parent — and must still reach FilterComplete to populate the filter grid. + if (IsDisposed || Disposing || _waitingForClose || _isClosing) + { + return; } - catch (Exception ex) + + if (filterRun.Outcome == FilterRunOutcome.Failed) + { + StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_FilterFailed, filterRun.Error?.Message)); + } + else { - _ = MessageBox.Show(null, string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_Filter_ExceptionWhileFiltering, ex, ex.StackTrace), Resources.LogExpert_Common_UI_Title_Error); + StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_Filter_FilterDurationMs, endTime - startTime)); } - long endTime = Environment.TickCount; + FilterComplete(); - //_logger.Info($"Single threaded filter duration: {endTime - startTime} ms.")); + //This needs to be invoked, because there is a potential CrossThreadException + _ = BeginInvoke(CheckForFilterDirty); + } - StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_Filter_FilterDurationMs, endTime - startTime)); + /// Bridges the ESC cancel-handler registry to a filter run's token. + private sealed class FilterRunCancelHandler (CancellationTokenSource cts) : IBackgroundProcessCancelHandler + { + public void EscapePressed () + { + cts.Cancel(); + } } [SupportedOSPlatform("windows")] - private void AddFilterLine (int lineNum, bool immediate, FilterParams filterParams, List filterResultLines, List lastFilterLinesList, List filterHitList) + private void AddFilterLine (int lineNum, bool immediate) { - int count; lock (_filterResultList) { - filterHitList.Add(lineNum); - var filterResult = FilterSpread.Expand(lineNum, filterParams.SpreadBefore, filterParams.SpreadBehind, _logFileReader.LineCount, lastFilterLinesList); - filterResultLines.AddRange(filterResult); - count = filterResultLines.Count; - lastFilterLinesList.AddRange(filterResult); - FilterSpread.TrimHistory(lastFilterLinesList); + // Adopts the window's canonical lists per call — ClearFilterList/ShiftFilterLines + // replace the instances, so the accumulator must not outlive them. + new FilterAccumulator(_filterResultList, _filterHitList, _lastFilterLinesList) + .AddHit(lineNum, _filterParams.SpreadBefore, _filterParams.SpreadBehind, _logFileReader.LineCount); } if (immediate) { TriggerFilterLineGuiUpdate(); } - else if (lineNum % PROGRESS_BAR_MODULO == 0) - { - //FunctionWith1IntParam fx = new FunctionWith1IntParam(UpdateFilterCountLabel); - //this.Invoke(fx, new object[] { count}); - } } [SupportedOSPlatform("windows")] @@ -5017,7 +4926,11 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n _progressEventArgs.Visible = true; _ = Invoke(new MethodInvoker(SendProgressBarUpdate)); _isSearching = true; - _shouldCancel = false; + + // Per-job token, linked to the window's lifetime; ESC reaches it via the registry. + using var pipeCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); + var cancelHandler = new FilterRunCancelHandler(pipeCts); + OnRegisterCancelHandler(cancelHandler); lock (_filterPipeList) { @@ -5029,37 +4942,53 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n pipe.OpenFile(); LogExpertCallback callback = new(this); - foreach (var i in lineNumberList) + try { - if (_shouldCancel) + foreach (var i in lineNumberList) { - break; - } + if (pipeCts.Token.IsCancellationRequested) + { + break; + } - var line = _logFileReader.GetLogLineMemory(i); - if (CurrentColumnizer is ILogLineMemoryXmlColumnizer) - { - callback.LineNum = i; - line = (CurrentColumnizer as ILogLineMemoryXmlColumnizer).GetLineTextForClipboard(line, callback); - } + var line = _logFileReader.GetLogLineMemory(i); + if (CurrentColumnizer is ILogLineMemoryXmlColumnizer) + { + callback.LineNum = i; + line = (CurrentColumnizer as ILogLineMemoryXmlColumnizer).GetLineTextForClipboard(line, callback); + } - _ = pipe.WriteToPipe(line, i); - if (++count % PROGRESS_BAR_MODULO == 0) - { - _progressEventArgs.Value = count; - _ = Invoke(new MethodInvoker(SendProgressBarUpdate)); + _ = pipe.WriteToPipe(line, i); + if (++count % PROGRESS_BAR_MODULO == 0) + { + _progressEventArgs.Value = count; + _ = Invoke(new MethodInvoker(SendProgressBarUpdate)); + } } } + finally + { + OnDeRegisterCancelHandler(cancelHandler); + } pipe.CloseFile(); - _ = Invoke(new WriteFilterToTabFinishedFx(WriteFilterToTabFinished), pipe, name, persistenceData); + var wasCancelled = pipeCts.IsCancellationRequested; + + // Same close-mid-job hazard as the filter run's epilogue: skip the UI finish when the + // window went away while writing (_isClosing, not IsHandleCreated — see FilterSearch). + if (IsDisposed || Disposing || _waitingForClose || _isClosing) + { + return; + } + + Invoke(() => WriteFilterToTabFinished(pipe, name, persistenceData, wasCancelled)); } [SupportedOSPlatform("windows")] - private void WriteFilterToTabFinished (FilterPipe pipe, string name, PersistenceData persistenceData) + private void WriteFilterToTabFinished (FilterPipe pipe, string name, PersistenceData persistenceData, bool wasCancelled) { _isSearching = false; - if (!_shouldCancel) + if (!wasCancelled) { var title = name; ILogLineMemoryColumnizer preProcessColumnizer = null; @@ -5107,7 +5036,7 @@ internal void WritePipeTab (IList lineEntryList, string title) } pipe.CloseFile(); - _ = Invoke(new WriteFilterToTabFinishedFx(WriteFilterToTabFinished), [pipe, title, null]); + Invoke(() => WriteFilterToTabFinished(pipe, title, null, wasCancelled: false)); } [SupportedOSPlatform("windows")] @@ -5153,17 +5082,15 @@ private void ProcessFilterPipes (int lineNum) continue; } - //long startTime = Environment.TickCount; if (Util.TestFilterCondition(pipe.FilterParams, searchLine, callback)) { - var filterResult = FilterSpread.Expand(lineNum, pipe.FilterParams.SpreadBefore, pipe.FilterParams.SpreadBehind, _logFileReader.LineCount, pipe.LastLinesHistoryList); + // Adopts the pipe's history; result/hit lists are not tracked per pipe. + var filterResult = new FilterAccumulator([], [], pipe.LastLinesHistoryList) + .AddHit(lineNum, pipe.FilterParams.SpreadBefore, pipe.FilterParams.SpreadBehind, _logFileReader.LineCount); pipe.OpenFile(); foreach (var line in filterResult) { - pipe.LastLinesHistoryList.Add(line); - FilterSpread.TrimHistory(pipe.LastLinesHistoryList); - var textLine = _logFileReader.GetLogLineMemory(line); var fileOk = pipe.WriteToPipe(textLine, line); if (!fileOk) @@ -5174,9 +5101,6 @@ private void ProcessFilterPipes (int lineNum) pipe.CloseFile(); } - - //long endTime = Environment.TickCount; - //_logger.logDebug("ProcessFilterPipes(" + lineNum + ") duration: " + ((endTime - startTime))); } } @@ -5465,9 +5389,15 @@ private void TestStatistic (PatternArgs patternArgs) List blockList = []; var blockId = 0; _isSearching = true; - _shouldCancel = false; + + // Per-job token, linked to the window's lifetime; ESC reaches it via the registry. + using var patternCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); + var cancelHandler = new FilterRunCancelHandler(patternCts); + OnRegisterCancelHandler(cancelHandler); + var cancellationToken = patternCts.Token; + var searchLine = -1; - for (var i = beginLine; i < num && !_shouldCancel; ++i) + for (var i = beginLine; i < num && !cancellationToken.IsCancellationRequested; ++i) { if (processedLinesDict.ContainsKey(i)) { @@ -5481,14 +5411,15 @@ private void TestStatistic (PatternArgs patternArgs) //bool firstBlock = true; searchLine++; UpdateProgressBar(searchLine); - while (!_shouldCancel && + while (!cancellationToken.IsCancellationRequested && (block = DetectBlock(i, searchLine, maxBlockLen, _patternArgs.MaxDiffInBlock, _patternArgs.MaxMisses, - processedLinesDict) + processedLinesDict, + cancellationToken) ) != null) { //_logger.Debug($"Found block: {block}"); @@ -5527,6 +5458,7 @@ private void TestStatistic (PatternArgs patternArgs) blockId++; } + OnDeRegisterCancelHandler(cancelHandler); _isSearching = false; _progressEventArgs.MinValue = 0; _progressEventArgs.MaxValue = 0; @@ -5567,7 +5499,7 @@ private static void AddBlockTargetLinesToDict (Dictionary dict, Patter // return null; //} - private PatternBlock DetectBlock (int startNum, int startLineToSearch, int maxBlockLen, int maxDiffInBlock, int maxMisses, Dictionary processedLinesDict) + private PatternBlock DetectBlock (int startNum, int startLineToSearch, int maxBlockLen, int maxDiffInBlock, int maxMisses, Dictionary processedLinesDict, CancellationToken cancellationToken) { var targetLine = FindSimilarLine(startNum, startLineToSearch, processedLinesDict); if (targetLine == -1) @@ -5592,7 +5524,7 @@ private PatternBlock DetectBlock (int startNum, int startLineToSearch, int maxBl }; block.QualityInfoList[targetLine] = qi; - while (!_shouldCancel) + while (!cancellationToken.IsCancellationRequested) { srcLine++; len++; @@ -6428,9 +6360,17 @@ public void CloseLogWindow () StopTimespreadThread(); StopTimestampSyncThread(); StopLogEventWorkerThread(); - _shouldCancel = true; + _windowCts.Cancel(); // window lifetime ends: every linked job token cancels _searchCts?.Cancel(); + // Jobs cancelled by the close skip their UI epilogues (the handle is going away), so the + // closing window clears the shared status line and progress bar itself, while its events + // are still wired to the parent. + StatusLineText(string.Empty); + _progressEventArgs.Visible = false; + _progressEventArgs.Value = _progressEventArgs.MaxValue; + SendProgressBarUpdate(); + if (_logFileReader != null) { UnRegisterLogFileReaderEvents(); @@ -6930,7 +6870,6 @@ public void StartSearch () _currentSearchParams = searchParams; // remember for async "not found" messages _isSearching = true; - _shouldCancel = false; // shared flag: SelectLine's cancel gate and the filter loops read it StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel); _progressEventArgs.MinValue = 0; @@ -6939,13 +6878,12 @@ public void StartSearch () _progressEventArgs.Visible = true; SendProgressBarUpdate(); - // Replace the token source only after a consumed cancellation (mirrors the old - // _shouldCancel = false reset); a still-running previous search keeps sharing it, - // so one ESC cancels every running search. + // Replace the token source only after a consumed cancellation; a still-running previous + // search keeps sharing it, so one ESC cancels every running search. if (_searchCts == null || _searchCts.IsCancellationRequested) { _searchCts?.Dispose(); - _searchCts = new CancellationTokenSource(); + _searchCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); } var cancellationToken = _searchCts.Token; @@ -6969,12 +6907,14 @@ private void SearchComplete (Task task) { _ = Invoke(new MethodInvoker(ResetProgressBar)); var result = task.Result; + _isSearching = false; // was SelectLine's job when it doubled as the search epilogue _guiStateArgs.MenuEnabled = true; GuiStateUpdate(this, _guiStateArgs); switch (result.Outcome) { case SearchOutcome.Found: + StatusLineText(string.Empty); _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), result.LineNumber, true); break; @@ -7058,7 +6998,6 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) { if (_isSearching) { - _shouldCancel = true; // shared flag: filter loops and SelectLine's cancel gate read it _searchCts?.Cancel(); } diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 7c958309..f39f98ad 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-09 21:05:43 UTC + /// Generated: 2026-07-11 15:26:05 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "81F2CD2C2F2A67C577F3DA2BB1C52884249332B4A2741FC921C18D5AF9F68B30", + ["AutoColumnizer.dll"] = "1A1D4B6181F93955C71CB0591779628EDCCBCD4D46891DD2FF36828086C883C9", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726", - ["CsvColumnizer.dll (x86)"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726", - ["DefaultPlugins.dll"] = "4B5206DB37D091B48037DE2C03A573BD3678772F412B128C1E57614E1EDC9065", - ["FlashIconHighlighter.dll"] = "D12A6517CC0402A8E3A580CFE50F2AB81C15F4019159B784416EC7F8FE73FBA8", - ["GlassfishColumnizer.dll"] = "2BA6B760A044FEE75EDC3BC478605E9E0AA9CE4F52C9F435EBA38A9EDD45A109", - ["JsonColumnizer.dll"] = "FD407BC814B287588F4DB0FE90BAA104BE5050D7F6097D183F9E0BE906EC8C5E", - ["JsonCompactColumnizer.dll"] = "B80B2AD30181E94C08286835CE01C607FA7FD66722EE0C5DB2A7C16AB357A09F", - ["Log4jXmlColumnizer.dll"] = "942FCD055F0C9BC2D141D9D2B407BAB14F89719F8AF35F50326F77FC89C74C42", - ["LogExpert.Resources.dll"] = "85E2154489ADDC1DC52B67EA0784519AB6427BECB4AD1B8A2BE29D796D3C41C8", + ["CsvColumnizer.dll"] = "6A2FD7BA67EE1DF94CF11D326E6E66EF341B3E4025B1EDA0ED599F4A256817E4", + ["CsvColumnizer.dll (x86)"] = "6A2FD7BA67EE1DF94CF11D326E6E66EF341B3E4025B1EDA0ED599F4A256817E4", + ["DefaultPlugins.dll"] = "8CFAC3764C109FFB1292B3DF62BABDFFDCF9025DAD09A93D9EDA60B322624237", + ["FlashIconHighlighter.dll"] = "A1F84BF9CB7A061719633CA879B65A7EB3D6A7E48C1C0B2BCEFEF2A461347941", + ["GlassfishColumnizer.dll"] = "1A70A1C2498170F21674B5891A93631AC9DDCE3D1D8CC547E3755DA91F8E897D", + ["JsonColumnizer.dll"] = "7FA4DABA1E29841B0DC254BA9047E96C70E10A086F063F6D1089E94D567CE110", + ["JsonCompactColumnizer.dll"] = "52F762DFBDA5771FEFE65E8A4A326F23D5FB33F69933E33A4FB9A236953B38B7", + ["Log4jXmlColumnizer.dll"] = "1A50FF0ED6C60D934B05058987F8A2C19300449E95AD2324B42432FF20339FB6", + ["LogExpert.Resources.dll"] = "EF96F863858116E94F7FF4C635CEE3AF2A43FCACC2EEE89E081E2CC5C739874A", ["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"] = "ECDDA17BBC01DF90E82065BAE66223568C4F0B44E3F49F595A6178CC4D5A5F5B", - ["SftpFileSystem.dll"] = "EA5235D8C1787798A349D38BEE086035AB79AA25DBD1C7C1713FA36379614690", - ["SftpFileSystem.dll (x86)"] = "398428B6D28C3FA4494BDD67156CAC72240B0E74D963F24B8B77295CA11C7E93", - ["SftpFileSystem.Resources.dll"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890", - ["SftpFileSystem.Resources.dll (x86)"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890", + ["RegexColumnizer.dll"] = "C6D70682B1708E8EF4479D105CBC6F3A92E9C5A6FB7E1C5D68C4B6ECB5CDD75F", + ["SftpFileSystem.dll"] = "E2551A64D602C59F069B6600FABDA8ACB49E9D6B9697C82DE8166BAF0E3836B9", + ["SftpFileSystem.dll (x86)"] = "37F2E09661274513A2B2418BD0CFE6012C8704F74B041A118B125CCB90CC4E14", + ["SftpFileSystem.Resources.dll"] = "A8934B9231E463B7A9ED62589523FF045489721AF1A4E6F941241CA1D285FC7F", + ["SftpFileSystem.Resources.dll (x86)"] = "A8934B9231E463B7A9ED62589523FF045489721AF1A4E6F941241CA1D285FC7F", }; }