Your inspection software runs clean for six hours, then dies with a heap corruption exception, and every dump points into the vision library's DLLs. You have audited the code, kept the tools on one thread, and stripped features — and it still crashes, now sometimes within ten minutes. This guide is for anyone running managed code over a native vision SDK on a multi-camera line. You will learn why the stack trace names the wrong module, why a bug this rare survives single-part testing, which five causes to eliminate in order, and the buffer ownership rule that ends this class of crash.
Why the stack trace names the wrong code
A heap corruption is not an error at the moment it happens. It is damage discovered later. Something writes past a buffer or frees a block twice; nothing fails; the next allocation or free that walks the damaged heap header kills the process. The stack in your dump belongs to whoever touched the corrupted block last — the victim — not to whoever damaged it. That is why every dump lands inside the vision tools, and why the landing spot moves whenever you shuffle your own code.
Windows reports the damage as exception 0xC0000374, STATUS_HEAP_CORRUPTION; the 0xC0000374 heap-corruption walkthrough covers what the debugger makes of it. Your WinDbg signature — sometimes a double free, sometimes something else — narrows the field to one family of bugs. No unsafe C# runs in your process, so the only native memory you can free twice is a buffer that crosses into the native vision tools. One release happens early, on your side of the boundary; the library releases the same pointer again at teardown.
So treat the library as innocent while buffer ownership is unproven: a premature free on the managed side produces exactly the dumps you are collecting.
Five causes, most likely first
Work down this list in order. Each item has a tell and a test.
- A buffer freed while a tool still holds it. The handler copies the frame into native memory (or passes a fixed pointer), starts the tool job, and returns. The tool keeps that pointer for the job's lifetime. A Dispose call, a finalizer, or a fixed pointer whose scope ended with the handler frees it mid-job, and the tool's own teardown frees it again. Tell: a double free in the dump, crashes that move around the library between builds. Test: apply the ownership rule below; if the crashes stop, this was it.
- The wrong allocator doing the free. Your code allocates with one heap (Marshal.AllocHGlobal, new, or a pooled array) and something frees the pointer with another. Or the reverse: you free a pointer the library handed you, instead of calling the library's own free or dispose function. Heaps do not mix. Tell: crashes only on paths where your code allocates or frees. Test: build a table of every native pointer — who allocates it, who frees it, with which call — and fix every row where the freer differs from the allocator. The rule from the Windows heap functions reference is plain: memory allocated from one heap must be freed by that same heap, never across boundaries.
- Disposal while jobs are in flight. A recipe change, a disconnect, or shutdown disposes a tool object while jobs are queued. The in-flight job writes its results into memory that is already gone. Tell: crashes cluster at stops, changeovers, and network drops — moments your steady-state tests never exercise. Test: count pending jobs before every dispose; refuse to dispose until each job has reported done or failed, or has been cancelled through the SDK's own cancellation path.
- Failure paths that own memory differently. Rejected parts run code that accepted parts never touch: record save, image archive, re-run, reject handshake. If one of those branches frees early or shares a scratch buffer across concurrent jobs, you get exactly what you observed — crashes tracking the failure rate, and a new product with a new failure mix crashing in minutes. Tell: crash rate follows reject rate. Test: force 100% failures on the bench with every logging feature on; it is the fastest reproducer you have.
- Heat — real, but the wrong suspect for this crash. At 95 °F ambient with cameras above their rated operating temperature you will see sensor noise, dropped frames, and disconnects. Those are device-side faults: they arrive as I/O errors and bad images, never as 0xC0000374 inside your process heap. Fix the cooling as its own work item, and take it off the list of causes here.
Crash budget for a 7-camera line at 450 parts per minute
Inputs, and where they come from: 7 cameras, 450 parts per minute at the top of its range, up to 3 inspections per image, and 10 minutes to 4 hours between startup and the first crash on the current build — the numbers of a real line in this class. The rule: count the inspection executions that fit inside each observed survival window, then ask what trigger rate each count allows.
Arithmetic. At the top rate, 7 × 450 ÷ 60 = 52.5 images per second, and 52.5 × 3 = 157.5 inspections per second — call it 158. At the bottom of the range, 7 × 300 ÷ 60 = 35 images per second, 105 inspections per second. The 100,000+ bottles of a 6-7 hour run checks out too: 6 h × 60 min/h × 300 parts/min = 108,000 at the low rate.
Survival windows. A crash after 10 minutes means 600 s × 157.5 = 94,500 executions completed clean before the heap died. A crash after 4 hours means 14,400 s × 157.5 = 2,268,000 executions.
Result and decision. The heap survives between roughly 95,000 and 2,268,000 inspection executions. Set trigger rates against that. A defect on every part fires within the first second at 158 per second; a 1-in-1,000 trigger fires after 1,000 ÷ 157.5 ≈ 6.3 s; anything more frequent than 1-in-10,000 fires within 10,000 ÷ 157.5 ≈ 63 s, far short of the observed 10-minute minimum. A 1-in-a-million trigger needs 1,000,000 ÷ 157.5 ≈ 6,349 s ≈ 1.8 h, which sits inside your window. So the defect is rarer than once per 10,000 executions and almost certainly order- or timing-dependent: a failed interleaving, not a failed part. Stop reviewing the happy path; test failure paths and shutdown races, because that is where interleavings go wrong.
The ownership rule: one owner, freed once, after the callback
The rule, in five lines:
- Allocate one native copy of each frame per job, and copy the image in before the tool job starts — the same reflex as copying the buffer before you queue it.
- Keep exactly one strong reference to that copy, keyed by job id, from hand-off until the inspection's done or failed callback fires.
- Free it exactly once, inside the callback, with the allocator that created it. Never free a pointer the library handed you — call the library's own free or dispose function, as in the SDK buffer copy pattern.
- Drain on the way down: stop acquisition, let every pending callback fire or be cancelled, dispose the tool objects, then free what remains.
- Give disconnects and timeouts the same treatment. Idle-time disconnects have their own failure pattern; either way, a camera that vanishes mid-job means its pending entry waits for shutdown, not a free on a timer while the tool still holds the pointer.
// Illustrative names - the pattern is the point, not these identifiers.
sealed class Job
{
public int Id; // your job id, starts at 1
public IntPtr Buffer; // native memory your allocator owns
public int Bytes; // frame size in bytes
}
readonly Dictionary<int, Job> pending = new Dictionary<int, Job>();
void FrameArrived(Frame frame) // camera callback
{
Job job = new Job { Id = nextId++, Bytes = frame.Bytes };
job.Buffer = AllocJobBuffer(job.Bytes); // your allocator
CopyTo(frame, job.Buffer, job.Bytes); // copy in
pending[job.Id] = job; // one strong owner
RunInspection(job.Id, job.Buffer, job.Bytes); // native tool job
}
void InspectionFinished(int jobId, bool passed) // done AND failed paths
{
if (pending.TryGetValue(jobId, out Job job))
{
pending.Remove(jobId);
FreeJobBuffer(job.Buffer); // your allocator, once
}
}
Pinning instead of copying is fine when the native call consumes the pointer before your method returns: hold a GCHandle or a fixed pointer across the call. When the tool signals completion later, the pinned handle must live until that callback — the same discipline, a different mechanism. If a wrapper's lifetime depends on GC timing at all, the design is wrong; the frame memory lifetime in managed code article fixes the same trap in its calmer form.
Buffer pooling still works under this rule: reuse buffers instead of per-frame allocation, but register every checkout in the pending map and release it in the callback, so ownership never depends on which frame arrived when. The map also tells a leak apart from a corruption. Entries that grow without bounds mean a callback path never frees — the inspection-loop leak diagnosis case — while entries that vanish early mean this article's bug.
Testing the fix without stopping the line
Leave production on the last known-good build and take the search to a bench that replays recorded frames — failures included — through the same executable and tool configurations, feeding it files instead of live sensors. Enable full PageHeap for the process and the tool DLLs. You found it too slow in production, and it is: guard pages on every allocation cannot survive 158 inspections per second. On a replay bench speed is irrelevant, and that slowness becomes the point — PageHeap breaks inside the first bad free instead of letting the process stagger on for hours and crash in an innocent tool.
Bench pass criterion: zero PageHeap breaks across at least 2.3 million inspection executions — more than the longest run you have seen in production — then one full shift at 100% forced failures with every logging and archive feature enabled, then repeated stop/start and cable-pull cycles to exercise the drain path. If PageHeap catches a break, the stack names the guilty free directly, and the dump finally points at the culprit instead of the victim.
Heat is a real problem, not this suspect
The factory's 95 °F is (95 - 32) × 5 / 9 = 35 °C, and cameras running above their rated maximum will degrade: dark current roughly doubles every 6-8 °C in a typical sensor, lifting noise and false rejects, and prolonged thermal stress shortens the electronics' life. None of that writes into a process heap. Handle cooling as its own line item — airflow across the camera bodies, and headroom between 35 °C ambient and each camera's rating — and expect it to fix image quality, not the crash.
Hardware decisions: bench, production, or on-camera
The choice in front of you is where inspections run. Keep them on the PC and the ownership rule above is mandatory; move them onto the camera and the PC never owns an image buffer at all, at the cost of rebuilding tool configurations on new hardware. Fixing the PC build is days; migrating inspections is a project that buys recurrence-free operation. Pick per line.
For the staging bench, a box that replays frames needs fewer ports and less peak compute than the line: the MV-VC3301H-128G66 carries 8 GB of RAM, a 128 GB SSD and an Intel Core i3.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- Light outputs
- 4 built-in channels
- Memory
- 8 GB
For production, size for the seven-camera network and image logging: the MV-VC5525-256G70-NN pairs 32 GB of RAM and a 256 GB SSD with PoE camera ports and 5+ LAN ports.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- PoE
- PoE camera ports
- Light outputs
- 8 built-in channels
The structural alternative is on-camera inspection, where no image buffer ever enters the PC process to be freed: the MV-SC5060XM-08M-WBN is a 6 MP mono GigE smart camera with an M12 mount that runs its inspections on the device.
- Interface
- GigE
- Resolution
- 6-10 MP
- Sensor format
- 1/2.4"
- Shutter
- Rolling
Both ranges are worth a pass before you commit: smart cameras for on-device inspection, vision controllers for PC-side builds.
Checks before you redeploy
- One owner per native buffer, tracked in a pending map keyed by job id.
- Freed exactly once, in the done and failed handlers, by the allocator that created it.
- Library-owned pointers freed only through the library's own free or dispose call.
- Drain before dispose: every pending job resolved, then tools disposed, then buffers freed.
- PageHeap bench run: zero breaks over at least 2.3 million executions, plus one full shift of forced failures.
- Drain exercised with stop/start loops and a pulled camera cable.
- Camera cooling fixed as a separate work item; 35 °C ambient stays on the action list.
- Production stays on the last known-good build until every bench criterion passes.
If the crash budget above describes your line — seven cameras and change at hundreds of parts per minute — scope the replay bench and the production controller around those numbers. Send your frame sizes, inspection counts, and dump summaries through the contact page and we will help you size the build. The systems topic page collects the rest of the software-side guides.
Questions
Does calling GC.Collect regularly prevent heap corruption?
No. Collection timing only changes when a finalizer or dispose runs; it cannot stop a buffer from being freed while a tool still holds its pointer. Your own tests already showed it: crashes occurred with manual collection, without it, with Dispose everywhere, and with Dispose removed — because the free comes from the wrong owner or the wrong allocator, not at the wrong moment. Fix ownership deterministically and leave the GC alone.
Can heap corruption produce wrong inspection results before the crash?
Yes, and this is the operational risk people miss. Overwritten memory can corrupt result values, scores, or records, so a build in this state can pass bad bottles or reject good ones for hours before it finally dies. Treat any machine that has thrown this exception as untrusted until the fix passes the PageHeap bench criteria: quarantine its output, re-inspect anything it accepted in the meantime, and only then return it to final accept/reject duty.
Why does the crash move to a different module after every code change?
The stack names the allocator that found the damage, not the one that caused it. Any change to your code reshuffles allocation order, so a different allocation lands on the corrupted block next and the blame migrates — sometimes into the vision tools, sometimes elsewhere. Chase the pointer instead of the stack: for every native buffer, record who allocates it, who frees it, and when it may be released relative to the done callback.
Should I enable full PageHeap on the production machine?
No. Full PageHeap gives every allocation its own guard page and reallocates blocks on every free, multiplying allocation cost until a line doing about 158 inspections per second cannot keep up — which you already saw. Run it on the replay bench, where cycle time does not matter, and let production stay on the last known-good build until the bench run passes.
Why did the new bottle line crash faster than the old one?
Two changes landed at once: a new bottle and two new computers. The new bottle brought a new reject mix, so failure-path code that rarely ran before now runs constantly — and that is exactly where a branch-specific free lives. New computers also shift memory layout and thread timing, which moves the interleaving that triggers the corruption. A faster crash is consistent with a rare, order-dependent defect, not with a part-specific or thermal one.


