System designTroubleshooting

Stop streaming hangs: drain the frame queue first

Why a streaming stop call hangs on undelivered frames, how to tell a slow drain from a queue deadlock, and the drain-stop-close order that releases the camera.

LumaSpec Vision10 min read Share on LinkedIn

On this page 8 sections
  1. 01What a stop call waits for
  2. 02Likely causes, most probable first
  3. 03The fix: drain, then stop, then close
  4. 04Worked example: draining a 4000 × 3000 mono stream at 30 fps
  5. 05When the queue is empty and stop still hangs
  6. 06Prevention checklist
  7. 07Matching the hardware to the loop
  8. 08Questions

You press the key to end a live view, the lines after cam.stop_streaming() never run, and the only way out is killing the process or restarting the kernel. If you write your own grab loops in Python for area scan cameras, this shutdown hang is almost never the stop call's fault: it is the frame queue behind it. A streaming SDK hands frames to your application through a queue, and a blocking stop waits until every queued buffer is returned before it releases the device. With no consumer running, that wait cannot end. This guide shows how the deadlock forms, ranks four likely causes with a test for each, gives the drain-stop-close order that fixes it, and works the numbers that tell a healthy drain from a hang.

What a stop call waits for

Start streaming and the camera writes frames by DMA into driver-owned buffers while the SDK passes them to your queue. Stopping has to unwind all of it. The Linux videobuf2 contract for the stop path is explicit: the stop sequence runs when streaming must be disabled, the driver stops the DMA transfers or waits for them to finish, and every buffer is given back. That return of all buffers is stated as the driver's obligation in the kernel's V4L2 videobuf2 documentation, so the unwinding cannot be skipped or shortened by an application.

That giving-back has an application-side half. In an asynchronous grab design, a delivery thread in the SDK moves completed frames onto your queue, and your loop takes them off. Many runtimes count a buffer as returned only after it has been delivered and dequeued, so the stop call blocks until that accounting completes.

The stop call is therefore not doing nothing while it hangs. It is waiting for the hand-back to finish, and the hand-back needs a consumer that has already exited. That is a structural deadlock, and no timeout inside the call resolves it. The same backlog shows up earlier in designs built on latest-image-only semantics, where stale duplicates pile up because nothing drains the queue (latest image only returns duplicate frames).

For the wider symptom tree — stops that hang for reasons other than a full queue — the asynchronous camera stop hangs guide walks the decision path.

Likely causes, most probable first

1. The grab loop exited with frames still queued — the most common cause. Your key handler breaks out of the loop, frames stay in the queue, and stop then waits on a consumer that no longer exists. A common tell is that a different grab example exits cleanly while your modified script freezes: the difference between them is the loop and the queue, not the camera. Test: after the stop flag is set, keep calling your retrieve function until it times out once, then call stop. If stop returns now, this was it.

2. The consumer is blocked, so delivery cannot proceed — the notebook case. In a Jupyter kernel the grab loop usually runs on the main thread, and delivering frames needs that same interpreter to service callbacks or a dequeue pump. Blocked inside the stop call, the interpreter cannot do either, so the freeze is total. Test: run the identical script from a plain terminal. If it exits there but not in the notebook, the queue logic is fine and the plumbing is the problem; move the grab loop into a worker process (the multiprocessing fix for a blocked camera loop).

3. Stop is called from the callback thread. A stop issued inside a frame callback waits for hand-back that can only proceed on that same thread. Test: log the thread identity at the stop call and inside the callback; if they match, move stop to the main thread.

4. The device or driver is wedged — rare. DMA never completes because the camera dropped off the bus mid-frame, typically on USB. Test: drain to an empty queue first. If stop still hangs and the device then vanishes from enumeration until a replug, the fault sits below your code, and the part that matters is stopping acquisition cleanly before any close (stop acquisition before you close).

The fix: drain, then stop, then close

  1. Signal, do not break. The key handler sets running = False (a Boolean, initialised True before the loop starts) and returns. The loop keeps running.
  2. Drain with the loop itself. Each pass retrieves with a short timeout — 0.1–0.2 s is comfortable at 30 fps — and discards what it gets. Exit only when one retrieve times out after running went false: that timeout is the proof the queue is empty. A frame pulled only to free its buffer should not be processed, and if callbacks queue references instead of copies it may be torn anyway (torn frames in a camera callback).
  3. Or flush instead of draining. If your SDK documents a queue-flush or buffer-revoke call in its buffer-management section, cancel the queued buffers explicitly; the stop path then has nothing left to wait for. The driver-side contract is the same either way: every buffer returned before streaming is disabled.
  4. Stop on the main thread. With the queue empty, the stop call has only DMA cancellation left, which finishes on its own in well under a second for the stream sized below.
  5. Close last. Release the device handle only after stop has returned. Closing earlier is its own crash class (camera SDK access violation on close).

In pseudocode — the call names are illustrative, your SDK's equivalents sit in its acquisition and buffer-management sections, and stop_streaming is the call that hung:

running = True            # bool, set False by the stop key handler
DRAIN_TIMEOUT_S = 0.2     # float, about 6 frame periods at 30 fps

while True:
    frame = cam.retrieve(timeout=DRAIN_TIMEOUT_S)  # None on timeout
    if frame is None and not running:
        break                                      # queue empty
    if running and frame is not None:
        process(frame)

cam.stop_streaming()  # returns in well under a second now
cam.close()           # last, never earlier

The test that proves the order: run the script both ways. With the old break-then-stop order the process hangs at the stop line; with the drain order, stop returns inside the bound worked out below and the interpreter exits on its own — from a terminal and from a notebook cell.

Worked example: draining a 4000 × 3000 mono stream at 30 fps

Inputs: width 4000 pixels, height 3000 pixels and frame rate 30 fps come straight from the data sheet of our 12 MP USB3 camera; bytes per pixel is 1, from the 8-bit mono format (Mono8) the capture uses; queue depth is 10 buffers, the depth configured when the stream started.

Rule in words: bytes per frame = pixels × bytes per pixel; stream rate = bytes per frame × frame rate; queue fill time = queue depth ÷ frame rate. A healthy stop costs about one queue-fill time plus DMA cancellation.

queue_fill_s = queue_depth / frame_rate stream_rate_MBps = width_px × height_px × bytes_per_px × frame_rate / 1000000

Arithmetic:

  • 4000 × 3000 = 12,000,000 pixels; × 1 byte = 12,000,000 bytes ≈ 12 MB per frame.
  • 12 MB × 30 fps = 360 MB/s of pixel data before protocol overhead.
  • 10 buffers ÷ 30 fps ≈ 0.33 s to fill the queue, and 10 × 12 MB = 120 MB sitting in it.
  • Drain bound ≈ 0.33 s of delivery plus cancellation: call it under 1 s.

Result and decision: for this stream, a stop that has not returned within about 1 s is not a drain, because the queue only holds 0.33 s of frames — treat 2–3 s of silence as a deadlock and fix the order instead of waiting longer. What moves the bound: queue depth and frame rate scale it linearly, so 100 buffers at 30 fps stretches it to 3.3 s, while a 16-bit format doubles each frame to 24 MB and the rate to 720 MB/s without changing the frame count, leaving the verdict unchanged.

When the queue is empty and stop still hangs

Three checks cover the remainder:

  • Wrong thread. Stop issued from a worker that holds a lock the delivery path needs waits on itself. Log the thread identity at stop, at the callback and at close, and keep all three on the main thread.
  • State left over from the previous run. Flaky stops that follow whatever the last run did disappear when every run sets exposure, pixel format and transport explicitly before streaming starts — initialization order at start-up matters for the same reason shutdown order matters here (set manual exposure before streaming).
  • Acquisition never came off cleanly. Some runtimes require acquisition stopped before the handle is released, and the next open then times out — the reopen-timeout class covered in the USB camera article linked above.

If stop still hangs with the queue verifiably empty and the threads correct, capture the drain-stop-close log lines and contact support with them: that is the boundary between your code and the driver.

Prevention checklist

  • Know your queue. Set its depth to what your consumer sustains at your frame rate: at 30 fps, 10 buffers is a third of a second of slack, no more.
  • Never break out of a grab loop. Set the flag, fall through the loop, and let the empty-queue timeout end it.
  • Retrieve with timeouts everywhere. A bare blocking get is a hang waiting for its first stalled frame.
  • One thread for stop and close, and never inside a callback.
  • Log around each stage. One line before and after drain, stop and close; the gap in the log names the stage that wedged.
  • Keep the app alive after the stream ends. Stopping the grab should not tear down the program (stop a camera stream in Python without closing your app).

Matching the hardware to the loop

If the hang traced back to the run-time rather than the order — several cameras sharing one underpowered box, delivery starved while the CPU churns — the fix is headroom. The vision controllers category covers fanless boxes with multiple independent network ports, built-in light channels and PoE camera ports; the camera finder and the system configurator match the rest of the chain. More grab-loop and shutdown fixes sit under the systems topic.

Questions

Why does the hang only appear in a notebook and not from the terminal?

In a notebook the grab loop usually runs on the kernel's main thread, and the SDK's delivery needs that same interpreter to service callbacks or run a dequeue pump. Blocked inside the stop call, it can do neither. The identical script exits from a terminal because the pump thread runs freely, which points at the notebook plumbing rather than the queue; a worker process fixes it.

How long should a healthy stop take?

About one queue-fill time plus DMA cancellation. At 30 fps with a 10-buffer queue that is 10 ÷ 30 ≈ 0.33 s of delivery, under a second in total. The bound scales linearly with queue depth and frame rate: 100 buffers at 30 fps is about 3.3 s. A stop blocked well past its own bound, with a consumer that has stopped retrieving, is deadlocked rather than slow.

Does the pixel format change the drain time?

Only through bytes per frame, not through the deadlock. Mono8 is one byte per pixel, so a 4000 × 3000 frame is about 12 MB; a 16-bit format doubles that to 24 MB per frame and roughly 720 MB/s at 30 fps. The queue still holds the same 10 frames and the queue-fill time stays 0.33 s, so the deadlock verdict does not move.

Can I call the stop function from inside my frame callback?

No. The stop call then waits for buffer hand-back that can only proceed on the same callback thread, so it waits on itself. Set a flag from the callback, let the loop drain on its own thread, and issue stop and close from the main thread. Logging the thread identity at the stop call and inside the callback confirms the mismatch in seconds.

What timeout should the drain loop's retrieve call use?

A few frame periods: 0.1–0.2 s is comfortable at 30 fps, whose frame period is 33 ms. Short enough that the empty-queue exit is prompt, long enough that a frame still in flight is not mistaken for an empty queue. Exit when one retrieve times out after the stop flag is set; that single timeout is the proof the queue is empty.