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:
Kamron Batman 2026-07-21 07:51:06 -07:00 committed by GitHub
parent 858c1d18bc
commit 1e97ed50f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 842 additions and 209 deletions

View file

@ -282,6 +282,65 @@ public MyItem(Serial serial) : base(serial) { }
// ModernUO — DELETE THIS CONSTRUCTOR. The source generator creates it.
```
## 15. Static `Parse(string)``IParsable<T>` / `ISpanParsable<T>`
RunUO predates `IParsable<T>`/`ISpanParsable<T>` (C# 11 / .NET 7 static-abstract interface
members), so RunUO types that convert from a string expose a bare `public static T Parse(string value)`.
**ModernUO expects any such type to implement `IParsable<T>` (string) and, where practical,
`ISpanParsable<T>` (span; it extends `IParsable<T>`, so implement span and you get both).**
This matters because the engine's string→value converter, `Server.Types.TryParse` — used by `[set`,
`[props`, spawner property assignment, the conditional-command compiler (`[where`), and Advanced
Search — binds to the `Parse(string, IFormatProvider)` signature. A type with **only** a legacy
`Parse(string)` is discovered by `Types` through a reflection fallback, but that fallback is a safety
net, not the intended path: a bare `Parse(string)` is easy to miss, doesn't participate in the
span-based fast paths, and (if it returns `null` instead of throwing) makes `[set` silently assign
`null` on bad input. Convert it.
The `Parse` overloads throw `FormatException` on failure; `TryParse` returns `false`. Delegate the
string overloads to a span core (see `Race`, `Poison`, `Point3D` for the established pattern):
```csharp
// RunUO
public abstract class Faction : IComparable<Faction>
{
public static Faction Parse(string name) // returns null on no-match — wrong contract, not IParsable
{
// ... linear search by name ...
return null;
}
}
// ModernUO
public abstract class Faction : IComparable<Faction>, ISpanParsable<Faction>
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Faction Parse(string s) => Parse(s, null);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryParse(string s, IFormatProvider provider, out Faction result) =>
TryParse(s.AsSpan(), provider, out result);
public static Faction Parse(ReadOnlySpan<char> s, IFormatProvider provider) =>
TryParse(s, provider, out var result)
? result
: throw new FormatException($"The input string '{s}' was not in a correct format.");
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider provider, out Faction result)
{
// ... linear search by name using s.InsensitiveEquals(...) ...
result = null;
return false;
}
}
```
To find un-migrated types: search for `public static [A-Za-z0-9_<>]+ Parse\(string ` and check whether
the declaring type lists `IParsable<T>`/`ISpanParsable<T>`.
## Quick Checklist
When migrating any RunUO script, apply these changes in order:
@ -300,6 +359,7 @@ When migrating any RunUO script, apply these changes in order:
12. [ ] Modernize property syntax
13. [ ] Remove `Serial` constructor (handled by serialization generator)
14. [ ] Update usings
15. [ ] Convert bare static `Parse(string)` to `IParsable<T>`/`ISpanParsable<T>`
## See Also