Repository navigation
Hold the game read lock across a binding's check and its reads (LockedHandle) - #57
Merged
Merged
Conversation
| const auto unitData2 = JSUnit::Unwrap(unitObj2); | ||
| if (!unitData1 || !*unitData1 || !unitData2 || !*unitData2) { | ||
| return; | ||
| } |
Owner
Author
There was a problem hiding this comment.
This looks dubious
Owner
Author
There was a problem hiding this comment.
Simplified: the mask is converted first, then one unwrap of both units and one check under it. The same "arguments first, then one guard" rule now applies across the PR (getStat, shop, overhead, moveNPC), and the test-twice guidance is gone from the docs.
…dHandle) A binding tested its handle under one lock window (`if (!*data)`) and read it under the next (`data->GetStat(n)`), so a unit freed in between reached the script as 0 or "" instead of undefined. ClassBase::Unwrap now returns a LockedHandle<T> for the game handle natives (Unit, Room, Level, Party, Control, StashTab - the GameHandle concept): it holds a GameReadLock for its lifetime, so the test and the reads see one game state. Every other class keeps its raw pointer; the choice is made from the native type at compile time and there is no unlocked Unwrap for a game handle. Call sites follow the rules for holding it. Untyped V8 arguments are converted first, then one guard covers the checks and reads (getStat, checkCollision, shop, overhead, moveNPC, getRoom). Nothing that can wait on either backend runs under it: Move / Interact / UseMenu / Shop / EquipItem / Description, Control Click / SetText / Text / TextLines, Room.reveal, StashTab click and gold moves, ClickMapAt / ClickItem / ClickPartyMember / LeaveParty / MoveNPC and setSkill's bind loop run on a copy of the handle after the guard's scope. Large arrays and lists of handles are copied out and built after it; numbers and single strings are built under it. getDistance, getMercHP, getArea, revealLevel and unit.getRepairCost hold an explicit GameReadLock across their Unit::Player() / InteractingNPC() test and the reads after it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ResurrectedTrader
force-pushed
the
binding-locks
branch
from
September 27, 2026 16:45
930abc7 to
3715d8a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #56: fixes the check-then-use races in the JS bindings.
Problem
Bindings tested a handle in one lock window and read it in another:
If the game thread freed the unit between the two windows, the script got
0/""instead ofundefined. This was never memory-unsafe, because every read re-resolves, but the answer was inconsistent.Design
LockedHandle<T>(api/core/LockedHandle.h) holds agame::GameReadLockfor its lifetime and exposes the handle through->/*.operator booltests the wrapper pointer.*data's bool is still "does the game object exist".ClassBase::Unwrapreturns aLockedHandlefor the game handle natives and a raw pointer for everything else.game::Unit,Room,Level,Party,ControlandStashTab, named by theGameHandleconcept.Resolved<T>locks nest inside the guard as recursive re-entries, which are free.So most of the diff is
auto* data = Unwrap(...)becomingconst auto data = Unwrap(...), with the check and the reads unchanged below it.Rules while a guard is alive
Documented in
docs/game_thread_safety.mdunder "Bindings -Unwrapreturns a LockedHandle".Unwrap, then takes one guard and checks and reads under it (getStat,checkCollision,shop,overhead,moveNPC,getRoom). Arguments already gated byIsString/IsNumber/IsUint32run no script and stay where they were.ExecuteEventsorFunction::Call. The Unit iterator readsPos()in a scoped block and calls the array iterator after it.move,interact/TakeWaypoint,useMenu,shop,description,setSkill's bind loop (it pumps script events)click,setText, thetextgetter and setter,getText()room.reveal,StashTab.click/depositGold/withdrawGoldclickMap(unit),clickItem(item)(both shapes),clickParty,moveNPC,revealLevelgetStat(-1/-2), collision grids, items, rooms, exits, presets) are copied out in a scoped block and built after it. Numbers and single strings are built directly under the guard.One documented exception to "one game state per guard": the first resolve of a room or level that is not built yet runs level init on the game thread, which hands the lock back meanwhile. This is safe because a guard holds identity handles, not raw pointers.
Other notable call-site changes
JSControl::MenuOnlyreturns aLockedHandle<Control>, so the Menu-state test runs under the same lock.getDistance: the unit branch ofgetObjectPointuses the guard in place of Hold the game read lock from resolve to last dereference (Resolved<T>) #56's ad-hocGameReadLock, and the player branches go through agetPlayerPointthat tests and reads under one lock.GameReadLockacross aUnit::Player()/InteractingNPC()test and the reads after it: globalgetMercHP,getArea()(default area),revealLevel(the reveal itself runs after the lock),unit.getRepairCost.JS-visible behaviour is unchanged apart from the race fix and one ordering change: an untyped argument's
valueOf/toStringnow runs before the unit validity check ingetStat,checkCollision,shop,overhead,moveNPCandgetRoom(x, y).moveNPCreturns straight away if converting its coordinates threw.Trade-offs
typeandgidread cached identity fields and never needed a lock, and neither doStashTab.kind/index. Every game-handleUnwrapnow takes one anyway: an uncontended recursive acquire.Tests
tests/runtime/api/LockedHandleTest.cppneeds no V8:static_asserts that the six game handles satisfyGameHandleand thatPresetUnitInfo,ExitInfoandint32_tdo notVerification
build.ps1 Release(Win32),build.ps1 Release -Platform x64andbuild.ps1 Debugall succeed.build.ps1 test: 169/169 test cases pass.build.ps1 check-formatis clean.scripts/lint.ps1 -NoCache: 126/126 files pass.interact, a contradiction in the docs, and a stray comment.🤖 Generated with Claude Code