Skip to content

mp_fdjac2: guard non-finite numerical derivatives#364

Merged
bl4ckb0ne merged 1 commit into
collabora:masterfrom
rocketmark:upstream-pr/mpfit-fdjac2-nan-hang
Jul 13, 2026
Merged

mp_fdjac2: guard non-finite numerical derivatives#364
bl4ckb0ne merged 1 commit into
collabora:masterfrom
rocketmark:upstream-pr/mpfit-fdjac2-nan-hang

Conversation

@rocketmark

Copy link
Copy Markdown
Contributor

Summary

mp_fdjac2's only protection against a non-finite finite-difference derivative is assert(isfinite(fjac[ij])), which a release build (-DNDEBUG) strips out entirely — and the two-sided derivative path has no check at all. If the user function returns a degenerate residual for some parameter value, wa[i]/fvec[i] can be non-finite, writing NaN/Inf straight into fjac.

That NaN then poisons fnorm/ratio in mpfit()'s outer Levenberg-Marquardt loop. Since any comparison against NaN is false, the iteration counter (which only advances inside the ratio>=p0001 success branch) never increments, so the maxiter check that's supposed to bound the loop never fires.

Demonstration

BEFORE fix:
User function returns a degenerate residual for some trial parameter value
mp_fdjac2: writes NaN/Inf into fjac (assert is a no-op under -DNDEBUG)
mpfit(): outer loop's ratio comparison against NaN is always false
Iteration counter never advances; maxiter check never fires
Result: solver thread spins indefinitely instead of returning MP_MAXITER

AFTER fix:
User function returns a degenerate residual for some trial parameter value
mp_fdjac2: detects non-finite fjac, sanitizes to 0
mpfit(): column treated as having no measurable gradient (same fallback mp_qrfac already uses for a zero-norm column)
Result: solver proceeds normally and returns a clean status (success or MP_MAXITER), never hangs

Impact

Any caller whose residual function can produce a degenerate/non-finite value for some parameter trial is exposed — e.g. a rank-deficient or otherwise ill-posed fit reached transiently during normal operation (in our case, immediately after a scene/calibration reset). On a release build this manifests as a CPU-pinned thread that never returns and never logs anything, rather than a clean failure return, since the only existing guard (assert) is compiled out.

Change

One file, redist/mpfit/mpfit.c, function mp_fdjac2. Added if (!isfinite(fjac[ij])) fjac[ij] = 0; immediately after each of the three places fjac[ij] is computed from a finite-difference (one-sided non-debug, one-sided debug, two-sided non-debug), ahead of the existing assert where one exists.

Found via

Reproduced on real hardware (Raspberry Pi 4B running the GSS/MPFIT poser): after a tracker reinit while actively tracking, the GSS solver thread would intermittently spin at ~95-100% CPU for several minutes with zero log output, confirmed via gdb thread dumps (stuck inside mpfit() → mp_fdjac2, FPU status register showing divide-by-zero/invalid-operation flags set, ~465k spin iterations/sec with no progress).

Notably, the same libsurvive build reproduced this reliably on one Raspberry Pi OS release but not on an earlier one under the identical trigger sequence — we were unable to identify a specific OS-level change responsible (kernel, systemd/udev, and firmware/EEPROM package diffs between the releases were all ruled out), which suggests the divide-by-zero is being reached via a timing-sensitive path rather than anything OS-version-specific in nature, but doesn't rule one out. This patch closes the gap regardless of root timing cause and has held up under repeated reinit-while-tracking testing and an overnight soak on the affected hardware.

Comment thread redist/mpfit/mpfit.c Outdated
/* assert is compiled out under NDEBUG; guard for real. */
if (!isfinite(fjac[ij]))
fjac[ij] = 0;
assert(isfinite(fjac[ij]));

@bl4ckb0ne bl4ckb0ne Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we now have a way to handle finite values without crashing, I don't think the assert here and on line 1243 are relevant anymore. They should be removed in favour of your new check.

@throni3git

Copy link
Copy Markdown

this is possibly related to #310 as in the /* Non-debug path for speed */ path there is some hickup from a copy-paste error.

@rocketmark

Copy link
Copy Markdown
Contributor Author

this is possibly related to #310 as in the /* Non-debug path for speed */ path there is some hickup from a copy-paste error.

Ok. I'll compare and do a test to see if the merge #310 covers it. We can possibly still simplify the checks to be a bit less brittle. Will follow up here if still valid, or close the PR if not.

@bl4ckb0ne

Copy link
Copy Markdown
Collaborator

I think it would be good to have both, #310 only covered the non-debug path, the other one can still have non-finite values.

@throni3git

Copy link
Copy Markdown

as the calculations were originally coded at [1], this check should be put there as well. now unfortunately, the page seems to be down :(

[1] https://pages.physics.wisc.edu/~craigm/idl/cmpfit.html

rocketmark added a commit to rocketmark/libsurvive that referenced this pull request Jul 13, 2026
bl4ckb0ne noted on PR collabora#364 that once the isfinite() sanitize-to-zero
guard runs, the following assert(isfinite(fjac[ij])) can never fire
in a debug build and is already a no-op under -DNDEBUG. Removed both
now-dead asserts (one-sided non-debug and debug paths).

Claude Code assisted with this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@rocketmark

Copy link
Copy Markdown
Contributor Author

I think it would be good to have both, #310 only covered the non-debug path, the other one can still have non-finite values.

Updated and pushed. I'm going to look at this other issue throni3git mentioned in case it is related.

@bl4ckb0ne

Copy link
Copy Markdown
Collaborator

@throni3git i found it on the wayback machine here 1

Comment thread redist/mpfit/mpfit.c Outdated

@bl4ckb0ne bl4ckb0ne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you squash all three commits together please, keep only the first commit message because it is the only relevant one.

There's also a rebase conflict that needs to be addressed.

The only protection against a non-finite finite-difference derivative
here was assert(isfinite(fjac[ij])), which a release build (-DNDEBUG)
strips out entirely, and the two-sided derivative path had no check at
all. If the user function returns a degenerate residual for some
parameter value, wa[i]/fvec[i] can be non-finite, writing NaN/Inf
straight into fjac.

That NaN then poisons fnorm/ratio in mpfit()'s outer Levenberg-Marquardt
loop. Since any comparison against NaN is false, the iteration counter
(which only advances inside the ratio>=p0001 success branch) never
increments, so the maxiter check that's supposed to bound the loop never
fires. The result is an unbounded CPU spin instead of a clean failure
return -- in our case, confirmed via gdb on a real reproduction: ~465k
spin iterations/sec with zero progress, instead of returning MP_MAXITER.

Fix: sanitize fjac to 0 on a non-finite derivative in all three
non-debug/debug, one-sided/two-sided paths, so the column is treated as
having no measurable gradient -- the same fallback mp_qrfac already uses
for a zero-norm column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rocketmark rocketmark force-pushed the upstream-pr/mpfit-fdjac2-nan-hang branch from 8cdab91 to 9059292 Compare July 13, 2026 15:19
@throni3git

Copy link
Copy Markdown

@throni3git i found it on the wayback machine here 1

thanks, that's good. there seems to be an even newer version 1.5 (strangely dating to 2022 which is before i looked at it in 2024). maybe this is also worth it bringing in...

@rocketmark

Copy link
Copy Markdown
Contributor Author

Could you squash all three commits together please, keep only the first commit message because it is the only relevant one.

There's also a rebase conflict that needs to be addressed.

fixed and pushed.

@bl4ckb0ne bl4ckb0ne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for bearing through those iterations

@bl4ckb0ne bl4ckb0ne merged commit 1b9cef6 into collabora:master Jul 13, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants