Conversation
petoollu
force-pushed
the
fix/cursor-leak
branch
from
September 13, 2026 13:43
c83c6a0 to
2ccc79a
Compare
Editor::tick() calls gSystem->setCursor() on every frame, and setCursor passed a fresh SDL_CreateSystemCursor() straight to SDL_SetCursor() without ever destroying it. On X11 every one of those is a new cursor resource owned by the X server, loaded from the cursor theme, and nothing frees them until the client disconnects. At 144 FPS that is 144 cursors a second, over half a million an hour. Because the resources live in the X server rather than in ArrowVortex, the editor's own memory and CPU stay flat while the whole desktop slows down the longer it runs, and recovers the moment it is closed. Counting the client's resources through the X-Resource extension showed 21,529 cursors after about three minutes, growing by 1,440 every 10 seconds; with this change it holds 12 and does not grow. Each icon's cursor is now created on first use and kept, and all of them are destroyed with the window. Passing the same pointer every frame also lets SDL_SetCursor() return early, so an unchanged cursor no longer sends an XDefineCursor per frame either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
System::setCursorcreates a new SDL cursor every time it is called, and it iscalled on every frame. None of those cursors are ever freed, so an editor left
open keeps accumulating them for as long as it runs. What that costs depends on
the platform: on Linux/X11 it slows the whole desktop down until ArrowVortex is
closed, and on every platform SDL's own cursor list grows without bound.
Why it happens
Editor::tick()sets the cursor unconditionally, once per frame:and
setCursorhands SDL a cursor it has just created:SDL_CreateSystemCursorreturns a newSDL_Cursoron every call and adds it toSDL's internal cursor list, where it stays until
SDL_DestroyCursororSDL_Quit. Nothing here destroys it. And because the pointer is different everyframe,
SDL_SetCursornever takes its "already the current cursor" earlyreturn, so the cursor is also re-applied through the OS on every frame.
What it costs on each platform
Traced through the SDL 3.2.22 sources that the vcpkg baseline resolves to.
Only the X11 case was measured; the others are from reading the backend code.
XcursorLibraryLoadCursor), plus anXDefineCursorrequestSDL_Cursor+SDL_CursorData, 48 bytes on x64 before allocator overhead). TheHCURSORcomes fromLoadCursor(NULL, IDC_*)and is shared, so no USER handles leak. Plus aSetCursor()call.SDL_Cursorand one extra retain on theNSCursor. The resize/move cursors are reloaded fromHIServices(info.plist+cursor.pdf) on every call. Each frame also postsinvalidateCursorRectsForView:to the main thread.SDL_Cursor, plus a newwl_surfaceon compositors withoutcursor-shape-v1X11 is the severe case. The cursors belong to the X server, not the
ArrowVortex process, so the editor's own memory and CPU look normal while every
application on the desktop gets slower. Closing ArrowVortex frees them all at
once, which is why the slowdown disappears the moment it exits.
On Windows it is a slow, steady process memory leak rather than a desktop-wide
slowdown. By the struct sizes that is about 25 MB per hour at 144 FPS, and
proportionally more at higher frame rates, such as with VSync disabled. That
figure is an estimate and has not been measured on Windows.
How it was found
The symptom came first: after a long session with the editor open, the whole
desktop became sluggish, and it recovered as soon as ArrowVortex was closed.
The editor itself showed flat memory and low CPU, which pointed at resources
held on its behalf by another process. Reading the frame loop led to the
per-frame
setCursorcall above.To confirm it, the X server was asked directly how many resources the
ArrowVortex client owns, using the X-Resource extension
(
XResQueryClientResources). XFixes (XFixesGetCursorImage) was used to readwhich cursor the server is actually showing. Nothing in ArrowVortex was
instrumented.
Counting a client's cursors (Python, X11)
The mask is the server's per-client resource mask (
XResQueryClientsreturnsit). Find the ArrowVortex window with
xwininfoor through_NET_WM_PID, thencall
cursor_counttwice a few seconds apart.Testing
Real desktop. Debian 13, X11, NVIDIA, 144 Hz monitor, VSync on, idle on the
logo screen. This was measured on this same change before it was rebased onto
the current
beta; the diff is identical.Before/after on the exact commits of this PR.
beta(5ebb828) against thisbranch (2ccc79a), each run in an isolated nested X server (Xephyr, Mesa
llvmpipe). VSync is unavailable there, so the frame rate is uncapped. For each
build the same script:
shortcuts.txt),beta5ebb828The cursor images were compared by hashing the pixels XFixes returns: the hand
and arrow are byte-identical between the two builds. The only behavioural
difference is that the fixed build reuses one hand and one arrow cursor instead
of creating new ones.
Not tested: Windows, macOS and Wayland builds. On Windows the memory growth
should be visible as a steady climb in ArrowVortex's private bytes (Process
Explorer, or "Memory" in Task Manager) over a few minutes idle before this
change, and a flat line after it.
The change
One file,
src/System/System.cpp:Each icon's cursor is created the first time it is needed and then reused, so
ArrowVortex creates at most eight, one per
Cursor::Icon. The destructor destroys them beforethe window. Because the same pointer now comes back every frame,
SDL_SetCursorreturns early when the cursor has not changed, so the per-frameXDefineCursor/SetCursor/ cursor-rect invalidation stops as well.The call site in
Editor::tick()is left alone. Setting the cursor every frameis fine once it is cheap, and it keeps the existing "reset to arrow, let
widgets override" flow working as before.
Why merge it
degrades the whole desktop the longer the editor stays open.
dependencies.
same places, verified pixel-for-pixel on X11, and they are now released
properly on exit.