fix(server): prevent item duplication on disconnect mid-swap#1950
Merged
thelindat merged 2 commits intoJun 5, 2026
Conversation
This was referenced Jun 5, 2026
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.
Summary
Transferring or stacking an item from a player inventory into a persistent
inventory (stash, trunk, glovebox, bag) and disconnecting within a ~50 ms window
mid-transfer duplicates the moved stack: the player keeps the items and the
container persists them. It is repeatable and survives reconnect.
This is a disconnect/save race, not a logic error in the move itself.
Real-world evidence
This appears to be the method currently being used in the wild. We confirmed the
behaviour with an affected server: their logs showed a character relog via the
multichar / character-selection screen happening during the exploitation
window.
That detail matches the root cause exactly. A multichar character switch fires
the same logout path as a disconnect (
qbx_core:server:playerLoggedOut→server.playerDropped→Inventory.Remove). A quick character swap is enough to land inside thesave window, which makes the exploit easy and reliable to reproduce by hand.
Root cause
swapItemscommits a transfer in this order (stack path shown; the move path isequivalent):
fromData.count -= n,toData.count += n(
modules/inventory/server.lua:1838-1839; move path:1930).swapItemshook runs under a to-be-closed handle,local hooks <close> = TriggerEventHooks('swapItems', ...). Its__closecalls
Wait(50)unconditionally, even with no hooks registered(
modules/hooks/server.lua:50), parking the swap coroutine.fromInventory.changed = true/toInventory.changed = true(modules/inventory/server.lua:1965-1966),after the yield.
Because the FiveM scheduler is cooperative, other handlers can only run while
this coroutine is yielded. If the player disconnects (or relogs) during step 2:
server.playerDropped→Inventory.Removesaves onlyif not inv.datastore and inv.changed(modules/inventory/server.lua:657).The player's
changedis still false, so the save is skipped and theplayer is dropped from memory. The
-ndecrement is discarded.Wait(50)and marks the containerchanged = true; it persists the+non the next periodic save.Net: player keeps
n, container gainedn→+nduplicated. Slot locks donot help (they only guard concurrent swaps, not the disconnect path).
Note the
Wait(50)is intended, but only to delay the post-eventbroadcast (
triggerPostEvents→ fire-and-forgetTriggerEvents). Hookcancellation is unrelated and fully synchronous: a callable hook returning
falseis handled insideTriggerEventHooksand consumed at the<close>declaration line, before
__close/Waitever runs. So the yield gates nothing, it only parks the swap on the critical path.Fix
Two complementary changes.
1. Root cause: keep the post-event delay off the swap's critical section
modules/hooks/server.lua__closenow returns immediately, so the count mutation (:1838) and thechangedcommit (:1965) happen with no yield between them. There is nolonger a suspension point for
playerDroppedto interleave into. The post-eventhooks still fire, still after the same 50 ms, just in their own coroutine.
Cancellation behaviour is unchanged.
2. Defense-in-depth: always persist a disconnecting player
modules/inventory/server.lua(Inventory.Remove)A disconnecting player's authoritative inventory is always written. The
changedoptimisation stays for containers (re-saved on the cron) but is forcedfor players, at the cost of one cheap
savePlayerper disconnect. Thisindependently closes the same race and backstops any future hook that yields
mid-swap.
Reproduction
nof an item in player slot 1 andnof the same item in a stash.Wait(50).Before: the player kept
nand the stash gainedn→ total2nfromnduplicated. After: the total is conserved regardless of disconnect/relog
timing.
Related issue: #1947