Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
08b9b86
feat: Filter Engine module in Core, table-tested and unused (ticket 1)
Hirogen Jul 11, 2026
5339e04
refactor: unify the tail trigger-dispatch loop bodies (ticket 2)
Hirogen Jul 11, 2026
a685b9c
refactor: migrate the full filter run to the Filter Engines (ticket 3)
Hirogen Jul 11, 2026
691e52f
refactor: migrate the tail filter path to FilterAccumulator (ticket 4)
Hirogen Jul 11, 2026
b7a293a
refactor: retire _shouldCancel - window CTS + per-job linked tokens (…
Hirogen Jul 11, 2026
b51874d
docs: Filter Engine glossary entries - contract sweep (ticket 6)
Hirogen Jul 11, 2026
e120823
chore: update plugin hashes [skip ci]
github-actions[bot] Jul 11, 2026
4ca9490
fix: guard filter epilogues against close-mid-run (smoke finding)
Hirogen Jul 11, 2026
28ed4b4
fix: clear the status bar when a tab closes mid-job (smoke finding)
Hirogen Jul 11, 2026
192fd26
fix: empty filter panel after session restore (smoke finding)
Hirogen Jul 11, 2026
0118dbd
feat: re-wire the orphaned Pattern analysis menu item (spike)
Hirogen Jul 11, 2026
282880e
fix: actually show the Pattern Window (spike, part 2)
Hirogen Jul 11, 2026
95f8f60
Revert "fix: actually show the Pattern Window (spike, part 2)"
Hirogen Jul 11, 2026
5a42957
Revert "feat: re-wire the orphaned Pattern analysis menu item (spike)"
Hirogen Jul 11, 2026
efabafc
Merge branch 'Development' into feature/filter-engine-extraction
Hirogen Jul 11, 2026
4974ad4
chore: update plugin hashes [skip ci]
github-actions[bot] Jul 11, 2026
4694c23
refactor: drop the tautological wasFollow check in the stop-tail disp…
Hirogen Jul 11, 2026
fb2de8d
chore: normalize Resources.Designer.cs to generator output
Hirogen Jul 11, 2026
af71efd
chore: update plugin hashes [skip ci]
github-actions[bot] Jul 11, 2026
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
32 changes: 28 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 4 additions & 5 deletions src/LogExpert.Core/Classes/Filter/Filter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,21 @@ public Filter (ColumnizerCallback callback)
public List<int> FilterResultLines { get; }
public List<int> LastFilterLinesList { get; }
public List<int> 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<int> filterResultLines, List<int> lastFilterLinesList, List<int> filterHitList, ProgressCallback progressCallback)
private int DoFilter (FilterParams filterParams, int startLine, int maxCount, List<int> filterResultLines, List<int> lastFilterLinesList, List<int> filterHitList, ProgressCallback progressCallback, CancellationToken cancellationToken)
{
var lineNum = startLine;
var count = 0;
Expand All @@ -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())
{
Expand Down
60 changes: 60 additions & 0 deletions src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace LogExpert.Core.Classes.Filter;

/// <summary>
/// The single home of the per-hit filter accumulation recipe: hit → <see cref="FilterSpread.Expand"/> →
/// append to results/history → <see cref="FilterSpread.TrimHistory"/>. 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.
/// </summary>
public class FilterAccumulator
{
public FilterAccumulator () : this([], [], [])
{
}

/// <summary>
/// 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.
/// </summary>
public FilterAccumulator (IList<int> resultLines, IList<int> hitLines, IList<int> history)
{
ArgumentNullException.ThrowIfNull(resultLines);
ArgumentNullException.ThrowIfNull(hitLines);
ArgumentNullException.ThrowIfNull(history);

ResultLines = resultLines;
HitLines = hitLines;
History = history;
}

/// <summary>Filter result lines: every hit plus its spread context, in accumulation order.</summary>
public IList<int> ResultLines { get; }

/// <summary>The hit lines only, without spread.</summary>
public IList<int> HitLines { get; }

/// <summary>The trailing dedup window (spread history) recent expansions were checked against.</summary>
public IList<int> History { get; }

/// <summary>
/// Records a filter hit: adds it to <see cref="HitLines"/> and appends its spread expansion
/// (deduplicated against <see cref="History"/>, clamped to the file's line range) to
/// <see cref="ResultLines"/>. Returns the expansion so callers that emit it (Filter Pipes)
/// need not diff the lists.
/// </summary>
public IList<int> 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;
}
}
30 changes: 0 additions & 30 deletions src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs

This file was deleted.

27 changes: 27 additions & 0 deletions src/LogExpert.Core/Classes/Filter/FilterRun.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace LogExpert.Core.Classes.Filter;

/// <summary>How a Filter Run ended.</summary>
public enum FilterRunOutcome
{
/// <summary>All lines in the run's range were filtered.</summary>
Completed,

/// <summary>The run observed cancellation; the lists hold the partial results accumulated so far.</summary>
Cancelled,

/// <summary>The filter condition threw; <see cref="FilterRun.Error"/> carries the exception.
/// The caller narrates — the engine never rethrows.</summary>
Failed,
}

/// <summary>
/// The result of one Filter Run. <see cref="ResultLines"/> and <see cref="HitLines"/> are sorted
/// ascending without duplicates (the engine contract); <see cref="History"/> is the spread-dedup
/// window state in accumulation order, handed to the tail filter path to continue from.
/// </summary>
public sealed record FilterRun (
IReadOnlyList<int> ResultLines,
IReadOnlyList<int> HitLines,
IReadOnlyList<int> History,
FilterRunOutcome Outcome,
Exception Error = null);
42 changes: 5 additions & 37 deletions src/LogExpert.Core/Classes/Filter/FilterStarter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,10 @@ public class FilterStarter
private readonly List<Filter> _filterReadyList;
private readonly SortedDictionary<int, int> _filterResultDict;

private readonly List<Filter> _filterWorkerList;

private readonly SortedDictionary<int, int> _lastFilterLinesDict;

private ProgressCallback _progressCallback;
private int _progressLineCount;
private bool _shouldStop;

#endregion

Expand All @@ -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
Expand All @@ -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();
Expand All @@ -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;

Expand Down Expand Up @@ -106,31 +96,14 @@ 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;
}

await Task.WhenAll(tasks).ConfigureAwait(false);
MergeResults();
}

/// <summary>
/// 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.
/// </summary>
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
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/LogExpert.Core/Classes/Filter/IFilterEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using LogExpert.Core.Callback;

namespace LogExpert.Core.Classes.Filter;

/// <summary>
/// A Filter Engine executes one full Filter Run over a log file and returns a <see cref="FilterRun"/>.
/// Two engines implement the seam — <see cref="SerialFilterEngine"/> and <see cref="ParallelFilterEngine"/>,
/// selected by the MultiThreadFilter preference — and are held to an identical contract:
/// <list type="bullet">
/// <item><see cref="FilterRun.ResultLines"/> and <see cref="FilterRun.HitLines"/> are sorted ascending with no duplicates.</item>
/// <item><see cref="FilterParams"/> is snapshotted at entry — mutating the caller's instance mid-run has no effect.</item>
/// <item>The run covers <c>[0, LineCount-at-entry)</c>; lines appended during the run belong to the tail path.</item>
/// <item>The engine never throws for filter errors and never touches UI: failures, like cancellation,
/// are reported as the run's <see cref="FilterRun.Outcome"/>.</item>
/// </list>
/// The dual-engine equivalence test table pins serial and parallel to byte-identical output.
/// </summary>
public interface IFilterEngine
{
/// <summary>
/// Runs the filter over all lines available at entry. Progress is a cumulative scanned-line
/// count through the sink; cancellation yields <see cref="FilterRunOutcome.Cancelled"/> with the
/// partial lists accumulated so far.
/// </summary>
FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress<int> progress = null);
}
Loading