System designTroubleshooting

Camera SDK hangs on exit: shut down streams in the right order

Your camera app streams fine but hangs on exit: SDK objects destroyed in the wrong order. The shutdown sequence that fixes it, plus a test for each cause.

LumaSpec Vision10 min read Share on LinkedIn

On this page 11 sections
  1. 01The answer: this order, and never std::exit
  2. 02Cause 1: std::exit destroys statics under live threads
  3. 03Cause 2: a frame callback is still inside the SDK
  4. 04Cause 3: crossed lifetimes between context, device and pipeline
  5. 05Cause 4: the SDK's own static logger race
  6. 06Which devices this covers
  7. 07Worked example: a shutdown budget for one network depth camera
  8. 08Quick tests, cause by cause
  9. 09Prevention checklist
  10. 10When the order is still not enough
  11. 11Questions

Your app streams a depth camera cleanly for hours, then locks solid the moment it closes. That hang lands on nearly every C++ integration of a camera SDK — network depth cameras, GigE area scan cameras, smart cameras on a GUI loop — and it is not random. An exit-time deadlock is a lifetime bug: something static inside the SDK is destroyed while one of its worker threads still runs. This guide gives the shutdown order that fixes it, the four causes in order of likelihood, a five-minute test for each, and a worked shutdown time budget you can lift into your own app. If the hang hits mid-run while you stop a stream and keep the process alive, start with troubleshooting asynchronous camera stop hangs; this article is about the exit path.

The answer: this order, and never std::exit

Two moves fix most exit hangs. First, delete the std::exit call and return from main, or let your framework's exit event run to completion — std::exit is the most common single trigger. Second, tear the SDK down in the reverse of the order you built it:

create: context → device → pipeline → filter destroy: filter → pipeline → device → context

In a live app that reads as six steps, each with its own log line:

void shutdown() {
    pipe->stop();            // 1. stop streaming
    drainCallbacks(200ms);   // 2. let in-flight callbacks return
    cloudFilter.reset();     // 3. filters built from the pipeline
    pipe.reset();            // 4. the pipeline
    device.reset();          // 5. the device
    ctx.reset();             // 6. the context, always last
}

The mid-run case — stop now, restart later, keep the process — is a different path: stop a camera stream without closing your app walks that one.

Cause 1: std::exit destroys statics under live threads

std::exit runs atexit handlers, destroys objects with static storage duration, and ends the process. It never unwinds the stacks of your other threads. Those threads keep running while the statics they depend on — the SDK's logger, its device registry, its thread pool — are pulled out from under them. A worker thread that tries to log one final device-closed line locks a mutex whose owner was just destroyed, and the process hangs inside a destructor nobody wrote.

This is why an app that works great until the close is the signature of the bug: nothing static has been destroyed while you stream, so the race exists only during teardown.

Test it in one minute: comment out the std::exit call, return from main with the same teardown sequence, and watch the process end. Keep that path. The fix for this cause is the return, not a sleep in front of the exit call.

Cause 2: a frame callback is still inside the SDK

Two callback mistakes deadlock at teardown. Destroying the pipeline from inside a frame callback makes the SDK wait for the callback to finish while the callback waits for the destructor — a lock cycle on a single thread. And a callback that captured frames in shared pointers keeps those frames alive after you reset the pipeline, so the pipeline's destructor waits on objects that cannot die until the callback returns.

Stop first, then drain. Set an atomic shutting-down flag, make the callback return immediately when the flag is set, and wait on a condition variable with a 200 ms timeout until the callback reports exit. Reset the pipeline only after that. The same rule keeps acquisition alive at runtime: streaming callbacks that stop firing covers the runtime side.

Cause 3: crossed lifetimes between context, device and pipeline

The SDK builds top-down: the context creates the device, the device feeds the pipeline, the pipeline creates the filters. Destruction must run bottom-up. Cross the lines — a context declared after the pipeline, a filter that outlives its pipeline, a static that holds the device — and some destructor waits on an object that is already gone.

C++ destroys members in reverse declaration order, so declare them in creation order and the compiler runs the teardown for you:

std::shared_ptr<Context>   ctx;     // declared first, destroyed last
std::shared_ptr<Device>    device;
std::shared_ptr<Pipeline>  pipe;
PointCloudFilter           cloud;   // declared last, destroyed first

Hold the context, the device and the pipeline as members for the app's whole life. The config object and the stream profile list can be locals in setup: once the pipeline has started it holds what it needs, and you keep the profile only when you reconfigure mid-session. That is the whole answer to which objects must live and which are setup-only: the object graph decides, not convenience.

Cause 4: the SDK's own static logger race

Some exit hangs are not in your code. Many camera SDKs route every log line through a registry — a static singleton behind a lock, destroyed during static teardown. A worker thread still emitting log lines while that singleton dies deadlocks inside the SDK, usually after your own objects are already gone. Vendors patch this class of bug; one depth-camera SDK shipped the fix in a minor release, and the hang disappeared on the upgrade alone.

Two tests isolate it. Run the app with streaming never started — create the context and device, destroy them, exit — and a hang there sits inside the SDK, because none of your streaming code ran. When the hang hits, break all threads in the debugger: stacks parked inside logging code, with your own stacks idle, point the same way.

The fix is a version number, not a code change: update the SDK to the current release and rerun the shutdown test. If the API exposes a log level, drop it to errors before teardown so fewer threads touch the registry at the worst moment. Version drift bites at both ends of a session — camera SDK build mismatch covers the runtime end.

Which devices this covers

The pattern is API-level, not sensor-level. Any SDK that spawns worker threads can deadlock this way: the network depth and stereo devices in the 3D sensors category, area scan cameras streaming to a host, smart cameras pushing frames to a PC. The order rule is identical for all of them, and the camera finder lists every class by interface and type. Exit hangs show up most on network depth cameras, because teardown adds a device-side close handshake on top of the local thread shutdown:

3D sensorsHikrobot MV-DLS300P-04 Laser galvanometer stereo cameraMV-DLS300P-04
Type
Stereo / structured light
$16,579.00 · quotedView and quote
3D sensorsHikrobot MV-DT01WMU ToF sensing cameraMV-DT01WMU
Type
Depth / ToF camera
Price on requestView and quote

Worked example: a shutdown budget for one network depth camera

Numbers make the sequence testable. Take a host app streaming one depth camera over a LAN at 30 fps, closing on a user key, with a 100 ms timeout already passed to the frame wait call in the update loop. Each budget line carries its basis:

  • Pipeline stop: 1,000 ms. A healthy stop on a LAN finishes in well under 100 ms; ten times that covers retries and a firmware flush.
  • Callback drain: 200 ms. One frame period at 30 fps is 33 ms; the rest covers worst-case callback work and scheduler jitter.
  • Filter reset: 50 ms. A point-cloud filter holds no device resource; this is memory release.
  • Device close: 500 ms. Several network round trips plus the emitter shutdown on the device side.
  • Context reset: 500 ms. Tears down the SDK's thread pool and logger.
budget = 1000 + 200 + 50 + 500 + 500 = 2250 ms ≈ 2.5 s watchdog = 2 × budget ≈ 5 s

Set the watchdog at 5 s — twice the budget. A teardown that passes 5 s is deadlocked, not slow: dump every thread's stack from the watchdog, then abort. One safety note belongs here: if the device projects a laser pattern, treat the emitter as active after any forced abort until the device reports idle, and keep eyes out of the output path.

Quick tests, cause by cause

Work the table top to bottom; the causes sit in order of how often each one is the culprit.

What you see Likely cause Five-minute test Fix
Hangs only when std::exit is called Exit path Replace the call with return from main Return, ordered teardown
Log shows streams stopped, then silence Static logger race in the SDK Create and destroy context and device with no streaming Update the SDK
Debugger stacks sit inside a frame callback Live callback at destruction Unregister the callback, wait 200 ms, retry Drain before destroy
Hang moves when you reorder class members Crossed lifetimes Declare members context, device, pipeline, filter Keep creation order
Hang appears only with two or more devices Shared context closed too early Stop all pipelines, close devices one by one Context last, one close at a time

Prevention checklist

  • One shutdown function, called from one place, with a log line after every step.
  • SDK members declared in creation order: context, device, pipeline, filter.
  • Return from main; no std::exit anywhere in an app with threads.
  • Callbacks drained with a timed wait before the pipeline is reset.
  • A 5 s shutdown watchdog that logs stacks and aborts.
  • SDK version pinned, shutdown test rerun after every upgrade.
  • The same order discipline at start-up: set manual exposure and every camera setting before streaming starts, because settings racing a live pipeline fail the same way.

When the order is still not enough

Three cases need more than the six steps. With several devices, stop every pipeline first, then close the devices one at a time, and destroy the context once at the end; per-device threads must all be idle before the shared pool dies. In a GUI app, run the whole teardown inside the framework's exit event, before the window and graphics context are destroyed — releasing camera textures after the GPU context is gone is its own deadlock. And when a stopped app refuses to come back rather than exit, that is a restart fault: diagnosing camera stream restart timeouts is the guide for it.

Cleared all four causes and still hung? Break all threads, save the stacks and the shutdown log, and send them through the contact page. A stack trace with a step-numbered log closes vendor tickets in days instead of weeks.

Questions

Should I hold the device as a shared_ptr, or let it go after building the pipeline?

Hold it as a member, next to the context and pipeline. Some SDKs keep their own reference inside the pipeline, so yours is redundant for streaming, but keeping it costs nothing and gives you an explicit close: reset the pipeline first, then the device, then the context, all in one place you control. Letting the device go after setup works only until you need to reconfigure or reopen a stream, and then you are rebuilding objects mid-session.

Is a watchdog kill of the process acceptable in production?

As a last line, yes: the operating system reclaims the process's memory and sockets. Two costs follow. A network device that never received a stop keeps its last state until its own timeout, so the next connect takes longer. And if the device projects a laser pattern, assume the emitter stays active until the device reports idle, and keep faces out of the output path. Fix the teardown order; keep the kill for failures only.

Why does returning from main work when std::exit deadlocks?

Returning from main unwinds the stack: your application object and its members run their destructors in reverse declaration order, so the pipeline dies before the device and the device before the context, all while your threads are already stopped. std::exit skips that unwind. It destroys only static-storage objects and runs atexit handlers, while your other threads keep running and the SDK's statics — its logger, its registries — vanish underneath them.

How long should I wait for in-flight frame callbacks before destroying the pipeline?

One frame period plus the callback's worst-case work. At 30 fps a frame arrives every 33 ms; a callback that copies a depth frame and updates a texture finishes in a few milliseconds, so a 200 ms timed wait covers both plus scheduler jitter. If the wait times out, log it and destroy anyway — a leaked frame is a better failure than a shutdown that never ends.

Can I run the teardown from a Ctrl+C signal handler?

No. A signal handler may only call async-signal-safe functions, and SDK calls, mutexes and logging are not on that list. Set an atomic flag in the handler, check it at the top of your update loop, and run the full teardown sequence on your normal thread. This also keeps start and stop on the same thread, which removes a second class of races around pipeline state.