Skip to content

fix(server): prevent item duplication on disconnect mid-swap#1950

Merged
thelindat merged 2 commits into
overextended:mainfrom
Kenshiin13:fix/dupe-disconnect-mid-swap
Jun 5, 2026
Merged

fix(server): prevent item duplication on disconnect mid-swap#1950
thelindat merged 2 commits into
overextended:mainfrom
Kenshiin13:fix/dupe-disconnect-mid-swap

Conversation

@Kenshiin13

Copy link
Copy Markdown
Contributor

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.playerDroppedInventory.Remove). A quick character swap is enough to land inside the
save window, which makes the exploit easy and reliable to reproduce by hand.

Root cause

swapItems commits a transfer in this order (stack path shown; the move path is
equivalent):

  1. Mutate live counts: fromData.count -= n, toData.count += n
    (modules/inventory/server.lua:1838-1839; move path :1930).
  2. Yield: the swapItems hook runs under a to-be-closed handle,
    local hooks <close> = TriggerEventHooks('swapItems', ...). Its __close
    calls Wait(50) unconditionally, even with no hooks registered
    (modules/hooks/server.lua:50), parking the swap coroutine.
  3. Set dirty flags: 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.playerDroppedInventory.Remove saves only
    if not inv.datastore and inv.changed (modules/inventory/server.lua:657).
    The player's changed is still false, so the save is skipped and the
    player is dropped from memory. The -n decrement is discarded.
  • The swap coroutine resumes after the Wait(50) and marks the container
    changed = true; it persists the +n on the next periodic save.
  • On reconnect the player loads the original (pre-decrement) count from the DB.

Net: player keeps n, container gained n+n duplicated. Slot locks do
not help (they only guard concurrent swaps, not the disconnect path).

Note the Wait(50) is intended, but only to delay the post-event
broadcast (triggerPostEvents → fire-and-forget TriggerEvents). Hook
cancellation is unrelated and fully synchronous: a callable hook returning
false is handled inside TriggerEventHooks and consumed at the <close>
declaration line, before __close/Wait ever 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

 		__close = function(self, err)
 			if err then
 				self.success = false
 			end
 
-			Wait(50)
-
-			triggerPostEvents(self, self.success, payload)
+			CreateThread(function()
+				Wait(50)
+				triggerPostEvents(self, self.success, payload)
+			end)
 		end

__close now returns immediately, so the count mutation (:1838) and the
changed commit (:1965) happen with no yield between them. There is no
longer a suspension point for playerDropped to interleave into. The post-event
hooks 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)

-    if not inv.datastore and inv.changed then
+    if not inv.datastore and (inv.changed or inv.player) then
         Inventory.Save(inv)
     end

A disconnecting player's authoritative inventory is always written. The
changed optimisation stays for containers (re-saved on the cron) but is forced
for players, at the cost of one cheap savePlayer per disconnect. This
independently closes the same race and backstops any future hook that yields
mid-swap.

Reproduction

  1. Put n of an item in player slot 1 and n of the same item in a stash.
  2. Open the stash and fire a player→stash stack transfer.
  3. Relog via multichar, so the logout lands during the Wait(50).
  4. Reconnect and check counts.

Before: the player kept n and the stash gained n → total 2n from n
duplicated. After: the total is conserved regardless of disconnect/relog
timing.

Related issue: #1947

@thelindat thelindat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Change to SetTimeout.

@Kenshiin13 Kenshiin13 requested a review from thelindat June 5, 2026 07:53
@thelindat thelindat merged commit 6c2b6e6 into overextended:main Jun 5, 2026
@Kenshiin13 Kenshiin13 deleted the fix/dupe-disconnect-mid-swap branch June 5, 2026 08:34
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