calibrate-serve: session stuck in `finalizing` forever when duration_s expires with frames < min_frames — no API escape
Summary
In calibrate-serve (wifi-densepose-cli), if a calibration session's duration_s expires while frames_recorded is below the requested min_frames, the session enters state: "finalizing" and never leaves it. /status reports finalizing with a growing eta_s indefinitely, POST /stop answers as if no session exists, and no further POST /start can run — the only recovery is restarting the process.
Environment
wifi-denseposeCLI built from thev2051tag (crates/wifi-densepose-cliunmodified),calibrate-servemode, one instance per node behind a UDP fan-out- 5× M5Stack Atom S3 Lite (
esp32-csi-node) streaming UDP CSI, HT20 tier (52 subcarriers)
Reproduction (deterministic, 5/5 instances)
Request min_frames above what the window can deliver and let duration_s expire:
POST /api/v1/calibration/start {"tier":"ht20","duration_s":300,"min_frames":2400,"room_id":"test"}
At ~3.3 valid HT20 frames/s, 300 s yields ~1000 frames < 2400. Observed on five independent instances simultaneously (same start, same wall-clock):
| instance | elapsed_s | frames_recorded | state | eta_s (growing) |
|---|---|---|---|---|
| 1 | 300.10 | 1042 | finalizing | 391 → … |
| 2 | 300.10 | 996 | finalizing | 423 → … |
| 3 | 300.09 | 955 | finalizing | 454 → … |
| 4 | 300.08 | 1014 | finalizing | 410 → … |
| 5 | 300.06 | 606 | finalizing | 888 → … |
All five stayed in finalizing until the processes were killed. POST /stop on a stuck instance returns the no-active-session response. First hit this on 2026-08-11 with a single instance; today's run reproduced it 5/5.
(Sessions that reach min_frames finalize and persist fine — same setup banked clean baselines minutes earlier with min_frames: 240.)
Mechanism
Three layers line up (paths/lines against v2051):
wifi-densepose-signal/src/ruvsense/calibration.rs:532—BaselineRecorder::finalize()correctly returnsErr(CalibrationError::InsufficientFrames { got, need })whenframe_count < config.min_frames.wifi-densepose-cli/src/calibrate_api.rs:605(async fn finalize) — writes the"finalizing"snapshot before calling the falliblerecorder.finalize():
let snap = session_snapshot(&sess, "finalizing", None);
status.write().await.session = Some(snap);
let baseline: BaselineCalibration = sess
.recorder
.finalize()
.map_err(|e| format!("finalize failed: {e}"))?; // <- error path returns here;
// the snapshot is never updated again
calibrate_api.rs:537(deadline tick) — the guard is a hardcoded floor, not the session's actual target, and the error is discarded:
if Instant::now() >= sess.deadline {
let frames = sess.recorder.frames_recorded() as usize;
if frames >= 10 { // <- should be `frames >= target_frames`
if let Some(done) = active.take() {
let _ = finalize(done, &output_dir, &status).await; // <- Err discarded
}
} else if let Some(mut done) = active.take() {
// not enough frames — abort honestly rather than emit a bad baseline
...
So with 10 <= frames < min_frames: the tick takes the finalize path, active is already take()n (hence /stop finds nothing), recorder.finalize() errors, the Err is dropped, and the last snapshot anyone can see is "finalizing". The growing eta_s is cosmetic fallout (rate estimate decays while remaining-frames stays constant).
The honest "aborted" path for the < 10 case is exactly the right behavior — it just never runs for this range.
Suggested fix
Either of these alone unsticks it; both together are cheap defense in depth:
- In the deadline tick, compare against the session's real target instead of
10, and route the shortfall to the existing abort path with anInsufficientFrames-style note (aborted: only N frames of M required in the time window). - In
finalize(), onErrwrite a terminal snapshot (state: "aborted", note = the error string) instead of leaving"finalizing"behind.
Happy to send a PR for either shape if useful.
Source: ruvnet/RuView