Skip to content

fix(net-tcp-server): prevent use-after-free crash in UvLoopHolder destructor#3919

Open
Ante042 wants to merge 1 commit into
citizenfx:masterfrom
Ante042:fix/uvloopholder-destructor-race-condition
Open

fix(net-tcp-server): prevent use-after-free crash in UvLoopHolder destructor#3919
Ante042 wants to merge 1 commit into
citizenfx:masterfrom
Ante042:fix/uvloopholder-destructor-race-condition

Conversation

@Ante042
Copy link
Copy Markdown

@Ante042 Ante042 commented Apr 11, 2026

Summary

UvLoopHolder::~UvLoopHolder() can crash with Assertion failed: 0 (../deps/uv/src/unix/core.c: uv_close: 234) due to a race condition during shutdown.

Root cause

The destructor creates a stack-allocated uv_async_t, registers it with the loop, sends a signal, then calls m_thread.join(). The loop thread sees m_shouldExit == true, destroys the loop (m_loop = {}), and exits. After join() returns, the destructor calls uv_close() on the stack-allocated handle — but the loop is already destroyed and the handle's internal type field is corrupt. libuv's uv_close hits the default branch in its type-switch and asserts.

Timeline:
  Destructor thread              Loop thread
  ──────────────────             ──────────────
  m_shouldExit = true
  m_loop->stop()
  uv_async_init(&async)
  uv_async_send(&async)
  m_thread.join() ───────────►   exits while loop
       (blocks)                  m_loop = {}  ← loop destroyed
       (returns) ◄──────────     thread exits
  uv_close(&async) ← CRASH

Fix

  • Reuse the existing m_async member to wake the loop thread — no stack-allocated handle needed.
  • Close m_async from within the loop thread (where libuv requires handle closure to happen) and run the loop one final time to process the close callback.
  • Remove the post-join uv_close() call that operated on a dead loop.

Observed impact

This crash was observed on a production FXServer (Linux, 64-slot) running artifacts 27055 and 27722. The crash dump signature:

Assertion failed: 0 (../deps/uv/src/unix/core.c: uv_close: 234)

The bug exists in all current artifacts — the file has never been modified since creation.

Testing

The fix is a minimal, targeted change (9 lines added, 12 removed) to a single file. The async handle lifecycle now follows libuv's threading rules: handles are closed from the thread that owns the loop, before the loop is destroyed.

…se-after-free in UvLoopHolder destructor

The destructor previously created a stack-allocated uv_async_t and called
uv_close() on it after m_thread.join(). By that point the loop thread had
already destroyed the loop (m_loop = {}), leaving the handle in a corrupt
state. libuv's uv_close then hit assert(0) in its type-switch because the
handle type was no longer valid.

The fix:
- Reuses the existing m_async member to wake the loop thread instead of
  creating a temporary stack-allocated handle.
- Closes m_async from within the loop thread (where libuv requires it)
  and runs the loop one final time to process the close callback before
  the loop is destroyed.
- Removes the post-join uv_close call that operated on a dead loop.

Crash signature: "Assertion failed: 0 (../deps/uv/src/unix/core.c: uv_close: 234)"
@github-actions github-actions Bot added the invalid Requires changes before it's considered valid and can be (re)triaged label Apr 11, 2026
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.

1 participant