fix(opl): refuse property list invalidation raised from inside GetProperties
Any property getter reached from GetProperties that calls InvalidateProperties
takes the tooltip build down with it:
System.ArgumentNullException: Value cannot be null. (Parameter 'array')
at Server.ObjectPropertyList.AppendStringDirect(String value)
at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list)
InvalidateProperties rebuilds in place -- Reset() then GetProperties() again on
the same instance -- and Reset() does two destructive things to a build already
in flight. It returns the pooled interpolation scratch buffer, which the compiler
rents in the interpolated-string handler ctor and returns in the closing Add, so
every hole is evaluated while that buffer is live; the next Append* then spans a
null array. It also rewinds the packet cursor, so properties already written are
overwritten by the nested pass.
There is no correct recovery, and retrying the build would only hide the defect,
so the engine refuses: the nested call logs an error with a stack trace, throws
in DEBUG so it gets found and fixed, and in RELEASE returns without touching the
list -- a possibly stale tooltip, but no crash, no corrupted packet, and nothing
leaked back to the pool. Getters that genuinely must invalidate should defer with
Timer.DelayCall(InvalidateProperties).
The guard flag lives on the ObjectPropertyList rather than on the entity: it is
that list's own lifecycle, it costs nothing (both Item and ObjectPropertyList
absorb it in existing padding, and the list is allocated lazily), and it stays
correct when builds for different entities nest.
Factions PlayerState was the getter that surfaced this, and it is now maintained
rather than lazily computed:
- Rank is a plain field read. The lazy `if (m_InvalidateRank)` recompute is gone
along with the flag itself; UpdateRank() recomputes at each point an input
actually changes (the RankIndex setter, the end of the KillPoints setter once
the swap bookkeeping and ZeroRankOffset have settled, Faction.AddMember after
the member is inserted, and FactionState after a load once the ordering is
final). All six readers of Rank were checked; none relied on the old side
effect.
- Both constructors seed the lowest rank. Nothing recomputes on read any more, so
Rank must be usable immediately -- including for members that never get a
RankIndex assigned, which is every member with no kill points.
- Rank always resolves. Ranks are ordered by Required descending ending at 0, so
a negative percent (RankIndex out of sync with ZeroRankOffset) matched nothing
and left m_Rank null -- an NRE on Rank.Title. It no longer divides by a zero
ZeroRankOffset either.
- Fixes a pre-existing staleness bug: the KillPoints setter writes m_RankIndex
directly in two places, bypassing the property setter, so the cached rank was
never refreshed when a player crossed zero kill points.
PropertyList also publishes the list into m_PropertyList before building it
rather than assigning through `??=` afterwards, so a nested InvalidateProperties
sees the build in progress instead of recursing into a second throwaway list.
ObjectPropertyList re-rents its scratch buffer instead of spanning a null array,
so a stray Reset() from any other caller degrades rather than aborting
GetProperties.
Documents the rule as audit rule 19 in CLAUDE.md, a new section in
dev-docs/property-lists.md, and the property-lists and code-audit skills.
This commit is contained in:
parent
967ddf48fa
commit
ca338f023c
12 changed files with 531 additions and 45 deletions
|
|
@ -191,8 +191,17 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold"
|
|||
|
||||
**See**: `dev-docs/string-handling.md` § "Interpolation Anti-Patterns" for the full reference with detailed before/after examples.
|
||||
|
||||
### 19. No InvalidateProperties From Inside GetProperties
|
||||
**Check**: Any property read by a `GetProperties` override — including through helpers — must be a pure read. Flag getters that call `InvalidateProperties()` (or a wrapper like `Invalidate()`) as a side effect.
|
||||
**Bad**: a `Rank` getter that lazily recomputes and then calls `Invalidate()`; reading it from `GetProperties` re-enters the build.
|
||||
**Good**: invalidate in the setter that actually changes the value, or defer with `Timer.DelayCall(InvalidateProperties)`.
|
||||
**Why**: `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild). `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. Re-entering mid-build throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from a line unrelated to the offending getter, or silently corrupts the tooltip. The engine refuses and logs an error, and `DEBUG` throws, so this shows up as a crash in development.
|
||||
**Note**: Lazy recomputation inside a getter is fine. It is the notification that must not happen there.
|
||||
|
||||
**See**: `dev-docs/property-lists.md` § "Never Invalidate From Inside `GetProperties`".
|
||||
|
||||
## Severity Levels
|
||||
- **ERROR**: Rules 3, 9, 10, 13 (will cause bugs, build failures, or client-side leaks)
|
||||
- **ERROR**: Rules 3, 9, 10, 13, 19 (will cause bugs, build failures, or client-side leaks)
|
||||
- **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14, 15, 17 (performance/convention issues)
|
||||
- **INFO**: Rules 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation), 16 (switch patterns — suggest but don't flag)
|
||||
- **ASK**: Rule 11 (need user input)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ description: >
|
|||
3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax
|
||||
4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change
|
||||
5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip
|
||||
6. **Never invalidate from inside `GetProperties`** -- every property a `GetProperties` override
|
||||
reads must be a pure read. `InvalidateProperties()` rebuilds in place (`Reset()` + rebuild), so a
|
||||
getter with that side effect tears down the list mid-build: it returns the pooled interpolation
|
||||
buffer under an in-flight `$"..."` handler (`ArgumentNullException`, parameter `"array"`) and
|
||||
rewinds the packet cursor. The engine refuses and logs an error; `DEBUG` throws. Defer instead:
|
||||
`Timer.DelayCall(InvalidateProperties)`
|
||||
|
||||
## IPropertyList Interface
|
||||
|
||||
|
|
@ -235,6 +241,7 @@ block.Add("Cannot be repaired".AsSpan()); // plain span, no string alloc
|
|||
- **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops
|
||||
- **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed
|
||||
- **One giant `Add()` for multi-line text**: A property over ~512 chars crashes the legacy 2D client. Use `AddChunked`/`OplTextBlock` for variable-length free text
|
||||
- **Side-effecting property getters**: A getter reached from `GetProperties` that calls `InvalidateProperties()` (directly or via a helper like `Invalidate()`) re-enters the build and is refused — error logged, `DEBUG` throws. Lazy recomputation in a getter is fine; the *notification* is not. Invalidate where the value changes, or `Timer.DelayCall(InvalidateProperties)`
|
||||
|
||||
## Real Examples
|
||||
- Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties)
|
||||
|
|
|
|||
|
|
@ -301,6 +301,66 @@ public void UseCharge()
|
|||
}
|
||||
```
|
||||
|
||||
### Never Invalidate From Inside `GetProperties` (CRITICAL)
|
||||
|
||||
`InvalidateProperties()` rebuilds the list **in place** — `Reset()`, then `GetProperties()` again on
|
||||
the same instance. Calling it from a property getter that the build itself reaches is therefore
|
||||
re-entrant, and `Reset()` does two destructive things to the build in flight:
|
||||
|
||||
1. It returns the pooled interpolation scratch buffer. The compiler rents that buffer in the
|
||||
interpolated-string handler's constructor and returns it in the closing `Add`, so **every hole is
|
||||
evaluated while the buffer is live**. Pulling it out mid-append makes the next `Append*` span a
|
||||
null array — `ArgumentNullException: Value cannot be null. (Parameter 'array')` thrown out of
|
||||
`GetProperties`, from a line that looks unrelated to the getter that caused it.
|
||||
2. It rewinds the packet cursor, so properties already written are overwritten by the nested pass.
|
||||
|
||||
This is always a defect in the property getter, so the engine refuses rather than trying to recover:
|
||||
a nested call logs an error with a stack trace, throws in `DEBUG`, and in `RELEASE` returns without
|
||||
touching the list — leaving a possibly stale tooltip, but never a crash, a corrupted packet, or a
|
||||
leaked pool buffer. Retrying the build would only hide the bug.
|
||||
|
||||
```csharp
|
||||
// BAD -- a getter with a side effect. Reading it from GetProperties re-enters the build.
|
||||
public RankDefinition Rank
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_invalidateRank)
|
||||
{
|
||||
_rank = Recompute();
|
||||
_invalidateRank = false;
|
||||
Invalidate(); // -> InvalidateProperties() -> Reset() on the list being built
|
||||
}
|
||||
|
||||
return _rank;
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD -- getters stay side-effect free; invalidate where the value actually changes.
|
||||
public int RankIndex
|
||||
{
|
||||
get => _rankIndex;
|
||||
set
|
||||
{
|
||||
if (_rankIndex != value)
|
||||
{
|
||||
_rankIndex = value;
|
||||
_invalidateRank = true;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lazy recomputation inside a getter is fine — it is the *notification* that must not happen there. If
|
||||
something genuinely must invalidate in response to a read, defer it off the build:
|
||||
|
||||
```csharp
|
||||
Timer.DelayCall(InvalidateProperties);
|
||||
```
|
||||
|
||||
**Check when writing a `GetProperties` override**: every property it reads must be a pure read.
|
||||
|
||||
## ObjectPropertyList Internals
|
||||
|
||||
Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue