Investigating Long-Run Crashes of an Industrial Camera App - The Handle Leak (Part 1)

· Updated: · · Windows Development, Bug Investigation, Industrial Camera, Handle Leak, Logging Design

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614469)
First published
Cite this article(DOI: 10.5281/zenodo.21614468)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Investigating Long-Run Crashes of an Industrial Camera App - The Handle Leak (Part 1). KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614468 https://comcomponent.com/en/blog/2026/03/11/002-handle-leak-industrial-camera-long-run-crash-part1/

DOI (latest version)
10.5281/zenodo.21614468
DOI (this version)
10.5281/zenodo.22217127

When a Windows app suddenly crashes after running for a long time, the first instinct is very often to suspect a memory leak. In reality, however, it is not uncommon for a handle leak to be the main culprit, finally surfacing weeks later as a secondary failure.

This article presents a case where we investigated a Windows app controlling an industrial camera that suddenly crashed after roughly one month of continuous operation. As we narrowed things down, the cause turned out to be a handle leak occurring on the failure path around camera reconnection.

In this first part, we cover what a handle leak is, how we isolated this incident, and what logs you should keep to prevent recurrence. In the second part, Building a Windows Failure-Path Test Foundation with Application Verifier, we discuss building a failure-path test foundation.

Proper names and some log fields have been redacted, but the way of thinking is broadly shared across Windows equipment control apps in general.

Table of Contents

  1. The Conclusion First (In One Line)
  2. What Is a Handle Leak?
    • 2.1. What “Handle” Means Here
    • 2.2. Why It Tends to Surface Only After Long-Running Operation
    • 2.3. How It Differs from a Memory Leak
  3. Case Study: An Industrial Camera Control App That Suddenly Crashes After One Month
    • 3.1. The Symptoms
    • 3.2. The Metrics We Looked at First
    • 3.3. The Leak That Was the Root Cause
  4. How We Isolated It
    • 4.1. Compress Time Instead of Waiting for a Month-Scale Repro
    • 4.2. Read the Slope of Handle Count
    • 4.3. Check the Pairing of create/open and close/dispose
    • 4.4. For Handle Leaks, Find Where It Leaked, Not Where It Crashed
  5. The Logs You Need to Prevent Recurrence
    • 5.1. The Minimum Set to Keep First
    • 5.2. The Logs We Actually Strengthened
    • 5.3. At What Granularity to Collect
  6. A Rough Decision Guide
  7. Summary
  8. References

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (18 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

1. The Conclusion First (In One Line)

  • In a control app that only crashes after long-running operation, always look at Handle Count, not just Private Bytes
  • Handle leaks tend to hide not in the normal path but in the timeout / reconnect / partial-failure / early-return paths
  • The line that actually crashes is often the place that could no longer create a new handle later, not the place that leaked it
  • The logs you need first are: the operation/session context, the process’s handle count, the open/close pairing of resources, and Win32 / HRESULT / SDK errors
  • Rather than waiting for a month-scale repro, it is faster to run the connect-disconnect-reconnect-failure paths thousands of times in a short loop
  • Application Verifier, covered in Part 2, is quite effective, but the foundation is being able to trace lifetime breakdowns with your own logs first

In short, the first thing to do on a case like this is not to stare at the fact that “it crashed after a long period,” but to get the growth of resources and the failure paths into an observable form.

By the time a handle leak is found, it usually already wears the face of a secondary failure. So if you only look at the exception at the moment of the crash, you tend to walk off in quite the wrong direction.

What to tackle firstShows that looking only at the exception at the moment of the crash leads off in the wrong direction, while first making resource growth and failure paths observable leads to the handle leak that wears the face of a secondary failure.Look only at the exception at the crashWalk off in the wrong directionObserve resource growth and failure pathsReach the real source of the leak

Figure 1: Before staring at the fact that it crashed, get the growth of resources and the failure paths into an observable form.

2. What Is a Handle Leak?

2.1. What “Handle” Means Here

A handle here is the identifier through which a Windows process references OS resources. Examples of what falls under this include:

Category Examples
Kernel objects event, mutex, semaphore, thread, process, waitable timer
I/O opens of files, pipes, sockets, devices
Common in equipment control the camera SDK’s internal events, wait objects tied to callback registrations, acquisition-thread-related handles

What tends to become a problem in control apps in particular is the pattern of “forgetting to close a resource that was opened temporarily for some operation, on a partial-failure path”.

The typical flow looks like this.

  • Create one event on every reconnect
  • Callback registration or acquisition start fails partway through
  • The success path closes it, but the failure path does not
  • Routine short tests only exercise the success path, so it goes unnoticed

This type slips through quite routinely, both in code review and in production.

The typical pattern that leaks only on the failure pathShows that an event is created on every reconnect and closed when callback registration and acquisition start succeed, but is left unclosed on a partial-failure path, and that short tests only exercise the success path so the leak goes unnoticed.SuccessPartial failureCreate an event on every reconnectDid registration and start succeedClosed on the success pathNot closed on the failure pathShort tests run only the success path and miss it

Figure 2: Forgetting to close a temporarily opened resource on a partial-failure path. Especially common in control apps.

2.2. Why It Tends to Surface Only After Long-Running Operation

A handle leak does not necessarily break things spectacularly in one shot. What is actually nastier is a small-slope leak, where one failure leaks just one handle.

Normal operationOccasional timeout / reconnectFailure path creates an event handleCloseHandle is never calledHandle Count creeps up slightlyRepeats hundreds of timesCreateEvent / SDK open failsCrash / stall somewhere else

Figure 3: One small leak per occurrence piles up hundreds of times at the boundary conditions of 24/7 operation and finally surfaces.

If one reconnect leaks just one handle, nothing happens within minutes. But in an equipment control app running 24/7, boundary conditions like timeouts, re-initializations, and disconnect recovery occur over and over. The result is the odd presentation of a problem that only surfaces weeks later.

What matters here is that the handle leak itself is not necessarily the crashing line. The common modes of breakage are these.

  • An API that creates a new event / file / thread fails
  • The SDK cannot create a resource it needs internally and returns only a generic failure code
  • Error handling after the failure is thin, and the app dereferences a null / invalid handle and crashes
  • Timeouts increase, and as a result a watchdog or upstream controller kills the process

In other words, the crash site is the “last victim,” not necessarily the “original culprit.”

The crash site is the last victimShows that when handles keep leaking somewhere, an API that creates a new resource eventually fails and the problem surfaces as a crash or stall somewhere else where error handling is thin, so the crashing line is the last victim rather than the original culprit.Handles keep leaking somewhereAn API that creates a new resource failsCrash or stall somewhere elseThe crashing line is only the last victim

Figure 4: The leak itself is not necessarily the crashing line. The breakage is almost always a secondary failure.

A naive question comes up here: why does a mere few thousand handles bring the app down?

Going by the raw numbers, the limit looks far away. For kernel object handles, the theoretical ceiling is 2^24 (about 16.77 million) per process. Handles live in the paged pool, though, so the number you can actually create depends on the memory available, and on 32bit Windows it lands far below the theoretical figure.

In short, crashing because you reached the theoretical limit is the minority case. What actually bites first is usually one of the following.

What you hit first Rough figure When it matters
GDI objects 65,536 per session in theory. On top of that there is a per-process default limit, adjustable through the registry value GDIProcessHandleQuota in the range 256 to 65,536 Apps with a GUI in the same process. Hitting it in the low thousands is routine
The SDK’s internal bookkeeping table Vendor-dependent The camera SDK’s internal handle table or fixed-length array fills up first
Kernel resources such as the paged pool Shared across the whole machine When resources other than handles are being consumed at the same time
The virtual address space of a 32bit process 2GB / 3GB The buffers allocated alongside the handles matter more than the handles themselves

So the reading “we still have headroom to the limit, so we are fine” does not hold up. Judge by whether what should come back has come back, not by whether the limit is within reach. Once the slope stands up, it is safer to treat the app as already abnormal.

What you hit before the theoretical limitShows that crashing on the theoretical kernel handle limit is the minority case and that in practice the GDI object limit, the SDK internal bookkeeping table, or the address space of a 32bit process is hit first, so the judgment should be whether what should come back has come back.The kernel theoretical limit is about 16.77 millionReaching it is the minority caseWhat you actually hit firstThe GDI limitThe SDK internal bookkeeping tableThe 32bit address spaceJudge by whether it comes back or not

Figure 5: Headroom to the limit is no reassurance. Once the slope stands up, treat it as abnormal.

2.3. How It Differs from a Memory Leak

For defects after long-running operation, the first suspicion is a memory leak. That instinct is natural, of course, but handle leaks are sometimes faster to find when viewed along a different axis.

Aspect Memory leak Handle leak
Metrics to check first Private Bytes, Commit, Working Set Handle Count
Typical symptoms Memory pressure, paging, slowdowns, OOM Create* / Open* / SDK internal init failures, secondary failures
Where it tends to hide Caches, retained references, forgotten frees Asymmetry between create/open and close/dispose
How it presents Memory creeps up Handle count creeps up and never comes back down

So when isolating long-run issues, looking only at memory is like driving with one eye closed. At minimum, watching Handle Count and Thread Count together makes things considerably easier to sort out.

Metrics to watch together when isolating long-run issuesShows that memory leaks and handle leaks are read from different metrics, so watching Handle Count and Thread Count alongside memory metrics such as Private Bytes avoids the state of driving with one eye closed.Isolating long-run issuesMemory metrics (Private Bytes and so on)Handle CountThread CountIf it grows and never comes back, it is a handle leak

Figure 6: Watching memory alone is driving with one eye closed. Track the handle and thread counts on the same screen.

3. Case Study: An Industrial Camera Control App That Suddenly Crashes After One Month

3.1. The Symptoms

The incident was simple.

  • A Windows app controlling an industrial camera runs 24/7
  • It runs fine normally
  • After roughly one month, one day the app suddenly crashes
  • After a restart, it runs fine again for a while

The first difficulty is that it takes a long time to crash. Waiting one month per reproduction attempt is brutal as an investigation.

What made it even nastier was that the crash site was not exactly the same each time. Sometimes it was right after a reconnect started, sometimes at acquisition start, sometimes after a failed SDK call.

With that presentation, at first you can suspect any of the following.

  • Instability on the camera SDK side
  • Transient failures caused by communication or device disconnects
  • A memory leak
  • A race around threading
  • An initialization failure not showing up in the logs

In other words, we were in a state of too many “vaguely suspicious” candidates.

The two things that made this case hard to investigateShows that the app crashed suddenly after about a month of continuous operation so each reproduction took a month, and that the crash site was not exactly the same every time, leaving too many suspects such as the SDK, communication, and memory.Crashes suddenly after about one monthEach reproduction takes a monthThe crash site differs slightly each timeToo many plausible suspects

Figure 7: When it takes a long time to crash and the crash site moves around, guesswork gets you nowhere.

3.2. The Metrics We Looked at First

So the first thing we did was look at how the process’s resources as a whole were growing. In this case, the observed trends were roughly as follows.

Metric Observed trend Reading
Handle Count Creeps up after reconnects and timeouts, never comes back down Suspect a handle leak
Private Bytes Fluctuates, but the monotonic-increase slope is weak The main culprit is not necessarily the heap
Thread Count Essentially flat A thread leak is unlikely
Crash site Slightly different every time A secondary failure is likely

At this point, our focus had narrowed considerably. It was more natural to read the situation not as “it crashes after one month,” but “something is leaking a little at a time along the way, and as a result it crashes after one month.”

The reading the first metrics narrowed us down toShows that Handle Count alone grew and never came back, the Private Bytes slope was weak, Thread Count was flat, and the crash site differed each time, which narrowed the reading to a slow leak that brings the app down after a month.Handle Count grows and never comes backThe focus narrowsThe Private Bytes slope is weakThread Count is essentially flatLeaking a little at a time, crashing after a month

Figure 8: Line up the shapes of the four metrics and you see that it has been leaking all along, not that it crashes after one month.

3.3. The Leak That Was the Root Cause

The ultimate cause was a missed close of an event handle created on the initialization-failure path during camera reconnection.

Simplified, the flow looks like this.

Camera SDKWindowsControl appCamera SDKWindowsControl appReturns on the failure pathCloseHandle is never calledloop[Repeated reconnects]CreateEventRegister callbackPartial failure / timeoutHandle Count creeps upNext CreateEvent / OpenFailureCrashes as a secondary failure

Figure 9: The root cause was a missed close of an event handle on the reconnect failure path. It piles up until something else falls over.

As a code sketch, the leak looks like this.

handle = CreateEvent(...)

if (!RegisterCallback(handle))
{
    return Error;   // CloseHandle(handle) is missing
}

if (!StartAcquisition())
{
    return Error;   // close is missing here too
}

...
CloseHandle(handle)

The reason this slips past short tests is also quite easy to see.

  • A normal startup -> normal shutdown does close it
  • Failures only happen partway through a reconnect
  • There is no test that hammers that failure path
  • In production, it accumulates a little at a time over weeks

In other words, the structure was: “invisible if you only watch the normal path, but it leaks routinely on the failure paths.”

The fix is not flashy.

  • Bring the responsibilities of create/open and close/dispose closer together
  • Move release into finally / destructors / a session object so it always happens even on partial failure
  • Make ownership explicit around callback registration and acquisition start
  • Express “who closes it” through the code’s responsibilities, not comments
The core of the fixShows a fix that brings the create and close responsibilities closer together, moves release into finally, a destructor, or the session object so it always happens even on partial failure, and expresses who closes the handle as a code responsibility rather than a comment.The fixBring create and close responsibilities togetherMove release into finally or a destructorExpress ownership as a code responsibilityDo not rely on a comment-based convention

Figure 10: Not a flashy fix. Embed the resource lifetime into the structure of the code itself.

Prose alone is hard to follow, so here is the same operation rewritten.

In C++, define one small RAII type that owns the handle, and keep raw HANDLE values out of the function body.

// C++17 / Windows
#include <windows.h>
#include <utility>

class UniqueHandle
{
public:
    UniqueHandle() noexcept = default;
    explicit UniqueHandle(HANDLE h) noexcept : h_(h) {}

    UniqueHandle(const UniqueHandle&) = delete;
    UniqueHandle& operator=(const UniqueHandle&) = delete;

    UniqueHandle(UniqueHandle&& other) noexcept
        : h_(std::exchange(other.h_, nullptr)) {}

    UniqueHandle& operator=(UniqueHandle&& other) noexcept
    {
        if (this != &other)
        {
            reset(std::exchange(other.h_, nullptr));
        }
        return *this;
    }

    ~UniqueHandle() { reset(); }

    HANDLE get() const noexcept { return h_; }
    explicit operator bool() const noexcept { return h_ != nullptr; }

    void reset(HANDLE h = nullptr) noexcept
    {
        if (h_ != nullptr)
        {
            ::CloseHandle(h_);
        }
        h_ = h;
    }

private:
    HANDLE h_ = nullptr;
};

With this in place, you no longer have to add a CloseHandle to every failure path.

// Member of CameraSession: UniqueHandle frameReady_;
bool CameraSession::Reconnect()
{
    UniqueHandle frameReady{ ::CreateEventW(nullptr, TRUE, FALSE, nullptr) };
    if (!frameReady)
    {
        return false;   // Creation itself failed. Nothing to close
    }

    if (!RegisterCallback(frameReady.get()))
    {
        return false;   // Returning here is fine: the destructor closes it
    }

    if (!StartAcquisition())
    {
        // On a failure after registration succeeded, unregister before closing.
        // If we leave without unregistering, the destructor calls CloseHandle
        // while the SDK still holds the handle we passed in. On the next frame
        // it signals an already-released handle value, and if that value has
        // been reused for another resource, the symptom surfaces as an
        // unrelated event firing on its own.
        UnregisterCallback();
        return false;
    }

    // Transfer ownership to the session only on success
    frameReady_ = std::move(frameReady);
    return true;
}

In C#, a single using often will not do the job, so track whether ownership was handed over in a flag and dispose in finally only when it was not. Writing a plain using var would dispose the object even when the operation succeeded.

// C# / .NET 8
// Field of CameraSession: private ManualResetEvent? _frameReady;
public bool Reconnect()
{
    var frameReady = new ManualResetEvent(false);
    var handedOver = false;
    var registered = false;

    try
    {
        if (!RegisterCallback(frameReady))
        {
            return false;
        }

        registered = true;

        if (!StartAcquisition())
        {
            return false;
        }

        _frameReady?.Dispose();
        _frameReady = frameReady;
        handedOver = true;
        return true;
    }
    finally
    {
        if (!handedOver)
        {
            // Detach the reference the outside world holds before disposing.
            // The SDK keeps the handle passed in at registration time,
            // so reversing the order lets it hit an already-released handle
            if (registered)
            {
                UnregisterCallback();
            }

            frameReady.Dispose();
        }
    }
}

Both do the same thing. The structure guarantees that no matter where the function returns, any resource whose owner is not settled is always disposed of. Instead of a human writing “close it if it fails” every single time, the type and the finally block take that over.

Ownership transfer decides who releases the resourceShows a structure where, no matter where the function returns, the session takes over release once ownership has been handed to it, the type or the finally block always disposes of the resource when ownership has not been handed over, and the SDK registration is removed before disposal.Handed overNot handed overNo matter where the function returnsWas ownership handed overThe session handles release from here onThe type or finally always disposes of itUnregister before disposing

Figure 11: Instead of writing close it on failure every time, let the destination of ownership decide who releases the resource.

This is not so much a special technique as housekeeping that embeds resource lifetimes into the code.

4. How We Isolated It

From this chapter on, the English investigation vocabulary appears as is. Here is a short glossary first.

Term In plain terms What it means in this article
baseline Reference value The value once warm-up has finished and things have settled. We read deltas from here
leakSlope Leak slope How many handles are gained per cycle. A home-grown metric for how fast the count climbs
structured log Structured log A log emitted as fixed fields such as key=value rather than prose. It can be aggregated mechanically later
heartbeat Periodic report A log emitted at a fixed interval that keeps reporting liveness and resource values
harness Test scaffold A small executable that repeatedly drives just the operation you want to exercise, in place of the real app
phase Stage A marker for which step of the processing you are in, such as OpenStart or ReconnectStart

4.1. Compress Time Instead of Waiting for a Month-Scale Repro

In this kind of investigation, waiting a month per attempt is a bad approach. What you should do is drive the suspicious paths over and over in a short time.

In this case, we compressed the repro by running a loop like this.

YesNoStartOpen cameraStart acquisitionSimulated timeout / disconnectReconnectResume acquisitionRepeat N timesCheck the deltas at the end

Figure 12: Instead of waiting for a month-scale repro, drive only the open, disconnect, and reconnect boundaries thousands of times in a short loop.

The point is to spend your time on the lifetime operations at the boundaries, not on the routine “frames are coming in” periods.

Concretely effective scenarios look like these.

  • Run open -> start -> stop -> close in large volumes
  • Deliberately trigger timeouts and cycle through reconnects
  • Force a failure right after callback registration
  • Inject disconnect aborts, reconnect aborts, and shutdown races

You do not need to perfectly reproduce a month of real operation. On the contrary, stepping on the suspected lifetime edge thousands of times gets you much closer to the cause.

4.2. Read the Slope of Handle Count

Before that, a word on where you actually look at Handle Count. Without knowing that, nothing in this section is actionable.

Method What to do When it fits
Task Manager Open the Details tab, right-click a column header, choose Select columns, and check Handles You want to see the current number right away
Process Explorer Select the process, open Properties, and read Handle Count on the Process Performance tab. Sorting the lower-pane Handles view by Type also gives you the breakdown by kind You want to know what kind of handles are growing
handle.exe handle -s -p CameraApp gives you the per-type summary as text You want periodic observations recorded in a log
PowerShell Get-Process -Name CameraApp \| Select-Object Name, Id, HandleCount You want to collect it periodically from a script
typeperf typeperf "\Process(CameraApp)\Handle Count" -si 60 -sc 1440 -o handles.csv You want a long recording straight to CSV
The app itself Embed GetProcessHandleCount or Process.HandleCount in the heartbeat log You want to collect nothing but logs from the production machine

The breakdown by type, and how to track the growth of unnamed events, are written up as step-by-step procedures in Process Explorer / Handle / VMMap in Practice.

For long-run investigations, the real workhorse is the last row: the app emitting the numbers itself. Having a person watch Task Manager does not hold up around the clock.

In a handle leak investigation, looking only at absolute values can be confusing. What matters is whether the count comes back down after operations that should return it, and how many handles you gain per how many operations.

Roughly the following order works well.

  1. Establish a baseline after warm-up
  2. Record Handle Count after each reconnect / start-stop / close
  3. Look at the delta per cycle
  4. Also look at the slope aggregated over several cycles

For example, a view like this.

leakSlope =
    (currentHandleCount - baselineHandleCount)
    / reconnectCount

Whether an absolute value of 2000 is high or low varies by app. But if it is +1 per reconnect and never comes back, that is quite suspicious.

Here is a rough guide to what the healthy case should look like. The numbers themselves depend on the app, so judge by the shape.

  • Right after startup the count climbs. Do not read anything into this stretch
  • Once warm-up finishes, the count should rise and fall with the operations while moving in and out of a fixed range
  • After one cycle of open -> start -> stop -> close, a healthy app returns to roughly the same value as before the cycle
  • If you run 100 cycles and the difference from the baseline stays within a few handles, the app is healthy as far as this goes
  • Conversely, if it climbs cleanly in proportion to the cycle count, it leaks by exactly that slope on every cycle

What you read is not “high or low” but whether it comes back. Get that backwards and you will burn time suspecting a healthy app.

How to read the slope of Handle CountShows a reading where a baseline is established after warm-up, the delta per cycle is examined, a return to the starting value after a cycle counts as healthy, and a clean climb proportional to the cycle count means a leak of exactly that slope on every cycle.It returnsClimbs proportionallyEstablish a baseline after warm-upLook at the delta per cycleDoes it return to the starting value after a cycleHealthy as far as this goesLeaking by that slope every cycle

Figure 13: Judge by the shape of whether it comes back, not by whether the absolute value is high or low.

The trick here is to not watch Handle Count alone, but to record at least the following alongside it.

  • Handle Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • Which phase you are currently in

With this, you can tell quite quickly whether “memory is growing,” “threads are growing,” or “resources are not coming back on every reconnect.”

4.3. Check the Pairing of create/open and close/dispose

Even once you know the process-wide Handle Count is suspicious, that alone does not get you to the leak site. What you need next is logs that show resource lifecycles as pairs.

As an image, structured logs like these.

CameraSession session=421 cameraId=CAM01 phase=ReconnectStart reason=FrameTimeout handleCount=1824 privateBytesMB=418

CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Create osHandle=0x00000ABC handleCount=1825

CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Close osHandle=0x00000ABC handleCount=1824

What matters here is to not rely on osHandle alone. Windows handle values can be reused later, so in the logs it is easier to trace if you carry at least the following.

  • sessionId
  • resourceId
  • kind
  • action(Create/Open/Register/Close/Dispose/Unregister)
  • osHandle
  • phase

With this in place, it becomes much easier to spot the lopsided flow where a Create exists but no Close.

Logs that trace resource lifecycles in pairsShows that recording Create and Close as a pair and linking them with sessionId and resourceId reveals the lopsided flow where a Create exists but no Close, and that osHandle alone is not enough because handle values get reused.Record Create and Close as a pairLink them with sessionId and resourceIdFind Creates with no matching CloseosHandle gets reused, so it cannot stand alone

Figure 14: To get from a process-wide number down to the leak site, you need logs that pair up resource lifecycles.

4.4. For Handle Leaks, Find Where It Leaked, Not Where It Crashed

This point is quite important.

A handle leak often presents like this.

  • The crashing line: CreateEvent fails
  • The real leak: CloseHandle had been missing on a failure path since days earlier

In other words, the API that finally fell over is the exit of the damage, not necessarily the entrance of the cause.

So the investigation order should be:

  1. Look at which resource keeps growing
  2. Look at which operation boundary it fails to come back at
  3. Find where the pairing of create/open and close/dispose is broken
  4. Read the crash site last

In this order, you are far less likely to get lost.

The investigation order that leads back to where it leakedShows that looking at which resource keeps growing, then at which operation boundary it fails to come back at, then finding where the create and close pairing is broken, and reading the crash site last, makes it far less likely that you get lost.Look at which resource keeps growingLook at the operation boundary it never returns fromFind where the create and close pairing is brokenRead the crash site last

Figure 15: The crash site is only the exit. Trace back from the entrance, the place that leaked.

5. The Logs You Need to Prevent Recurrence

5.1. The Minimum Set to Keep First

What worked in this investigation was not simply increasing log volume. It was methodically adding “information that lets you reach the cause later.”

At minimum, you want to keep the following.

Category Minimum fields wanted Reason
Operation context cameraId, sessionId, operationId, reconnectCount, phase To tie the event to which operation, on which iteration
Process resources handleCount, privateBytes, workingSet, threadCount To first isolate what is growing
Resource lifecycle action, resourceId, kind, osHandle, owner To trace the pairs of create/open and close/dispose
External call results win32Error, HRESULT, sdkError, timeoutMs To compare failure types later
State transitions OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart, etc. To know mid-which-phase things broke down
Execution environment pid, tid, buildVersion, machineName To correlate with dumps / symbols / deployed artifacts

We are not claiming this is sufficient. But without at least this, you easily end up with logs that record nothing more than the fact that “it crashed.”

5.2. The Logs We Actually Strengthened

In this case, we strengthened the logs in the following directions.

  1. Periodic heartbeat
    • Emit Handle Count / Private Bytes / Thread Count / ReconnectCount every 1-5 minutes
  2. Boundary logs per camera session
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. Resource lifecycle logs
    • Create/Open/Register and Close/Dispose/Unregister for events / threads / files / timers / SDK registration tokens
  4. Error normalization
    • Do not stop at the exception message; emit win32Error, HRESULT, sdkError, and phase together

What is important is to not change the shape of the logs between success and failure. If failures get a different format, aggregation later becomes painful.

The four log tracks we strengthenedShows that combining a periodic heartbeat, boundary logs per camera session, resource lifecycle logs, and error normalization, while keeping the same shape for success and failure, produces logs that let you reach the cause later.Periodic heartbeat (resource values)Logs that reach the causeSession boundary logsResource lifecycle logsError normalizationDo not change the shape between success and failure

Figure 16: Do not increase log volume; line up the four tracks you can cross-reference later.

5.3. At What Granularity to Collect

A common trap here is “just dump everything at INFO.” But if you do that, you end up facing a wall of logs when you read them later. That is quite painful.

In terms of granularity, roughly the following split is realistic.

  • Periodic monitoring
    • Handle Count, Private Bytes, Thread Count, ReconnectCount
  • Operation boundaries
    • Session start / done / fail
  • Resource boundaries
    • create/open/register and close/dispose/unregister
  • Failure details
    • Error codes, stacks, dump capture triggers

Detailed per-frame logging is usually unnecessary. For long-run defects, logs that let you read “which responsibility opened it, and which responsibility closed it” are far more effective.

How to split log granularityShows a split where periodic monitoring covers resource counts, operation boundaries cover session start and end, resource boundaries cover create and close pairs, and detail goes deep only on failures, avoiding the wall of logs that comes from dumping everything at INFO.Log granularityPeriodic monitoring: countsOperation and resource boundariesDeep detail only on failuresDump everything at INFOAn unreadable wall of logs

Figure 17: Rather than per-frame detail, aim for a granularity where you can read who opened it and who closed it.

6. A Rough Decision Guide

  • Crashes only after days to weeks
    • First add a heartbeat for Handle Count / Private Bytes / Thread Count
  • There are retries / reconnects / shutdowns
    • Build a harness first that hammers just those boundaries in volume
  • Heavy use of native SDKs / P/Invoke / Win32
    • Applying Application Verifier (Part 2) is well worth it
  • A GUI lives in the same process
    • In addition to Handle Count, also watch GDI Objects / USER Objects
  • The exception at the moment of the crash tells you nothing
    • It is faster to first put operation / session / resource lifecycle structured logs in order

That last item is quite important. In bug investigation, what decides the outcome is often not the analysis technique itself, but whether things are in an observable form.

7. Summary

For an app that only crashes after long-running operation, look at Handle Count, not just memory. Handle leaks tend to hide in the failure paths of abnormal flows rather than the normal path, and the crash site is usually the exit of a secondary failure, not the place that leaked. When it comes to reading the symptoms, it ultimately comes down to these three points.

For prevention, bring the responsibilities of create/open and close/dispose closer together, keep logs that carry context per session / operation, and record both process resources and resource lifecycles. In testing, instead of waiting for a month-scale repro, run timeout / reconnect / shutdown in short loops, and make “traceable when it breaks” - not just “does not break” - the acceptance criterion. What worked in this case was this combination. In Part 2, we use Application Verifier to surface hard-to-trigger failure modes such as memory exhaustion and handle anomalies ahead of time.

In control apps, the normal path working matters, but being able to tell “what happened” when things break counts for a lot in long-term operation.

Handle leaks are exactly the type of defect where that difference pays off. If you look at them through growth rates, boundaries, and responsibility pairs, rather than only at the moment they occur, they become considerably easier to chase.

Part 2: Building a Windows Failure-Path Test Foundation with Application Verifier

8. References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This case-study page shows a similar structure for diagnosis, prioritization, or redesign.

This article connects naturally to the following service pages.

Windows App Development

If you want to review how your Windows app is built, including logging design and operational observability, this also connects to our Windows application development consulting.

Frequently Asked Questions

Common questions about the topic of this article.

What is a handle leak?
It is when a Windows process forgets to close a handle it uses to reference an OS resource - an event, mutex, file, socket, and so on - so the Handle Count keeps climbing. The most common pattern is a resource opened temporarily for one operation and then left unclosed on a partial-failure path such as a timeout, a reconnect, or an early return. Routine short tests only exercise the success path, so the leak is easy to miss.
How do I tell a memory leak from a handle leak?
You watch different metrics. A memory leak shows up as Private Bytes and Commit creeping up, whereas a handle leak shows up as Handle Count creeping up and never coming back down. When isolating long-run issues, watching only memory leaves you driving with one eye closed, so the basic move is to watch Handle Count and Thread Count alongside it. If a GUI lives in the same process, watch GDI Objects and USER Objects too.
Why does a handle leak only crash the app after long-running operation?
A small-slope leak that loses just one handle per failure does nothing within minutes, but in 24/7 operation boundary conditions such as timeouts and reconnects happen over and over, and the leak accumulates over weeks. It finally surfaces as a secondary failure at the moment an API that creates a new event, file, or thread fails. It also matters that the crash site is usually the last victim, not the place that leaked.
How should I investigate a handle leak?
Rather than waiting for a month-scale reproduction, compress the repro by running the suspicious lifetime boundaries - open -> start -> stop -> close, timeouts, reconnects - thousands of times in a short loop. Establish a baseline after warm-up, look at the per-cycle delta and the slope of Handle Count, hunt for the place where create/open and close/dispose stop matching using structured logs that carry sessionId, resourceId, and action, and read the crash site last. That order makes it much harder to get lost.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog