Skip to content

Hold the game read lock from resolve to last dereference (Resolved<T>) - #56

Merged
ResurrectedTrader merged 1 commit into
mainfrom
g18-read-lock
Sep 27, 2026
Merged

ResurrectedTrader merged 1 commit into
mainfrom
g18-read-lock

Conversation

@ResurrectedTrader

@ResurrectedTrader ResurrectedTrader commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

What races

Handle accessors called ResolvePtr(), which took a GameReadLock only 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:

  • takes a GameReadLock before the resolve,
  • resolves through the existing ResolvePtr(), so every resolve still goes through the per-frame HandleCache. Caching and its invalidation are unchanged,
  • holds the lock for its whole lifetime, so the resolve and every dereference happen under one lock,
  • has one way to reach the pointer: -> / * for the struct, and an implicit operator 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), so T* p = Resolve<T>(); does not compile and a pointer can't outlive its lock. There is no Get() and no separate operator bool.

Each handle (Unit, Room, Level, Party, Control) gets a private Resolve<T>(). T is the backend's own struct type, so the contract never names a backend type and the backend stops hand-casting (AsUnit(ResolvePtr())):

uint32_t Unit::Mode() const {
    const auto u = Resolve<D2UnitStrc>();
    return u ? u->dwAnimMode : 0U;
}

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 explicit GameReadLock.

Unlocked raw-pointer reads fixed

Handle accessors:

  • Level::Name called ResolvePtr() with no lock.
  • Unit::GetSkillLevel dereferenced its resolved pointer with no lock.

Free functions in GameHelpers.cpp:

  • ClickItem read 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.
  • ResolveBeltSize walked the player's inventory unlocked.
  • IsScrollingText walked Storm's live window-handler lists unlocked.
  • CheckUnitCollision looked up two units and ran the collision test unlocked.
  • ClickPartyMember handed a roster pointer to the game after its lock scope had ended. The lock now spans the dispatch.

Deliberately left as they are:

  • GetGameState dereferences 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.
  • The Bnet/session getters and SwapWeapon's expansion check read the long-lived Bnet session struct.
  • Say copies into static chat buffers.
  • The PlugY page helpers only run under their callers' locks.
  • Menu.cpp goes through handles or runs inside GameThread::Execute.
  • SubmitItem, RevealLevel and the ClickMapAt / click dispatches dereference only inside GameThread::Execute, on the game thread.

One lock, tightly scoped

  • Bridge::Lock() is gone. It was just static GameReadLock Lock() { return {}; }, a second name for the same lock.
  • Every lock site was narrowed. The lock is now taken right before the first game read, after argument checks, WaitForGameReady and 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, and AcceptTrade / TradeOK / ClickBodyLocation / ClickContainerSlot / GetDialogLines in the backend.
    • getCollision(level, x, y), getRoom, getPresetUnit, getPresetUnits and getControls now copy the game data out under one lock and build the JS values after it.
    • 22 binding locks that only wrapped a single self-locking (or waiting) backend call are removed. They are in JSRoom, JSStashTab, JSUnit, and in getParty, getStashTabs, getDialogLines, clickItem, getDistance, acceptTrade, tradeOk and revealLevel. Several of them held the lock across V8 allocation, which the docs had flagged as a latent stall. The getDistance one also covered V8 object-property reads.

Review fixes

  • getDistance resolves 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>'s operator* is deleted on temporaries, like the T* conversion, and the resolver constructor is constrained so a copy attempt hits the deleted copy constructor. Both are covered by static_asserts.
  • IsTradeAccepted, GetRecentTradeId and IsTradeBlocked take the read lock.
  • The doc example now matches the real getControls loop.

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 on GameReadLockReleaser.

  • GameReadLock::ReleaseEpoch() is a thread-local counter. GameReadLockReleaser bumps it when it releases a held read lock, and GameWriteLockReleaser bumps 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 the T* conversion.
  • Release builds compile the check out.

Methods that wait

Method Wait Handling
Unit::Move ClickMapAt (Execute) + sleep Resolved in an inner scope that copies out the click point, then waits
Unit::Interact ClickMapAt(unit) (Execute) inner scope for the packet paths; ClickMapAt re-resolves by id on the game thread
Unit::EquipItem GameThread::Execute inner scope copies out bodyLoc, and the checks run in the same order as before
Control::Click sleeps between PostMessages inner scope copies out the click coordinates
Control::SetText GameThread::Execute the task re-resolves with self.Resolve<D2WinControlStrc>() on the game thread
Room::Reveal GameThread::Execute inner-scope precheck, then the task re-resolves on the game thread
ClickItem GameThread::Execute lookup and reads in one locked scope, copied out before the dispatch

The epoch assert did not surface any other stale use.

No JS-visible behaviour changes.

Verification

  • build.ps1 format / check-format: clean
  • build.ps1 Release (Win32) and build.ps1 Release -Platform x64: 0 errors
  • build.ps1 Debug (Win32, asserts on): 0 errors. The Debug js_tests.exe passes too.
  • build.ps1 test: 164/164 (157 existing + 7 new). The new cases cover Resolved<T> holding, releasing, nesting and moving the lock; static_asserts that the rvalue conversion is deleted; the release epoch across both releasers; and IsGameLockHeld().
  • python scripts/gen_enum_names.py --check: up to date
  • scripts/lint.ps1 -Jobs 8 -NoCache: 125 passed, 0 failed

Not validated in a live game.

🤖 Generated with Claude Code

@ResurrectedTrader ResurrectedTrader changed the title lod114d: hold the game read lock across each accessor's whole read Hold the game read lock from resolve to last dereference (Resolved<T>) Sep 27, 2026
Comment thread src/backends/lod114d/game/Level.cpp Outdated
}

std::optional<Level> Level::Get(uint32_t levelNo) {
GameReadLock guard;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could move below the zero check.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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>
@ResurrectedTrader
ResurrectedTrader merged commit bd0abe4 into main Sep 27, 2026
1 check passed
@ResurrectedTrader
ResurrectedTrader deleted the g18-read-lock branch September 27, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant