Skip to content

fix(server/state): don't insert into the world grid map under a shared lock#4062

Open
Syxless wants to merge 1 commit into
citizenfx:masterfrom
Syxless:fix/world-grid-shared-lock
Open

fix(server/state): don't insert into the world grid map under a shared lock#4062
Syxless wants to merge 1 commit into
citizenfx:masterfrom
Syxless:fix/world-grid-shared-lock

Conversation

@Syxless

@Syxless Syxless commented Jul 11, 2026

Copy link
Copy Markdown

Goal of this PR

While reading the relevance pass in ServerGameState::Tick I noticed it resolves the world grid differently from every other place that touches m_worldGrids:

std::shared_lock _(m_worldGridsMutex);
const auto& grid = m_worldGrids[selfBucket];

Everything else uses find(). operator[] on a std::map inserts when the key is missing, and here that happens while holding only the reader side of the lock, racing any concurrent reader of the map. The key can actually be missing: UpdateWorldGrid creates grids at the start of the tick, but a routing bucket change from another thread (or a player entity appearing mid tick) leaves the pass looking at a bucket that has no grid yet.

The inserted entry is also a default constructed null unique_ptr, and since UpdateWorldGrid only creates a grid when find() fails, it keeps finding the null entry and bailing on its if (!grid) check. From then on nobody in that bucket gets world grid ownership until the bucket empties out.

How is this PR achieving the goal

Resolving the grid once per client with find() before the entity loop, like the other call sites do. This is safe because grids are only created and erased in UpdateWorldGrid, which runs earlier in the same tick on the same thread. A missing grid now just means no world grid relevance for that pass, which is what the next tick would produce anyway, instead of poisoning the bucket.

This also removes a shared_mutex acquisition and a map walk per entity per client from the pass.

This PR applies to the following area(s)

Server, OneSync

Successfully tested on

Platforms: Windows

Checklist

  • Code compiles and has been tested successfully.
  • Code explains itself well and/or is documented.
  • My commit message explains what the changes do and what they are for.
  • No extra compilation warnings are added by these changes.

Fixes issues

…d lock

The relevance pass in ServerGameState::Tick resolved the world grid with
operator[] on m_worldGrids while holding only a shared lock. For a client
whose routing bucket has no grid yet (for example when the bucket changed
after UpdateWorldGrid ran for this tick), operator[] inserts into the map
concurrently with other readers. The inserted entry is also a null grid,
which UpdateWorldGrid then finds and bails on from that point onwards,
leaving the bucket without world grid ownership until it empties out.

Since grids are only created and erased in UpdateWorldGrid, which runs
earlier in the same tick on the same thread, the grid for the client's
routing bucket can be resolved once per client with find() instead of
taking the lock and walking the map for every entity in the pass.
@github-actions github-actions Bot added the invalid Requires changes before it's considered valid and can be (re)triaged label Jul 11, 2026
@FabianTerhorst

Copy link
Copy Markdown
Contributor

Hi, both emplace and erase of the world grid happens inside the tick. Please provide an scenario where emplace or erase of the world grid happens outside.

@Syxless

Syxless commented Jul 12, 2026

Copy link
Copy Markdown
Author

Hello! The emplace and erase in UpdateWorldGrid are fine, the issue is that operator[] in the relevance pass is itself an emplace when the bucket is missing, and readers of the map exist outside the sync thread.

SET_PLAYER_ROUTING_BUCKET calls ClearClientFromWorldGrid directly on the script runtime thread (unlike the drop path, which marshals HandleClientDrop onto the sync thread via gscomms_execute_callback_on_sync_thread). That does m_worldGrids.find() under a shared_lock, plus another in SendWorldGrid. The same native also sets clientData->routingBucket with no ordering against the tick, so the relevance pass can see a bucket with no grid and operator[] inserts into the tree under the reader lock while the script thread's find() walks it. Two shared locks do not exclude each other.

Even without the race, the inserted entry is a null unique_ptr. UpdateWorldGrid only emplaces when find() fails, so it keeps hitting its if (!grid) check and the bucket never gets a real grid for as long as it keeps clients.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

invalid Requires changes before it's considered valid and can be (re)triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants