fix(server): commit swapItems atomically so a yielding hook cannot dupe#1952
Merged
thelindat merged 3 commits intoJun 6, 2026
Merged
Conversation
Closed
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.
Background
#1950 stopped the actively-exploited duplication (transfer an item into a
persistent inventory, then disconnect or relog via multichar mid-transfer) by
deferring the post-event
Waitoff the swap's critical section and force-savinga disconnecting player. That closes the case where no
swapItemshook isregistered.
It does not fully close the case where a registered
swapItemshookyields, which is a documented, valid use (e.g. an async DB read for
validation). A hook that yields re-opens the same disconnect window, and worse,
unbounded in length. This PR closes that window structurally so duplication is
impossible regardless of whether a hook yields.
The fix
Three changes that together make the commit atomic with respect to any
disconnect, for any hook behaviour:
Stack branch becomes compute-then-commit. It no longer mutates the live
counts before the hook. It computes the resulting slot weights using a
temporary count assignment that is restored before the hook can yield, runs
the hook, then applies the counts in a single block with no yield until the
changedflags are set. The two manual count-rollbacks are gone becausenothing is mutated before the commit. The
swapandmovebranches alreadyran the hook before mutating, so they keep their structure.
Post-hook presence re-check (
partiesPresent). After everyswapItemshook (four sites), and innewdrop/dropItem(see below):This is the load-bearing piece. Compute-then-commit alone is not enough:
after the hook, the swap would still commit the container side for a player
already removed and saved in pre-state during the yield, duplicating a
deposit. The re-check aborts the commit if any party (player, source, or
destination inventory) was removed during the hook yield. It compares against
the raw
Inventoriesregistry by identity, so a reconnect that re-registersa new object under the same id is correctly treated as absent. It sets
hooks.success = falseso post-event listeners are not told a non-committedswap succeeded (matching the existing idiom at
dropItem).newdrop/dropItemgets the same guard. ThetoType == 'newdrop'path returns
dropItem(...)directly fromswapItems.dropItemruns itsown
swapItemshook and then decrements the source and creates the worlddrop. Without a re-check, a disconnect during that hook saved the player with
the full stack while the drop was created for another player to pick up, the
same dupe. A presence re-check after the hook closes it.
After this, a disconnect during a hook yield means nothing was mutated, the
player is saved clean, and the swap aborts. With no hook, the swap is fully
synchronous and nothing can interleave. Neither deposit nor withdrawal can dupe
or void, no matter how long a hook yields.
Behaviour change to note
In the
stackbranch, aswapItemshook now sees the pre-swap counts inpayload.fromSlot.count/payload.toSlot.countinstead of thealready-decremented values. This makes the
stackbranch consistent with theswapandmovebranches, which already pass pre-mutation counts. Hooks thatuse
payload.count(the delta, unchanged) are unaffected.Out of scope (follow-ups)
The same "mutate or commit after a yielding hook" shape exists in other hook
callers that this PR does not touch:
giveItem,craftItem(which awaits aclient callback, a long yield),
buyItem, andusingItem. They should get thesame presence re-check in a follow-up. Until then, the force-save of a
disconnecting player from #1950 (
inv.changed or inv.playerinInventory.Remove) is worth keeping as a cheap backstop for those paths. It isno longer load-bearing for
swapItems, but it provides partial protection forthe un-audited callers at the cost of one save per disconnect. Once those are
hardened it can revert to
inv.changed.Separately, there is a pre-existing concurrency gap unrelated to the disconnect
race:
Inventory.AddItem/Inventory.RemoveItemand other name-based mutatorsdo not honour the per-slot locks that
swapItemstakes. If such a mutator runson an involved inventory during a yielding hook, the swap's post-hook weight
assignment (a pre-hook snapshot) can clobber it, desyncing
inventory.weightand bypassing
maxWeight; and if it clears the swapped slot,SwapSlotscanoperate on stale or nil slot data. The weight-after-hook assignment is unchanged
from upstream, so this is not introduced here. The proper fix is to make those
mutators lock-aware (or take an inventory-level lock), which is a separate
change.