Skip to content

Create each system cursor once instead of every frame - #347

Open
petoollu wants to merge 1 commit into
uvcat7:betafrom
petoollu:fix/cursor-leak
Open

petoollu wants to merge 1 commit into
uvcat7:betafrom
petoollu:fix/cursor-leak

Conversation

@petoollu

@petoollu petoollu commented Sep 13, 2026

Copy link
Copy Markdown

System::setCursor creates a new SDL cursor every time it is called, and it is
called 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:

// src/Editor/Editor.cpp
if (GuiMain::isCapturingMouse()) {
    gSystem->setCursor(GuiMain::getCursorIcon());
} else {
    gSystem->setCursor(gSystem->getCursor());
}

and setCursor hands SDL a cursor it has just created:

// src/System/System.cpp
void setCursor(Cursor::Icon c) override {
    myCursor = c;
    SDL_SetCursor(SDL_CreateSystemCursor(getCursorResource()));
}

SDL_CreateSystemCursor returns a new SDL_Cursor on every call and adds it to
SDL's internal cursor list, where it stays until SDL_DestroyCursor or
SDL_Quit. Nothing here destroys it. And because the pointer is different every
frame, SDL_SetCursor never takes its "already the current cursor" early
return, 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.

Platform Created per frame and never released Measured
Linux, X11 A cursor resource inside the X server, loaded from the cursor theme (XcursorLibraryLoadCursor), plus an XDefineCursor request Yes, see below
Windows Two heap allocations (SDL_Cursor + SDL_CursorData, 48 bytes on x64 before allocator overhead). The HCURSOR comes from LoadCursor(NULL, IDC_*) and is shared, so no USER handles leak. Plus a SetCursor() call. No
macOS (Cocoa backend, for the macOS build) One SDL_Cursor and one extra retain on the NSCursor. The resize/move cursors are reloaded from HIServices (info.plist + cursor.pdf) on every call. Each frame also posts invalidateCursorRectsForView: to the main thread. No
Linux, Wayland One SDL_Cursor, plus a new wl_surface on compositors without cursor-shape-v1 No

X11 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 setCursor call 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 read
which cursor the server is actually showing. Nothing in ArrowVortex was
instrumented.

Counting a client's cursors (Python, X11)
import ctypes as C
X = C.CDLL("libX11.so.6"); R = C.CDLL("libXRes.so.1")
X.XOpenDisplay.restype = C.c_void_p
X.XGetAtomName.restype = C.c_char_p; X.XGetAtomName.argtypes = [C.c_void_p, C.c_ulong]
class RType(C.Structure): _fields_ = [("type", C.c_ulong), ("count", C.c_uint)]
R.XResQueryClientResources.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_int), C.POINTER(C.POINTER(RType))]

def cursor_count(dpy, window_id, client_mask=0x1fffff):
    base = window_id & ~client_mask            # any XID the client owns identifies it
    n = C.c_int(); t = C.POINTER(RType)()
    R.XResQueryClientResources(dpy, base, C.byref(n), C.byref(t))
    return next((t[i].count for i in range(n.value)
                 if X.XGetAtomName(dpy, t[i].type) == b"CURSOR"), 0)

The mask is the server's per-client resource mask (XResQueryClients returns
it). Find the ArrowVortex window with xwininfo or through _NET_WM_PID, then
call cursor_count twice 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.

Cursors owned by ArrowVortex Growth
Before 21,529 after about two and a half minutes +1,440 per 10 s, i.e. 144/s, one per frame
After 12 none over 10 s

Before/after on the exact commits of this PR. beta (5ebb828) against this
branch (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:

  1. starts the editor and leaves it idle for 10 seconds,
  2. opens the About screen (bound to a key in a test copy of shortcuts.txt),
  3. moves the pointer over the GitHub button and off it again, three times,
  4. counts cursors again, then closes the editor with SIGTERM.
beta 5ebb828 This PR 2ccc79a
Idle, 10 s 3,305 → 8,186 cursors (+488/s) 12 → 12
Cursor the server shows while idle a new one every frame (serial 123806 → 124204 in 0.8 s) the same one throughout
Hover over the GitHub button hand cursor the same hand cursor (identical image)
Move off the button arrow the same arrow (identical image)
Cursors after hovering still growing (10,518 → 12,056 in 5 s) 13, not growing
Shutdown exits normally exits normally

The 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:

std::map<Vortex::Cursor::Icon, SDL_Cursor*> myCursorCache;

void setCursor(Cursor::Icon c) override {
    myCursor = c;
    SDL_Cursor*& cursor = myCursorCache[c];
    if (!cursor) cursor = SDL_CreateSystemCursor(getCursorResource());
    // SDL returns early when this is already the active cursor.
    if (cursor) SDL_SetCursor(cursor);
}

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 before
the window. Because the same pointer now comes back every frame,
SDL_SetCursor returns early when the cursor has not changed, so the per-frame
XDefineCursor / SetCursor / cursor-rect invalidation stops as well.

The call site in Editor::tick() is left alone. Setting the cursor every frame
is fine once it is cheap, and it keeps the existing "reset to arrow, let
widgets override" flow working as before.

Why merge it

  • It fixes an unbounded leak that affects every platform, and on Linux/X11 it
    degrades the whole desktop the longer the editor stays open.
  • It is small and local: 11 lines in one file, no interface changes, no new
    dependencies.
  • There is no visible behaviour change. The same system cursors appear in the
    same places, verified pixel-for-pixel on X11, and they are now released
    properly on exit.

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>
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.

1 participant