fix: Harden Advanced Search: crash-safety, autosave, correct results & worker fixes (#2543)
## Summary
Hardens the **Advanced Search** engine (`Projects/UOContent/Engines/Advanced Search/`) — the GM entity finder that fans searches across background worker threads. A code review surfaced 14 defects (A–N), including a shard-crasher reachable from a single admin typo and a path that silently disables autosave for the rest of the shard's uptime. Each behavioral fix ships with a test.
Full `UOContent.Tests` suite: **530/530 green** (21 new AdvancedSearch tests).
## Fixes
### Crash / data-loss
- **A — Shard crash on a malformed Property Test.** `AdvancedSearchThreadWorker.Execute` had no `try/catch` and the worker `Thread` is foreground, so a parse throw (`Hits>abc`, `Layer=onehanded` — `Enum.Parse` was case-sensitive, `Hits>1@` — empty sub-expression indexing) terminated the process. Now: `ParseValue`/`CompareValues` use `TryParse`/`Enum.TryParse(ignoreCase)` and return no-match instead of throwing; the per-entity filter is wrapped in `try/catch` (logs + skips); empty expressions are guarded.
- **C — Overlapping searches corrupt state + brick autosave.** `_threadWorkers`/`_threadId` were `static` but `DoSearch` is an instance method; a second search (double-click / two admins) stomped shared worker state and could leave a drain waiting forever on the shared `AutoResetEvent`, so `AutoSave.SavesEnabled` was never restored. Now: an `Interlocked` re-entrancy guard rejects concurrent searches.
- **G — Autosave restore not guaranteed.** The restore lived only in the success callback. Now it's in a `finally` (plus an outer `catch` covering the synchronous setup and a `catch` on the drain body), so autosave + the guard are always released.
### Wrong results
- **D — `@`/`|` operator precedence.** `a@b|c` evaluated as `a && (b || c)` instead of `(a && b) || c`. OR now binds looser than AND (`AdvancedSearchUtilities.EvaluateBoolean`, unit-tested).
- **E — Descending sort, partial last page rendered blank** (the index decreased in descending mode and the `break` early-out killed the loop). Now a bounded `VisibleCount`-driven loop renders the last page in both directions.
- **F — Deleted entities** were not skipped (ghost rows). Now `DoEntitySearch` skips `entity.Deleted`.
- **N — Reference-type comparisons** threw (`Comparer<T>.Default.Compare` on non-`IComparable`) and compared references to a string. Now equality is by value and ordering is guarded to `IComparable` (no throw).
### Worker perf / hardening
- **H** busy-spin → `Thread.Yield()` in the drain; **I** `GetProperties()` cached per `Type`; **J** `HandleValidInternal` moved behind the cheap map/range/region filters; **K** worker threads are `IsBackground` + `Exit()` tolerates an already-terminated worker; **L** `_filter == null` guard; **M** consistent `Volatile` access on `_pause`/`_exit`.
### Documented
- **B** — the residual worker/event-loop read race is documented on `AdvancedSearchThreadWorker`: workers read live entity state concurrently with the loop, so value-type reads may be stale-but-safe and getter exceptions are swallowed; fully eliminating it would require snapshotting entity fields on the main thread (deferred).
## Notes
- New test-only seams (`TryBeginSearch`/`EndSearch`/`IsSearchInProgress`/`VisibleCount`/`TryParseValue`/`EvaluateBoolean`) are `internal` via the existing `InternalsVisibleTo("UOContent.Tests")`.
- Dead `public ParseValue<T>` removed.
- `ConcurrentDictionary` for the reflection cache is intentional — these workers are genuinely parallel.
This commit is contained in:
parent
858c1d18bc
commit
1e97ed50f6
13 changed files with 842 additions and 209 deletions
|
|
@ -8,6 +8,7 @@ using Server.Commands;
|
|||
using Server.Commands.Generic;
|
||||
using Server.Engines.Spawners;
|
||||
using Server.Gumps;
|
||||
using Server.Logging;
|
||||
using Server.Network;
|
||||
using Server.Saves;
|
||||
|
||||
|
|
@ -28,11 +29,19 @@ public enum AdvancedSearchGumpOptions : long
|
|||
|
||||
public class AdvancedSearchGump : Gump
|
||||
{
|
||||
private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchGump));
|
||||
|
||||
private const int MaxEntries = 18;
|
||||
|
||||
private static int _threadId;
|
||||
private static AdvancedSearchThreadWorker[] _threadWorkers;
|
||||
|
||||
private static int _searchInProgress;
|
||||
|
||||
internal static bool IsSearchInProgress => Volatile.Read(ref _searchInProgress) == 1;
|
||||
internal static bool TryBeginSearch() => Interlocked.CompareExchange(ref _searchInProgress, 1, 0) == 0;
|
||||
internal static void EndSearch() => Volatile.Write(ref _searchInProgress, 0);
|
||||
|
||||
private static void Configure()
|
||||
{
|
||||
EventSink.Shutdown += Shutdown;
|
||||
|
|
@ -124,6 +133,10 @@ public class AdvancedSearchGump : Gump
|
|||
|
||||
public AdvancedSearchGump() : base(50, 50) => Build();
|
||||
|
||||
// Entries on the current page — bounds the paging loop so a partial last page can't read past the end.
|
||||
internal static int VisibleCount(int total, int displayFrom, int maxEntries) =>
|
||||
Math.Clamp(total - displayFrom, 0, maxEntries);
|
||||
|
||||
private void Build()
|
||||
{
|
||||
const int height = 500;
|
||||
|
|
@ -319,15 +332,13 @@ public class AdvancedSearchGump : Gump
|
|||
|
||||
var allDisplayedSelected = true;
|
||||
|
||||
for (var i = 0; i < MaxEntries; i++)
|
||||
{
|
||||
var offset = SortDescending ? MaxEntries - 1 - i : i;
|
||||
var index = offset + DisplayFrom;
|
||||
// Bound to this page's real entries so a partial last page still renders in descending mode.
|
||||
var visibleCount = VisibleCount(SearchResults.Length, DisplayFrom, MaxEntries);
|
||||
|
||||
if (index >= SearchResults.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
for (var i = 0; i < visibleCount; i++)
|
||||
{
|
||||
var offset = SortDescending ? visibleCount - 1 - i : i;
|
||||
var index = offset + DisplayFrom;
|
||||
|
||||
var entry = SearchResults[index];
|
||||
|
||||
|
|
@ -739,78 +750,103 @@ public class AdvancedSearchGump : Gump
|
|||
return;
|
||||
}
|
||||
|
||||
if (!TryBeginSearch())
|
||||
{
|
||||
from.SendMessage("A search is already running. Please wait for it to finish.");
|
||||
return;
|
||||
}
|
||||
|
||||
_threadId = 0;
|
||||
|
||||
var autoSave = AutoSave.SavesEnabled;
|
||||
if (autoSave)
|
||||
AutoSave.SavesEnabled = false;
|
||||
|
||||
try
|
||||
{
|
||||
AutoSave.SavesEnabled = false;
|
||||
}
|
||||
_threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)];
|
||||
|
||||
_threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)];
|
||||
var ignoreQueue = new ConcurrentQueue<IEntity>();
|
||||
var results = new ConcurrentQueue<AdvancedSearchResult>();
|
||||
var worldLocation = new WorldLocation(from.Location, from.Map);
|
||||
|
||||
var ignoreQueue = new ConcurrentQueue<IEntity>();
|
||||
var results = new ConcurrentQueue<AdvancedSearchResult>();
|
||||
var worldLocation = new WorldLocation(from.Location, from.Map);
|
||||
|
||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
||||
{
|
||||
(_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue);
|
||||
}
|
||||
|
||||
var type = Filter.FilterType ? Filter.Type : null;
|
||||
|
||||
// Push the entities
|
||||
foreach (var item in World.Items.Values)
|
||||
{
|
||||
if (type == null || type.IsInstanceOfType(item))
|
||||
{
|
||||
PushToWorkers(item);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
if (type == null || type.IsInstanceOfType(m))
|
||||
{
|
||||
PushToWorkers(m);
|
||||
}
|
||||
}
|
||||
|
||||
ThreadPool.QueueUserWorkItem(state =>
|
||||
{
|
||||
// Block until everything is processed
|
||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
||||
{
|
||||
_threadWorkers[i].Sleep();
|
||||
(_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue);
|
||||
}
|
||||
|
||||
var ignoredEntities = new HashSet<IEntity>(ignoreQueue);
|
||||
var type = Filter.FilterType ? Filter.Type : null;
|
||||
|
||||
// Force the GC to collect the ignored entities
|
||||
ignoreQueue.Clear();
|
||||
|
||||
var resultsList = new List<AdvancedSearchResult>(results.Count);
|
||||
foreach (var result in results)
|
||||
// Push the entities. Workers read entity state concurrently with the main loop —
|
||||
// see AdvancedSearchThreadWorker for the accepted, bounded race.
|
||||
foreach (var item in World.Items.Values)
|
||||
{
|
||||
if (!ignoredEntities.Contains(result.Entity))
|
||||
if (type == null || type.IsInstanceOfType(item))
|
||||
{
|
||||
resultsList.Add(result);
|
||||
PushToWorkers(item);
|
||||
}
|
||||
}
|
||||
|
||||
SearchResults = resultsList.ToArray();
|
||||
|
||||
// Force the GC to collect the results
|
||||
resultsList.Clear();
|
||||
resultsList.TrimExcess();
|
||||
|
||||
// Send the gump on the main thread
|
||||
Core.LoopContext.Post(
|
||||
autoSaveState =>
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
if (type == null || type.IsInstanceOfType(m))
|
||||
{
|
||||
AutoSave.SavesEnabled = (bool)autoSaveState!;
|
||||
Resend(from);
|
||||
}, state);
|
||||
}, autoSave);
|
||||
PushToWorkers(m);
|
||||
}
|
||||
}
|
||||
|
||||
ThreadPool.QueueUserWorkItem(state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Block until everything is processed
|
||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
||||
{
|
||||
_threadWorkers[i].Sleep();
|
||||
}
|
||||
|
||||
var ignoredEntities = new HashSet<IEntity>(ignoreQueue);
|
||||
|
||||
// Force the GC to collect the ignored entities
|
||||
ignoreQueue.Clear();
|
||||
|
||||
var resultsList = new List<AdvancedSearchResult>(results.Count);
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (!ignoredEntities.Contains(result.Entity))
|
||||
{
|
||||
resultsList.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
SearchResults = resultsList.ToArray();
|
||||
|
||||
// Force the GC to collect the results
|
||||
resultsList.Clear();
|
||||
resultsList.TrimExcess();
|
||||
|
||||
// Send the gump on the main thread
|
||||
Core.LoopContext.Post(() => Resend(from));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A drain-phase throw here would terminate the process; the finally still
|
||||
// restores autosave and releases the guard.
|
||||
_logger.Warning(ex, "AdvancedSearch: search drain failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
AutoSave.SavesEnabled = (bool)state!;
|
||||
EndSearch();
|
||||
}
|
||||
}, autoSave);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Setup failed before the work item took ownership of the release.
|
||||
AutoSave.SavesEnabled = autoSave;
|
||||
EndSearch();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetSortSwitches(int radioSwitch)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue