fix: WearableStorage locks on the shared base-registry monitor#9424
fix: WearableStorage locks on the shared base-registry monitor#9424eordano wants to merge 1 commit into
Conversation
…ity swap fully unequips Production evidence (decentraland Sentry, archive snapshot 2026-07-17): - UNITY-EXPLORER-PF3: 17 events / 1 users, last seen 2026-07-06 - UNITY-EXPLORER-KW0: 5 events / 2 users, last seen 2026-07-13 - UNITY-TEST-ENVIRONMENT-TT: 4 events / 2 users, last seen 2026-03-26 - UNITY-EXPLORER-NJ1: 3 events / 3 users, last seen 2026-04-19 - UNITY-EXPLORER-NJ0: 3 events / 3 users, last seen 2026-03-17 - UNITY-EXPLORER-MBC: 3 events / 1 users, last seen 2026-02-16
|
Slack notification sent to #explorer-ext-contributions for external review. |
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review: fix: WearableStorage locks on the shared base-registry monitor
STEP 2 — Root-cause check: PASS ✅
The PR fixes two distinct root causes confirmed by Sentry:
KeyNotFoundExceptionon identity swap (#9304upper_body, #9303body_shape):Equip()can add non-priority category keys (categories outsideCATEGORIES_PRIORITY). The oldUnEquipAll()only nulled priority-category entries, leaving stale non-priority keys. After an identity change, lookups for standard categories that happened to not be in the dictionary threwKeyNotFoundExceptionvia the directwearables[category]indexer.- Shadowed
lockObjectinWearableStorage: The derived class declared its ownprivate readonly object lockObject, shadowing the base classAvatarElementNftRegistry.lockObject(protected). Result:lock(lockObject)inWearableStoragemethods andlock(lockObject)inAvatarElementNftRegistrymethods used different monitors — concurrent callers could interleave unsafely across base/derived boundaries.
Both fixes address causes, not symptoms.
STEP 3 — Design & integration: PASS ✅
No new units introduced. All changes modify existing methods in EquippedWearables and remove dead/shadowing fields from WearableStorage.
UnEquipAll()Clear+re-seed: Correct approach. The dictionary must (a) lose all non-priority keys from the previous identity and (b) retain seeded priority keys withnullvalues forItems()consumers. Clear+Add achieves both.Wearable()→TryGetValue: Aligns the implementation with the interface contract (IWearable? Wearable(string)— nullable return). The old direct indexer violated this by throwing instead of returningnull.Clear()→UnEquipAll(): Maintains the seeding invariant. The old barewearables.Clear()left the dictionary empty, so subsequentWearable()calls for priority categories would find no key.- Removed
ownedNftsRegistry: Dead field — shadowedAvatarElementNftRegistry.ownedNftRegistryand was never referenced by any code inWearableStorage. - Removed
lockObject: Fixes the shadowing bug. Alllock()calls inWearableStoragenow use the base class'sprotected lockObject, synchronizing withSetOwnedNft,TryGetOwnedNftRegistry, etc.
STEP 4 — Member audit
Wearable(string category) — 4 external callers found:
ElementProviderHelper.cs:50—equippedWearables.Wearable(BODY_SHAPE)!.GetUrn()(null-forgiving!)ProfileExtensions.cs:38—equippedWearables.Wearable(BODY_SHAPE)!.GetUrn()(null-forgiving!)HideCategoriesController.cs— properly handles nullableBackpackBusController.cs— properly handles nullable
The ! usages in ElementProviderHelper and ProfileExtensions are pre-existing — they assumed BODY_SHAPE is always seeded, which remains true after this fix (priority categories are always re-seeded). Not a regression.
IsEquipped(ITrimmedWearable) — now routes through Wearable() instead of the direct indexer. Correct — single code path for dictionary access.
STEP 5 — Line-level review
One P2 finding posted as an inline comment below.
Security review: No security issues. The locking fix improves thread safety. No secrets, no input validation concerns, no auth changes.
STEP 6 — Complexity: SIMPLE
2 files, +11/−9 lines, straightforward dictionary-management fix and dead-field removal.
STEP 7 — QA: YES
Touches runtime avatar rendering code (EquippedWearables is used during identity swap, backpack, profile updates). Identity swap and wearable equip/unequip flows should be tested.
STEP 8 — Non-blocking warnings
None.
REVIEW_RESULT: PASS ✅
COMPLEXITY: SIMPLE
COMPLEXITY_REASON: Dictionary-management fix in EquippedWearables and dead-field removal in WearableStorage — no ECS, async, or architectural changes.
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by eordano via Slack
| // Re-seed instead of a bare Dictionary.Clear(): reads rely on every seeded category key | ||
| // surviving with a null value (Clear() made category reads throw KeyNotFoundException after | ||
| // an identity change), and Equip() can add non-priority category keys that must not keep | ||
| // the previous identity's wearables equipped. |
There was a problem hiding this comment.
[P2] Comment narrates caller/external behavior (CLAUDE.md §11 "Comments that narrate caller/external behavior"). The WHY (Clear+re-seed vs. bare null-assignment) is genuinely non-obvious and worth documenting, but the current 4-line comment describes what Equip() does and what callers expect — behavior that can change without UnEquipAll() changing. Trim to the invariant itself.
| // Re-seed instead of a bare Dictionary.Clear(): reads rely on every seeded category key | |
| // surviving with a null value (Clear() made category reads throw KeyNotFoundException after | |
| // an identity change), and Equip() can add non-priority category keys that must not keep | |
| // the previous identity's wearables equipped. | |
| // Clear + re-seed: bare null-assignment leaves non-priority keys from a prior Equip(); | |
| // bare Clear() drops the seeded keys that reads and Items() depend on. |
Identity swap fully unequips
Production evidence (decentraland Sentry, archive snapshot 2026-07-17):
fixes #9304
fixes #9303
fixes #7397
fixes #7396
Supersedes #9410: moved from the fork into the org repo so CI workflows receive repository secrets (fork PRs do not).