The number
Measured on scanner-node-0, 12 Aug 2026, ~23:00 EEST, immediately after a battery exchange.
POST /capture, back to back, nothing in between:
1 -> 200 in 1.85s
2 -> 200 in 37.32s
3 -> 200 in 37.38s
Flat at ~37 s, not growing — so this is a fixed timeout being waited out, not something accumulating.
At 1.8 s a frame a 300-page book is ~15 minutes of capture per camera. At 37 s it is five hours. This is a throughput-fatal defect, not a nuisance.
It is also new. This afternoon ten consecutive captures ran at ~1.8 s each; three ran at 1.7 s at 22:37. The change is tonight, and the only physical event between the two is the battery going flat and being swapped.
It is not our code
Same method that exonerated the node during the DMF fault: stop the service and go to the CLI.
Three frames in one gphoto2 process (session stays open — the opposite of our session_per_capture):
Saving file as /tmp/multi_1.arw
ERROR: Could not capture image.
ERROR: Could not capture.
Saving file as /tmp/multi_2.arw
three frames, one session: 39.05s
Three separate gphoto2 processes (session per capture, like the node):
separate capture 1: 38.43s
separate capture 2: 2.26s
So it reproduces with our service stopped, under both session strategies, and one capture in two fails outright with Could not capture image before a retry succeeds. The node's capture_with_retry is the only reason this surfaces as a slow 200 rather than a 503 — which is worth saying plainly: our recovery is currently hiding the fault and reporting success. A run would look healthy at a fifth of the throughput.
What it is not
Ruled out by measurement, not by argument:
- Not our session strategy. One-session and session-per-capture stall identically.
- Not the body's mode settings. Checked after the swap:
focusmode: Manual, capturemode: Single Shot, expprogram: M. The DMF fault from earlier tonight is genuinely fixed and is not this. (This was my first hypothesis — that _enforce_capture_prerequisites() runs in connect() but not on the reconnect() path a battery swap takes, so a reset body would come back in DMF unnoticed. The gap in the code is real and worth closing regardless, but it is not the cause here, because the body is in Manual.)
- Not files accumulating on the camera.
gphoto2 --list-files → There is no file in folder '/'.
- Not USB autosuspend. The udev rule pins
power/control=on, and usb_resets stayed at 1 throughout.
- Not the
/status polling in the test harness — removing it changed nothing.
The leading hypothesis
The body's volatile buffer, which is the hazard PR #9's duplicate guard was built against, showing up as a throughput failure instead of a correctness one. libgphoto2's own source predicts the mechanism:
"It may even accumulate so we will get images from history. Camera on-off does not delete RAM. Just USB reconnection helps."
The alternating fast/slow pattern fits: a capture leaves something in body RAM, the next capture collides with it and waits out a timeout that clears it, the one after is clean.
This is testable and untested. If it is right, usb_reset() should restore the fast path, and the alternation should resume from there. That is the next measurement, and it decides everything: if a re-enumeration fixes it we have a workaround we already own; if it does not, the cause is elsewhere.
Worth checking in the same sitting
- Whether a cold body power cycle (not just a USB reset) clears it — the state was introduced by a power event, so it may be clearable by one.
- Whether the ~37 s matches a specific libgphoto2 timeout constant, which would name the wait exactly rather than by its duration.
- Whether it survives a reboot of the Pi. If it does, the state lives in the body.
Related
The number
Measured on
scanner-node-0, 12 Aug 2026, ~23:00 EEST, immediately after a battery exchange.Flat at ~37 s, not growing — so this is a fixed timeout being waited out, not something accumulating.
At 1.8 s a frame a 300-page book is ~15 minutes of capture per camera. At 37 s it is five hours. This is a throughput-fatal defect, not a nuisance.
It is also new. This afternoon ten consecutive captures ran at ~1.8 s each; three ran at 1.7 s at 22:37. The change is tonight, and the only physical event between the two is the battery going flat and being swapped.
It is not our code
Same method that exonerated the node during the DMF fault: stop the service and go to the CLI.
Three frames in one
gphoto2process (session stays open — the opposite of oursession_per_capture):Three separate
gphoto2processes (session per capture, like the node):So it reproduces with our service stopped, under both session strategies, and one capture in two fails outright with
Could not capture imagebefore a retry succeeds. The node'scapture_with_retryis the only reason this surfaces as a slow 200 rather than a 503 — which is worth saying plainly: our recovery is currently hiding the fault and reporting success. A run would look healthy at a fifth of the throughput.What it is not
Ruled out by measurement, not by argument:
focusmode: Manual,capturemode: Single Shot,expprogram: M. The DMF fault from earlier tonight is genuinely fixed and is not this. (This was my first hypothesis — that_enforce_capture_prerequisites()runs inconnect()but not on thereconnect()path a battery swap takes, so a reset body would come back in DMF unnoticed. The gap in the code is real and worth closing regardless, but it is not the cause here, because the body is in Manual.)gphoto2 --list-files→There is no file in folder '/'.power/control=on, andusb_resetsstayed at 1 throughout./statuspolling in the test harness — removing it changed nothing.The leading hypothesis
The body's volatile buffer, which is the hazard PR #9's duplicate guard was built against, showing up as a throughput failure instead of a correctness one. libgphoto2's own source predicts the mechanism:
The alternating fast/slow pattern fits: a capture leaves something in body RAM, the next capture collides with it and waits out a timeout that clears it, the one after is clean.
This is testable and untested. If it is right,
usb_reset()should restore the fast path, and the alternation should resume from there. That is the next measurement, and it decides everything: if a re-enumeration fixes it we have a workaround we already own; if it does not, the cause is elsewhere.Worth checking in the same sitting
Related
eadefca; the duplicate guard that anticipated the mechanism is4440db8. Both in Survive the cable, the power cycle and the battery swap #9./meteronraw_image_visible) is unaffected.docs/throughput-measuredis a branch name that now carries a question mark.