Help us improve Softanics
We use analytics cookies to understand which pages and downloads are useful. No ads. Privacy Policy
Artem Razin
September 9, 2026

How I hunted leaks in qBittorrent and found a bug in Qt

Every BitTorrent client keeps a share ratio: what you gave back, measured against what you took. Code keeps the same account, and nothing prints the number for you. Every allocation takes a block from the heap, every free gives it back, and a leak is a block that was taken and never returned. A program that leaks is a peer running a bad ratio.

So I pointed a leak profiler at qBittorrent and went looking for leechers. It is a fair subject: C++ against Qt and libtorrent, shipping since 2006, the kind of program people leave running for days, and open source, so you can check every claim below rather than taking my word for it.

A word on the tool. Deleaker, our leak detector for native Windows applications, runs standalone or inside Visual Studio, RAD Studio and Qt Creator, and it tracks the heap along with GDI, USER and kernel handles. That last part is what turned up the finding worth reporting upstream.

The verdict first, because it is the interesting part. qBittorrent's ratio is good. Adding a torrent and removing it again, the obvious place for a client to bleed, leaks nothing that grows. Five cycles left exactly one row allocated by qBittorrent's own code, and that row is a ring buffer with a hard cap, doing what it was built to do.

Two small bugs turned up in the post mortem snapshot, both worth fixing, neither able to run away from you: 1404 bytes of thread abandoned at shutdown, and 76 bytes of event filter allocated at startup and never freed. Both are reported as #24945.

And then the same snapshot, read a different way, produced something else entirely. Two handles that qBittorrent never asked for and cannot free, leaked by Qt itself, in the Windows platform plugin (qwindows.dll), in code that every Qt application on Windows executes when it shows its first window (including, since a Qt Creator plugin is itself built with Qt, our own Deleaker plugin). It has been there since at least Qt 6.7, it is still there in 6.10.3, and nothing that counts bytes was ever going to find it. It is now QTBUG-149920.

Building a peer you can profile

qBittorrent v5.3.0 at commit fe4506e8c, Debug x64, Qt 6.7.2 msvc2019_64, libtorrent and Boost from vcpkg, Ninja and the VS 2022 toolset. Every stack through qBittorrent's and Qt's own code resolves to a file and a line, which is the only thing the build had to deliver.

One setup detail, because it cost me twenty minutes. Start qBittorrent under the profiler while another copy is already running and the process launches and vanishes: no error, no window, nothing to read. qBittorrent is single-instance, so the second process hands its command line to the first and exits. The copy already running is easy to miss, because it minimises to the system tray.

Give the profiled instance its own configuration directory rather than hunting the other one down. --profile=<dir> does that, and since the single-instance check is keyed on that directory, the two then run independently. It also starts every session from a configuration you chose rather than years of accumulated state, which is one less source of snapshot-to-snapshot variance. --confirm-legal-notice skips the first-run dialog.

Deleaker's launch configuration

The first announce

Deleaker launches the binary itself. Point Command at the executable, press Start Debugging, use the application, close it. Closing it captures a post mortem snapshot: everything still allocated when the process exited, and therefore everything that was never freed.

Here is that snapshot. 1362 entries.

The post mortem snapshot, unfiltered

Almost none of it is qBittorrent's. Nearly every row belongs to a Windows module: ntdll.dll, CRYPT32.dll, RPCRT4.dll, KERNELBASE.dll. The loader, crypto, RPC, and a pile of one-time operating system caches that every Windows GUI process accumulates and that no amount of reading will make interesting.

This is the normal shape of a first snapshot, and the first instinct, which is to start at the top and work down, is the wrong one. Narrow it to your own code before you read anything.

Two rows

Set Module to qbittorrent.exe. The list goes from 1362 to two.

The same snapshot, filtered to qbittorrent.exe

2 of 1362 shown. Two rows, 76 bytes each. Everything qBittorrent's own code allocated and never freed, in a single screenful, and both of them are real bugs.

  • application.cpp line 911
  • qobjectdefs_impl.h line 626

Which is a pleasant thing to be able to say about a codebase of this size, and it takes the next two sections to work through.

Finding 1. A filter with no owner

The first row, and the shorter story. application.cpp line 911, inside Application::exec:

    UIThemeManager::initInstance();
 
#ifdef Q_OS_WIN
    installNativeEventFilter(new NativeEventFilter(UIThemeManager::instance()));
#endif

QCoreApplication::installNativeEventFilter does not take ownership. It stores the pointer, calls the filter for the life of the application, and leaves disposal to you. Nothing here removes it, nothing deletes it, and it is still allocated when the process exits. The stack panel in the screenshot above shows the whole of it: operator new at application.cpp line 911, called from main line 323.

76 bytes, once, at startup. Which is nothing, and I want to be careful about the word.

"Small" is never a property of the code. It is a property of how the code happens to be called today. A new whose result nobody owns costs 76 bytes when it runs once at startup, and 76 bytes per call the day somebody wires the same function into something that runs repeatedly. Nothing in the line announces which of those it is, and whoever moves it will have no reason to look.

So this is hygiene rather than arithmetic. Fixing a leak while it is one line and 76 bytes costs less than meeting it again from the other end, once it has become the reason a long session runs out of something. Fix them as you find them, rather than waiting to see which one goes off.

Finding 2. The thread that missed its own funeral

The second row is 76 bytes as well, and 76 bytes is the visible tip of it.

qobjectdefs_impl.h line 626 is a QtPrivate::makeCallableObject slot object, the internal record of a signal-slot connection. Its stack lands in BitTorrent::SessionImpl::~SessionImpl, in this block at sessionimpl.cpp line 794:

    auto *sessionTerminateThread = QThread::create([nativeSessionProxy]()
    {
        qDebug("Deleting libtorrent session...");
        delete nativeSessionProxy;
    });
    sessionTerminateThread->setObjectName("~SessionImpl sessionTerminateThread");
    connect(sessionTerminateThread, &QThread::finished, sessionTerminateThread, &QObject::deleteLater);
    sessionTerminateThread->start();
    if (sessionTerminateThread->wait(shutdownDeadlineTimer))
        LogMsg(tr("BitTorrent session successfully finished."));

A worker thread tears down the libtorrent session on the way out, and deleteLater is supposed to clean up the thread object once it finishes. The 76 bytes on screen are that connection. The slot never runs.

Widening the filter

Before the mechanism, a point about attribution, because it decides what a module filter puts in front of you.

Deleaker's Module column is the module that allocated the block. It is not "my code appears somewhere in the stack". qbittorrent.exe allocated exactly two of these blocks, through its own operator new; everything else that qBittorrent leaked was allocated inside Qt and files under Qt6Cored.dll.

Set Module to Qt6Cored.dll, fifteen rows in this snapshot, and the rest of the object graph appears.

The leaked QThread, filtered to Qt6Cored.dll

The QThread itself, its private data, the connection records, the extra data holding that object name: nine rows, 1404 bytes, of which the qbittorrent.exe filter showed one.

So: filter to your own modules first, or you will spend the afternoon reading ntdll. Then widen it again, or you will miss the leaks your code is responsible for but did not allocate itself. Qt allocated these nine blocks. qBittorrent leaked them.

(There is a shortcut worth knowing. Export the snapshot and hand the file to an LLM when you want the whole thing characterised at once: which modules dominate, which stacks repeat, which rows are worth opening the list for.)

Why deleteLater never ran

Read that stack from the bottom up and it explains itself: QCoreApplication::aboutToQuit -> Application::cleanup -> Session::freeInstance -> SessionImpl::~SessionImpl -> QThread::create.

cleanup is wired to aboutToQuit, which Qt emits after the event loop has returned. What Qt still does at that point is flush pending DeferredDelete events, and nothing else. That is one event short of what this connection needs: finished is emitted on the worker thread while the receiver lives on the main thread, so the connection is queued and the signal only posts a metacall. Delivering that metacall is what would call deleteLater(), which would then post the DeferredDelete. The loop that would deliver it has already returned, so deleteLater() is never invoked and there is nothing for the flush to find.

The fix is to stop asking the event loop for a favour it can no longer grant: drop the deleteLater connect and delete sessionTerminateThread; in the branch where wait() succeeded. Leave it leaked on the timeout branch, because deleting a QThread that is still running aborts the process, and 268 bytes is much cheaper than that.

Change what you are counting

Everything so far came off the heap. 1183 of the 1362 entries in this snapshot are heap blocks, and if that were all the profiler tracked, the report would end here with two small shutdown bugs and a clean bill of health.

It is not all it tracks. Allocation Types in the toolbar governs what gets recorded, and Leak type filters what you are looking at.

The leak type selector

One thing to set before you start rather than after: allocation types are a property of the session, not of the snapshot. You cannot go back and ask a finished snapshot about handles it was never watching.

With them on, this same snapshot also holds 98 critical sections, 31 event handles, 6 file handles, 5 mutexes, 5 thread handles, 2 HFONT, 2 HBITMAP, and two HICON.

The two HICON are the reason this article exists.

Finding 3. The icon that never goes back

Set Leak type to User32 objects and clear the module filter. The entire application, Windows and Qt and libtorrent and qBittorrent together, leaks three USER objects.

The two leaked HICONs

Two of them are Qt's.

Look at the Size column first: <Not Available>, on every row. A USER handle has no size to report. Every ranking up to this point, by bytes, by hit count, by anything a heap profiler knows how to sort on, was blind to these two by construction. An allocator-level leak dump would not have listed them at any position, in any order, however carefully it was read.

Both rows are qwindowscontext.cpp, lines 642 and 646, in Qt's Windows platform plugin. That is the qwindowsd.dll in the Module Name column, the debug build of the qwindows.dll that ships with every Qt application on Windows. The stack on the selected one runs from user32.dll!LoadImageW through QWindowsContext::registerWindowClass and WindowCreationData::create down to QWidget::show and, at the bottom, qbittorrent.exe!MainWindow::MainWindow line 480. Qt's code at the top, qBittorrent's at the bottom: this happens when the main window is first shown.

Here is the code, at qwindowscontext.cpp line 641:

    if (icon) {
        wc.hIcon = static_cast<HICON>(LoadImage(appInstance, L"IDI_ICON1", IMAGE_ICON, 0, 0, LR_DEFAULTSIZE));
        if (wc.hIcon) {
            int sw = GetSystemMetrics(SM_CXSMICON);
            int sh = GetSystemMetrics(SM_CYSMICON);
            wc.hIconSm = static_cast<HICON>(LoadImage(appInstance, L"IDI_ICON1", IMAGE_ICON, sw, sh, 0));
        } else {
            wc.hIcon = static_cast<HICON>(LoadImage(nullptr, IDI_APPLICATION, IMAGE_ICON, 0, 0, LR_DEFAULTSIZE | LR_SHARED));
            wc.hIconSm = nullptr;
        }
    } else {

Three calls to LoadImage. Two of them leak, and the third is the one that shows why.

I misread this the first time, and the way I misread it is the point. LR_SHARED is right there on line 648, so at a glance the function looks like it is loading shared system handles that the caller must not destroy. But LR_SHARED is only on the fallback, the path taken when the application has no icon resource of its own and Qt settles for IDI_APPLICATION. The two calls above it load IDI_ICON1 from the application's own module without it, and per the LoadImage contract that returns a private handle the caller owns and must release with DestroyIcon.

Nothing releases them. Not registerWindowClass, and not the function that undoes its work, unregisterWindowClasses, which is short enough to quote in full:

void QWindowsContext::unregisterWindowClasses()
{
    const auto appInstance = static_cast<HINSTANCE>(GetModuleHandle(nullptr));
 
    for (const QString &name : std::as_const(d->m_registeredWindowClassNames)) {
        if (!UnregisterClass(reinterpret_cast<LPCWSTR>(name.utf16()), appInstance) && QWindowsContext::verbose)
            qErrnoWarning("UnregisterClass failed for '%s'", qPrintable(name));
    }
    d->m_registeredWindowClassNames.clear();
}

The class names are cleared. The classes are unregistered. The icons are not mentioned.

qBittorrent qualifies for the leaking branch because it ships an icon resource under exactly the name Qt looks for, at qbittorrent.rc line 3:

IDI_ICON1 ICON "icons\qbittorrent.ico"

So does nearly every other deployed Qt application on Windows. That is the part that makes this worth reporting: it is not a qBittorrent bug at all, and qBittorrent is not doing anything unusual to trigger it.

The third row in that screenshot, for completeness, is a SetTimer inside CoreMessaging.dll, which is Windows' own message plumbing. Not Qt's, not qBittorrent's, and not worth chasing.

Proving it: two peers and a control

An argument about ownership rules is still an argument. A leak that grows is a measurement. So I wrote a small reproducer that does nothing but replicate this and count.

Two binaries and three modes:

raw replicates Qt's register-and-unregister sequence directly, byte for byte, with no Qt involved. It answers the one question the source cannot: does UnregisterClass release the class icon on your behalf?

It does not. Every cycle leaks exactly 2 USER objects and 6 GDI objects: the two icons, and six GDI objects behind them.

qt does the real thing: constructs a QApplication and a top-level QWidget, destroys both, repeats. QWindowsContext re-registers its window classes for each new application instance, and reloads the icons each time. +3 USER and +6 GDI per cycle, linear across 300 cycles with no plateau. (The third USER object is a separate, unrelated leak in application teardown, and not one I chased.)

qticonleak_noicon is the control: the same source compiled without the IDI_ICON1 resource, so Qt takes the LR_SHARED fallback instead.

You do not need a profiler to watch the first one. Task Manager's Details tab has USER objects and GDI objects columns if you switch them on.

Six frames, five seconds. USER goes 1,395 -> 1,459 -> 1,521 -> 1,585 -> 1,647 -> 1,711. GDI goes 4,186 -> 4,378 -> 4,564 -> 4,756 -> 4,942 -> 5,134. Every step is exactly 1:3, which is the +2 and +6 signature arriving from an instrument that has never heard of Qt.

And the Memory column reads 1,624 K in all six frames. It does not move once.

That is the whole thesis of this article in a single strip of pixels. Two columns climbing, one column flat, in the same six frames. Any tool that ranks leaks by bytes is looking at the column that is not changing.

Now the control, same crop, same columns:

The control build, with no icon resource

1 USER object. 0 GDI objects. Same code path, same loop. The only difference between this process and the one above it is whether the executable contains an icon resource named IDI_ICON1.

That is what removes the argument. Not the reasoning about LR_SHARED, not the reading of the LoadImage documentation. A build that differs in one resource and does not leak.

Two more things worth stating precisely, because a bug report that overclaims gets discounted along with everything attached to it:

The code is byte-identical in Qt 6.7.2, 6.10.2 and 6.10.3. I diffed registerWindowClass across all three; only the line numbers move (642 and 646 in 6.7.2, 621 and 626 in 6.10.x). This is not a bug that was fixed while I was writing.

And for a normal application it is bounded: two USER objects and six GDI objects, once, for the life of the process. Window class names are stable, and registerWindowClass checks its own registry before loading anything, so a real application pays this once at startup and never again. The loop above is an amplifier built to make a fixed cost measurable, not a growth path anybody hits by using qBittorrent.

What makes it worth fixing anyway is the blast radius rather than the size. This is in the Windows platform plugin. Every Qt application on Windows that ships an icon resource runs it, and none of them can do anything about it.

Announcing to the tracker

Reported as QTBUG-149920 against QPA: Windows, with the reproducer attached.

One aside if you file a Qt bug yourself: bugreports.qt.io now redirects to Jira Cloud, whose migration has a documented bug in first-login enrolment. If Create tells you that you lack permission, visit https://qt-project.atlassian.net/jira/your-work once and try again.

The fix is a handful of lines: keep the loaded handles in QWindowsContextPrivate next to m_registeredWindowClassNames, and destroy them where the class names are cleared.

// QWindowsContextPrivate
QList<HICON> m_registeredWindowClassIcons;
 
// registerWindowClass(), after each successful non-shared LoadImage
if (wc.hIcon)
    d->m_registeredWindowClassIcons.append(wc.hIcon);
if (wc.hIconSm)
    d->m_registeredWindowClassIcons.append(wc.hIconSm);
 
// unregisterWindowClasses(), after the UnregisterClass loop
for (HICON icon : std::as_const(d->m_registeredWindowClassIcons))
    DestroyIcon(icon);
d->m_registeredWindowClassIcons.clear();

Only the IDI_ICON1 handles get collected. The IDI_APPLICATION fallback is LR_SHARED and must never be passed to DestroyIcon, which is the same distinction that caused the bug, now on the other side of it.

Does the swarm leak? One cycle says yes

Everything so far came from a single post mortem snapshot: start the program, close it, read what survived. That finds fixed costs. It cannot find the thing you actually care about in a program people leave running for a week, which is whether ordinary use leaves anything behind.

For qBittorrent the ordinary use is obvious. Add a torrent, remove a torrent.

That is a claim about slope, and slope is measured by comparison. Take a snapshot as a baseline, do the work, take another, and diff them. Deleaker keeps both and subtracts.

Snapshots and comparisons

Three snapshots and two comparisons, and the design of them is the whole experiment:

  1. Snapshot #1, once the window has settled. The cold baseline.
  2. One complete cycle (add, wait, remove), then Snapshot #2.
  3. Five more identical cycles, then Snapshot #3.

Diff #4 compares #1 against #2: what one cycle cost, measured from cold. Diff #5 compares #2 against #3: what five more cost, measured from a baseline that already contains everything the first cycle warmed up.

Keep the cycles identical or you are measuring your own inconsistency. I added by URL every time, using the Debian netinst torrent from cdimage.debian.org, waited about fifteen seconds for it to start downloading, and removed it with the same "delete files" choice each time.

Here is Diff #4. One add, one remove, filtered to qBittorrent's own code.

One cycle, filtered to qbittorrent.exe

44 rows. Sorted by size: 16,564 bytes, 15,412, 4,276, 3,892, 3,124, 3,124, 1,588, 1,204, all from qhash.h line 394, and the stack names them without ambiguity:

qbittorrent.exe!operator new Line 36
qbittorrent.exe!QHashPrivate::Span<...libtorrent::storage_index_tag_t...>
qbittorrent.exe!QHash<...storage_index_tag_t..., CustomDiskIOThread::StorageData>
qbittorrent.exe!CustomDiskIOThread::new_torrent Line 78

A hash table, sixteen kilobytes of it, allocated when a torrent was added and apparently still there after it was removed. And the code looks exactly as bad as the profiler makes it sound. customstorage.cpp line 75 inserts:

lt::storage_holder CustomDiskIOThread::new_torrent(const lt::storage_params &storageParams, const std::shared_ptr<void> &torrent)
{
    lt::storage_holder storageHolder = m_nativeDiskIO->new_torrent(storageParams, torrent);
    m_storageData[storageHolder] =
    {
        .savePath = Path(storageParams.path),

and line 92 removes:

void CustomDiskIOThread::remove_torrent(lt::storage_index_t storage)
{
    m_nativeDiskIO->remove_torrent(storage);
}

The entry goes in on add. It does not come out on remove. There is no erase anywhere in the file. Add a torrent, remove it, and the map keeps the row.

Switch the leak type and it looks worse still. Four USER objects appeared during that one cycle:

USER objects from one cycle

Two timers, another timer, and a cursor. This is the point in the investigation where you write the bug report.

One cycle was lying

Do not write the bug report. Run the cycles.

Here is Diff #5: five more identical cycles, the same filter, the same view.

Five cycles, filtered to qbittorrent.exe

1 of 336 shown.

Forty-four rows became one. The sixteen kilobyte hash table did not appear five more times; it did not appear at all. Whatever the first cycle cost, the next five cost nothing like it, and a per-torrent leak cannot behave that way.

Why the hash table did not grow

It is worth understanding rather than just observing, because "it stopped happening" is not an explanation and the code genuinely does look wrong.

m_storageData is a QHash keyed by libtorrent's storage_index_t, and libtorrent hands those out from a free list, returning each one when the torrent is removed. So the second torrent is handed the index the first one gave back, m_storageData[storageHolder] = ... assigns over the existing key, and the hash never allocates again. The first cycle grew it from empty; the next five wrote into the same slot.

The entry is still never erased, and the map still holds a stale StorageData for every index that has ever been used. But it is bounded by the largest number of torrents present at one time, not by how many you have added over the life of the session, which is the difference between a wart and a leak. Worth knowing if you are modifying that class. Not worth a bug report.

The one row that remains

The single surviving row is 5,219 bytes, and it resolves completely:

qbittorrent.exe!operator new Line 36
qbittorrent.exe!std::allocator<Log::Msg>::allocate Line 986
qbittorrent.exe!boost::circular_buffer<Log::Msg>::allocate Line 2396
qbittorrent.exe!boost::circular_buffer<Log::Msg>::set_capacity Line 879
qbittorrent.exe!boost::circular_buffer_space_optimized<Log::Msg>::check_low_capacity Line 155
qbittorrent.exe!boost::circular_buffer_space_optimized<Log::Msg>::push_back Line 780
qbittorrent.exe!Logger::addMessage Line 78
qbittorrent.exe!BitTorrent::SessionImpl::handlePortmapWarningAlert Line 6347

libtorrent reported a UPnP port mapping warning, qBittorrent logged it, and the log buffer grew. That buffer is declared at logger.h line 96 and constructed at logger.cpp line 52 with a hard bound from logger.h line 38:

inline const int MAX_LOG_MESSAGES = 20000;

circular_buffer_space_optimized allocates lazily and enlarges its storage on demand up to that ceiling, which is precisely the push_back -> check_low_capacity -> set_capacity -> allocate chain in the stack. Twenty thousand messages and it stops. It is a ring buffer filling up, caught in the act.

One row is a better result than none, and I would rather report it this way. An empty list invites the reader to wonder whether the filter was wrong or the profiler had stopped watching. One row that resolves cleanly to a fixed-capacity buffer proves the instrument was live the whole time and still had nothing to report.

Peers that only looked like leechers

Four things in this investigation looked like leaks and were not. Telling those apart in a few seconds is most of what makes a leak report usable, and each of the four fails for a different reason.

The timer that is always new: m_freeDiskSpaceCheckingTimer is single-shot and restarts itself, at sessionimpl.cpp line 710:

    connect(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::checked, this, [this](const qint64 value)
    {
        m_freeDiskSpace = value;
        m_freeDiskSpaceCheckingTimer->start();
        emit freeDiskSpaceChecked(m_freeDiskSpace);
    });

A single-shot QTimer releases its Win32 timer when it fires and registers a new one when restarted, so it has a different timer id in every snapshot. A comparison subtracts the old id, finds a new one, and reports it as something that appeared. It will do that forever, in every diff, in any application that polls on a timer.

The arithmetic is what settles it. FREEDISKSPACE_CHECK_TIMEOUT is 30 seconds. If each restart leaked a timer, several minutes between snapshots would have produced a dozen. There was one.

The timer that belongs to Qt has an even shorter explanation, written in its own stack, reading from the caller down: QPMCache::timerEvent -> QObject::startTimer -> registerTimer. That is QPixmapCache's flush timer, restarting itself for exactly the same reason.

The 590 KB notification icon: in an earlier run, DesktopIntegration::showNotification accounted for 589,876 bytes in a single block, which is an alarming number for showing a balloon. It goes through QWindowsSystemTrayIcon::showMessage -> icon.actualSize(QSize(256, 256)) -> QPixmap::scaled -> qSmoothScaleImage, and 384 x 384 x 4 = 589,824, plus a 52-byte header. A 256x256 logical icon on a display at 1.5x device pixel ratio. The number is the arithmetic of the screen it was rendered for.

And the Qt code around it is correct, which is worth showing given the rest of this article, at qwindowssystemtrayicon.cpp line 221:

    const auto size = icon.actualSize(QSize(256, 256));
    QPixmap pm = icon.pixmap(size);
    if (m_hMessageIcon) {
        DestroyIcon(m_hMessageIcon);
        m_hMessageIcon = nullptr;
    }

The previous icon is destroyed before the next one is created, and both are destroyed again during cleanup. Same file, same plugin, same author community as Finding 3, and here the handle is managed properly. Which is a useful thing to know about a codebase before you accuse it of anything.

Other vendors' shell extensions: this one is a mistake in method rather than a misreading, and it was mine. An earlier run of the same experiment added torrents through File > Open, and its diff carried 539 allocations from dui70.dll, UIAutomationCore, TortoiseOverlays, YandexDisk3ShellExt and OneDrive's FileSyncShell64, 344 of them underneath QFileDialog::getOpenFileNames. The Windows common file dialog loads every registered shell overlay handler into your process, and none of that memory is yours.

The run in this article adds by URL instead. Same measurement, one UI step removed, and those five modules never appear. Look at what is left in the unfiltered five-cycle comparison:

Five cycles, all modules

336 rows against the earlier run's 1500, and the survivors are MSWSOCK, torrent-rasterbar, combase, CRYPT32, RPCRT4: sockets, TLS and RPC. The selected stack is NtCreateFile -> MSWSOCK -> WS2_32!WSASocketW -> libtorrent, which is a BitTorrent client opening a connection, which is the job.

If you take one procedural thing from this article, take that one: keep a file dialog out of your profiling loop, or you will spend an afternoon reading other people's shell extensions.

Settling the ratio

Three things are worth a patch. Ranking them by size gets the order wrong, so rank them by who is exposed.

First, the DestroyIcon in Qt: two USER objects and six GDI objects is nothing for any one process, and it ranks first anyway, because it is the only finding here that cannot be fixed where it is found. It sits in the Windows platform plugin. No application that suffers it can do anything about it, and one patch upstream settles it for all of them at once.

Then the sessionTerminateThread: 1404 bytes, at shutdown, bounded. It comes second because it is qBittorrent's own and only qBittorrent pays it. The fix is a delete on the successful wait() branch. Reported with the next one as #24945.

Then the NativeEventFilter: 76 bytes. Free to fix, so fix it.

One word on what this ranking is not, because it is easy to read it as the usual excuse. "It gets freed when the process exits" is true of every finding in this article, true of every leak anyone has ever reported, and for that reason it distinguishes nothing and excuses nothing. The two qBittorrent bugs come second and third because their cost is capped and known, not because the process was going to end anyway. Second and third are still on the list.

And qBittorrent's ratio, for the record, is good. Two bounded bugs around start-up and shutdown, nothing at all on the path that runs all day. For a GUI application of this size and age, that is a better result than most.

The same pass, on your own code

Nothing above depended on qBittorrent being qBittorrent. Here is the procedure with the client taken out of it.

Build so that symbols reach the binary, and point the program at a scratch profile so the starting state is one you chose rather than one your machine accumulated. Set the allocation types before you run. They are a property of the session, and you cannot ask a finished snapshot about handles it was never watching.

Start and close the application, and read the post mortem snapshot. Filter to your own modules immediately, or you will read ntdll all afternoon. Then widen the filter again, because the module column is who allocated the block, and the leaks your code is responsible for are frequently allocated inside a framework on its behalf. Both halves are necessary: nine tenths of Finding 2 sits outside the module that leaked it.

Then change the resource type and read the list again. This step has no equivalent in a heap dump, and in this case it is the step that found the only thing worth reporting upstream.

Finally, for anything you suspect repeats: stop reading and measure it. Baseline, one cycle, snapshot, several more cycles, snapshot, and compare the second pair rather than the first. The first cycle is mostly caches waking up: certificate stores, style pixmaps, model construction, DNS. It will show you a sixteen kilobyte hash table that looks exactly like a leak and is not. The second comparison starts from a warm baseline and subtracts all of it.

There is a free trial if you want to run that pass on your own application.

Postscript

The thing I did not expect was which way round it came out.

I went looking for leaks in a BitTorrent client and the client was clean. The path I was most suspicious of, adding a torrent and removing it, the operation a user performs hundreds of times a week, came back with one row, and that row was a ring buffer with a documented ceiling. Meanwhile the actual bug was underneath it, in the toolkit, in eight lines that run when any Qt application on Windows shows its first window, and it had been sitting there across every Qt 6 release I could check.

It survived because of what it leaks rather than how much. Two handles with no size, in a column that reads <Not Available>, in a process whose memory usage does not move by a single byte while they accumulate. Every instrument that ranks by bytes, every allocator hook and CRT leak dump and glance at Task Manager's memory column, was pointed at the one number that stays still.

Which is the question worth carrying to your own code, and it is not how many bytes. It is which resource, and does the number grow.