Repository navigation
Hold the game read lock from resolve to last dereference (Resolved<T>) - #56
Conversation
3799353 to
c878775
Compare
| } | ||
|
|
||
| std::optional<Level> Level::Get(uint32_t levelNo) { | ||
| GameReadLock guard; |
There was a problem hiding this comment.
Could move below the zero check.
There was a problem hiding this comment.
Done - moved below the check. Narrowed the other lock sites the same way: lock taken after argument checks and released before V8 work in FindNextInventoryItem, Unit::ItemCost/TakeWaypoint/Overhead/GetSkillName, AcceptTrade/TradeOK/ClickBodyLocation/ClickContainerSlot/GetDialogLines, and getCollision(level,x,y)/getRoom/getPresetUnit/getPresetUnits/getControls (data copied out under one lock, JS values built after); and dropped 22 binding locks that only wrapped a single self-locking or waiting call (JSRoom, JSStashTab, JSUnit, getParty, getStashTabs, getDialogLines, clickItem, getDistance, acceptTrade, tradeOk, revealLevel). Consistent walks still hold one lock throughout.
c878775 to
7fd2063
Compare
Handle accessors called ResolvePtr(), which took a GameReadLock only for
the resolve itself, then dereferenced the returned raw game pointer with no
lock held. The game thread holds the write lock for its whole frame, so it
could free or relink the structure between the resolve and the read.
Resolved<T> (contract, HandleCache.h) is a move-only RAII value that takes
a GameReadLock, then resolves the handle through the unchanged ResolvePtr()
/ HandleCache path, and holds the lock for its lifetime. Handles produce it
with a private Resolve<T>(), T being the backend's own struct type, so the
backend stops hand-casting void* and the contract stays free of backend
types. `->` / `*` reach the struct and a named Resolved converts implicitly
to T* (which also serves the null test); the conversion is deleted on a
temporary, so a pointer cannot outlive its lock. Every lod114d accessor now
reads through it:
const auto u = Resolve<D2UnitStrc>();
if (!u) return 0;
return u->field;
ResolvePtr() takes no lock of its own any more; debug builds assert that
the calling thread holds the game lock (a read lock, or the game thread's
write lock). Validity tests (operator bool) and static factories that read
game memory hold an explicit GameReadLock. Level::Name and
Unit::GetSkillLevel resolved with no lock held and now take one.
Free functions that looked a raw game pointer up and read through it with
no lock held now take one: ClickItem (item lookup; its reads are copied out
before the dispatch waits on the game thread), ResolveBeltSize (inventory
walk), IsScrollingText (Storm window-handler lists), CheckUnitCollision, and
ClickPartyMember, which handed a roster pointer to the game after dropping
the lock.
Bridge::Lock() was a second name for the same lock; it is gone. Lock sites
are narrowed: taken after argument checks, released before V8 allocation,
and dropped where a binding wrapped a single call that locks (or waits)
itself. Reads that must agree - a walk, a level and its rooms - stay under
one lock.
A wait that hands the lock back (GameThread::Execute, PollUntil, anything
built on GameReadLockReleaser) invalidates every pointer resolved before
it. Each releaser that actually gives a lock back (GameReadLockReleaser
releasing a held read lock, GameWriteLockReleaser yielding the write lock)
bumps a thread-local release epoch; Resolved<T> snapshots it and, in debug
builds, asserts it unchanged on each access. Release builds compile the
check out. The methods that wait keep their reads in a scope that closes
before the wait (Unit::Move, Unit::Interact, Unit::EquipItem, Control::Click),
or post a task that re-resolves the handle on the game thread
(Control::SetText, Room::Reveal).
Tests cover Resolved<T> holding, releasing, nesting and moving the lock,
the deleted rvalue conversion, the release epoch across both releasers, and
IsGameLockHeld().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
7fd2063 to
73513af
Compare
What races
Handle accessors called
ResolvePtr(), which took aGameReadLockonly for the resolve itself. The caller then dereferenced the returned raw game pointer with no lock held (review item G18). The game thread holds the write lock for its whole frame, so between the resolve and the read it could free or relink the unit / room / control being read.Design:
Resolved<T>Resolved<T>(contract,src/contract/game/HandleCache.h) is a move-only RAII value that:GameReadLockbefore the resolve,ResolvePtr(), so every resolve still goes through the per-frameHandleCache. Caching and its invalidation are unchanged,->/*for the struct, and an implicitoperator T*() const&to pass it on. That conversion also serves the null test (if (!u)). It is deleted on a temporary (operator T*() const&& = delete), soT* p = Resolve<T>();does not compile and a pointer can't outlive its lock. There is noGet()and no separateoperator bool.Each handle (
Unit,Room,Level,Party,Control) gets a privateResolve<T>().Tis the backend's own struct type, so the contract never names a backend type and the backend stops hand-casting (AsUnit(ResolvePtr())):All lod114d accessors read through it. That is 116 methods: 73 on Unit, 16 on Room, 2 on Level, 10 on Party, 15 on Control, plus the Unit/Room
operator==.ResolvePtr()no longer locks. It asserts in debug builds that the calling thread already holds the game lock (IsGameLockHeld(): a read lock, or the game thread's write lock). Validity tests (operator bool) and static factories that read game memory directly hold an explicitGameReadLock.Unlocked raw-pointer reads fixed
Handle accessors:
Level::NamecalledResolvePtr()with no lock.Unit::GetSkillLeveldereferenced its resolved pointer with no lock.Free functions in
GameHelpers.cpp:ClickItemread the item it looked up in the hash table before taking the lock. The lookup and every read now happen under one lock and are copied out before the dispatch waits on the game thread.ResolveBeltSizewalked the player's inventory unlocked.IsScrollingTextwalked Storm's live window-handler lists unlocked.CheckUnitCollisionlooked up two units and ran the collision test unlocked.ClickPartyMemberhanded a roster pointer to the game after its lock scope had ended. The lock now spans the dispatch.Deliberately left as they are:
GetGameStatedereferences the player chain without a lock. It is hot and called from many non-script threads, and adding a lock there changes blocking behaviour everywhere, so it needs its own change.SwapWeapon's expansion check read the long-lived Bnet session struct.Saycopies into static chat buffers.Menu.cppgoes through handles or runs insideGameThread::Execute.SubmitItem,RevealLeveland theClickMapAt/ click dispatches dereference only insideGameThread::Execute, on the game thread.One lock, tightly scoped
Bridge::Lock()is gone. It was juststatic GameReadLock Lock() { return {}; }, a second name for the same lock.WaitForGameReadyand V8 argument extraction. It is released after the last read, so V8 allocation and script-facing work happen outside it. Reads that must agree with each other (a walk, or a level and its rooms) stay under one lock.Level::Get(review comment),FindNextInventoryItem,Unit::ItemCost/TakeWaypoint/Overhead/GetSkillName, andAcceptTrade/TradeOK/ClickBodyLocation/ClickContainerSlot/GetDialogLinesin the backend.getCollision(level, x, y),getRoom,getPresetUnit,getPresetUnitsandgetControlsnow copy the game data out under one lock and build the JS values after it.JSRoom,JSStashTab,JSUnit, and ingetParty,getStashTabs,getDialogLines,clickItem,getDistance,acceptTrade,tradeOkandrevealLevel. Several of them held the lock across V8 allocation, which the docs had flagged as a latent stall. ThegetDistanceone also covered V8 object-property reads.Review fixes
getDistanceresolves a JS Unit's validity and position under one lock. Only that branch is locked: plain{x, y}objects are still read outside the lock, because their property getters run script.Resolved<T>'soperator*is deleted on temporaries, like theT*conversion, and the resolver constructor is constrained so a copy attempt hits the deleted copy constructor. Both are covered bystatic_asserts.IsTradeAccepted,GetRecentTradeIdandIsTradeBlockedtake the read lock.getControlsloop.Stale-pointer check (release epoch)
A wait that hands the lock back invalidates every pointer resolved before it. That covers
GameThread::Execute,PollUntil, and anything else built onGameReadLockReleaser.GameReadLock::ReleaseEpoch()is a thread-local counter.GameReadLockReleaserbumps it when it releases a held read lock, andGameWriteLockReleaserbumps it when it yields the write lock.Resolved<T>snapshots the counter when it is created and, in debug builds, asserts it is unchanged on->,*and theT*conversion.Methods that wait
Unit::MoveClickMapAt(Execute) + sleepResolvedin an inner scope that copies out the click point, then waitsUnit::InteractClickMapAt(unit)(Execute)ClickMapAtre-resolves by id on the game threadUnit::EquipItemGameThread::ExecutebodyLoc, and the checks run in the same order as beforeControl::ClickControl::SetTextGameThread::Executeself.Resolve<D2WinControlStrc>()on the game threadRoom::RevealGameThread::ExecuteClickItemGameThread::ExecuteThe epoch assert did not surface any other stale use.
No JS-visible behaviour changes.
Verification
build.ps1 format/check-format: cleanbuild.ps1 Release(Win32) andbuild.ps1 Release -Platform x64: 0 errorsbuild.ps1 Debug(Win32, asserts on): 0 errors. The Debugjs_tests.exepasses too.build.ps1 test: 164/164 (157 existing + 7 new). The new cases coverResolved<T>holding, releasing, nesting and moving the lock;static_asserts that the rvalue conversion is deleted; the release epoch across both releasers; andIsGameLockHeld().python scripts/gen_enum_names.py --check: up to datescripts/lint.ps1 -Jobs 8 -NoCache: 125 passed, 0 failedNot validated in a live game.
🤖 Generated with Claude Code