Skip to content

fix(server): commit swapItems atomically so a yielding hook cannot dupe#1952

Merged
thelindat merged 3 commits into
overextended:mainfrom
Kenshiin13:fix/swapitems-atomic-commit
Jun 6, 2026
Merged

fix(server): commit swapItems atomically so a yielding hook cannot dupe#1952
thelindat merged 3 commits into
overextended:mainfrom
Kenshiin13:fix/swapitems-atomic-commit

Conversation

@Kenshiin13

Copy link
Copy Markdown
Contributor

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 Wait off the swap's critical section and force-saving
a disconnecting player. That closes the case where no swapItems hook is
registered.

It does not fully close the case where a registered swapItems hook
yields, 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:

  1. 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
    changed flags are set. The two manual count-rollbacks are gone because
    nothing is mutated before the commit. The swap and move branches already
    ran the hook before mutating, so they keep their structure.

  2. Post-hook presence re-check (partiesPresent). After every
    swapItems hook (four sites), and in newdrop/dropItem (see below):

    local function partiesPresent()
        return Inventories[playerInventory.id] == playerInventory
            and Inventories[fromInventory.id] == fromInventory
            and Inventories[toInventory.id] == toInventory
    end
    ...
    if not hooks.success then return end
    if not partiesPresent() then hooks.success = false return end

    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 Inventories registry by identity, so a reconnect that re-registers
    a new object under the same id is correctly treated as absent. It sets
    hooks.success = false so post-event listeners are not told a non-committed
    swap succeeded (matching the existing idiom at dropItem).

  3. newdrop / dropItem gets the same guard. The toType == 'newdrop'
    path returns dropItem(...) directly from swapItems. dropItem runs its
    own swapItems hook and then decrements the source and creates the world
    drop. 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 stack branch, a swapItems hook now sees the pre-swap counts in
payload.fromSlot.count / payload.toSlot.count instead of the
already-decremented values. This makes the stack branch consistent with the
swap and move branches, which already pass pre-mutation counts. Hooks that
use 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 a
client callback, a long yield), buyItem, and usingItem. They should get the
same presence re-check in a follow-up. Until then, the force-save of a
disconnecting player from #1950 (inv.changed or inv.player in
Inventory.Remove) is worth keeping as a cheap backstop for those paths. It is
no longer load-bearing for swapItems, but it provides partial protection for
the 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.RemoveItem and other name-based mutators
do not honour the per-slot locks that swapItems takes. If such a mutator runs
on an involved inventory during a yielding hook, the swap's post-hook weight
assignment (a pre-hook snapshot) can clobber it, desyncing inventory.weight
and bypassing maxWeight; and if it clears the swapped slot, SwapSlots can
operate 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.

@Kenshiin13 Kenshiin13 mentioned this pull request Jun 5, 2026
@Kenshiin13 Kenshiin13 marked this pull request as ready for review June 5, 2026 18:31
@thelindat thelindat merged commit e9b2f91 into overextended:main Jun 6, 2026
@Kenshiin13 Kenshiin13 deleted the fix/swapitems-atomic-commit branch June 6, 2026 08:15
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.

2 participants