DM-55992: Fix SEGV when failing to make SplineMap into a SIP WCS - #73
DM-55992: Fix SEGV when failing to make SplineMap into a SIP WCS#73timj wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #73 +/- ##
==========================================
+ Coverage 61.63% 61.73% +0.10%
==========================================
Files 83 83
Lines 96521 96706 +185
Branches 30597 30636 +39
==========================================
+ Hits 59487 59700 +213
+ Misses 20843 20803 -40
- Partials 16191 16203 +12 ☔ View full report in Codecov by Harness. |
TallJimbo
left a comment
There was a problem hiding this comment.
Commit messages have some painfully Claude-voiced text, if that sort of thing matters in this repo. Otherwise looks good, though I'm curious from the description why this was as rare as it seemed to be.
|
Analysis from Claude: → The rarity is not luck with undefined behavior. It is a discrete switch: SplineMap.OutUnit. The segfault needs two things at once: SIPIntWorld must have accepted a SIP description, and FitLine must then fail. At FitsTol=1e-4 over a real image box, FitLine fails for every one of these FrameSets. So the only thing that varies across your 700 is whether the SIP description is accepted at all. → Why acceptance is a coin toss: SIPIntWorld tests the post-PolyMap Mapping over a box in the wrong units. It uses astLinearApprox over ±NAXIS, but that box is measured in the PolyMap's output space, where one pixel is about 4.9e-4 units. So the box is roughly 4 million pixels wide, while the spline's knots span about 0.46 units (~950 pixels). Every sample point lands outside the knots. → OutUnit then decides everything. Default is 0: out-of-knot points come back AST__BAD, astLinearApprox fails, SIPIntWorld returns NULL, and astWrite cleanly returns 0. Your lsst2.ast has OutUnit = 1 explicitly set, so those points pass through unchanged and the check sees a perfectly linear map (fit offsets exactly 0.0). SIP accepted, FitLine fails, null deref. → Verified, not inferred. With both fixes reverted in an ASAN build: shipped fixture → SEGV fitschan.c:22140 in MakeIntWorld. Same fixture with OutUnit flipped to 0 → upper linapprox=0, SIPIntWorld returned NULL, astWrite -> 0, no crash. Nothing else in the run changed. → So on the LSST side, the thing to grep for is whatever sets OutUnit=1 on the SplineMap. Those are your handful. The rest were always taking the correct-refusal path, which is why ASAN over the old corpus never saw it. → Also worth knowing: CRPIX was AST__BAD for this fixture no matter what, because the spline's inverse is undefined at the IWC origin. So OutUnit=1 plus a loose FitsTol wouldn't have crashed, it would have silently written a header with SIP coefficients and no CTYPE. That is the second commit's bug, and it means some of your successful writes may be quietly broken rather than absent. |
|
Actually, it turns out that there were two real bugs:
|
6b6623a to
d175024
Compare
|
More Claude analysis. It has some concern that this fix will mean that some of the new difference images will have slightly incorrect FITS WCS. What this fixesFour defects in the FITS-WCS write path, found while writing the pixels-to-sky FrameSet of a DP2 difference image (
Why the segfault was as rare as it lookedIt is not luck with undefined behaviour. It is a discrete switch: The crash needs two things at once —
Nothing else in the run changes. Whatever sets The linearity test was not testing linearityTwo independent problems at Box centred on the wrong point. tested: [-4072,-4072] .. [ 4072, 4072] Disjoint, not merely under-sampled. Tolerance in the wrong units. This changes the answer, not just the rigour. Over the correct box the fit returns offsets The fix derives the box by transforming a 3x3 grid spanning the image through the lower Mapping and the PolyMap, and scales the tolerance by the displacement a one pixel step produces in the upper Mapping's output. Impact on existing outputNothing in the repo changes. All 1253 tests pass with no reference regenerated, including every SIP fixture. That is structural rather than lucky: when the Mapping after the polynomial is exactly a CD matrix — what the SIP conventions expect and what every existing fixture has — its linear fit is identical over any box at any tolerance. Output changes only where that Mapping is not exactly linear. Those headers do need regenerating, because the values written were never validated: On the synthetic case added to Tests
Future workNot addressed here, each worth its own ticket:
|
|
@dsberry could you please take a look at the fitschan changes since it is possible that Claude found a real bug (rather than just a missing if check) with the region the linearity check was being applied. |
|
I can't claim I've understood the full sequence of events, but:
doesn't sit well with me as a rarity explanation. I don't know if we're setting that or not, but we're certainly not only setting it on some detectors. |
|
@TallJimbo Okay so I asked it to drill down: OutUnit is necessary but not the discriminator. The real answer is that there are two independent coincidences, and the SEGV needs both. Here's what the traces show. → First: SIP acceptance is not a near-miss, it's essentially guaranteed for these FrameSets, whatever the offset. astLinearApprox fits from just four points — the centres of the box faces (src/mapping.c:6690), i.e. (±NAXIS, 0) and (0, ±NAXIS) in pixel offsets from the reference point. Those four sweep out a diamond in the tangent plane, not a square. In your fixture the two arms run along (-0.701, +0.713) and (+0.990, +0.143), while the knot box sits at (0.806, 0.676) — between the arms. So no sample point ever lands on the spline, OutUnit makes it the identity there, the fit is exactly the CD matrix with offsets of exactly 0.0, and SIP is accepted. → Enlarging the box does not save you, which is what killed my earlier explanation. I swept NAXIS to 16000 (a ±0.875 deg box, well past the knots at 0.574 deg): the arms just get longer and still thread past. G9 PASS, SEGV, every time. Distance from the tangent point is not the variable. → Second, and this is the actual lottery: whether FitLine fails. FitLine tests the Mapping SIPIntWorld hands back — the original with the SIP polynomial removed. Because that polynomial carried most of the mapping, where the image lands in the spline's input space in the reduced mapping is a different question from where it lands in the real one. Inside the knots, the spline bites, FitLine fails, the rows stay NULL, you crash. Outside, the reduced mapping is exactly linear, FitLine passes, and there is no crash. → Proof that direction, not distance, decides it. I moved the knot region to the same 1.0522 deg from the reference point but along one sampling arm instead of between them: base NAXIS=4072 G9 PASS FitLine FAIL -> SEGV Same offset magnitude. Only the position angle differs. → The important part: the non-crashing branch is not a clean refusal. It writes a header, and the header is garbage. The onarm case emitted 41 cards: NAXIS, RADESYS, and 38 SIP coefficients, with zero CTYPE, CRPIX, CRVAL or CD cards — and astWrite returned 1. That's the defect the second commit fixes, and it is the common outcome for this class of FrameSet, not the rare one. → So: the handful of segfaults are the visible tip. Expect a much larger population that silently produced an unusable header instead. Two cheap checks on your side: count products whose WCS carries A_ORDER but no CTYPE1, and re-run as_fits_wcs over the corpus against old and new AST and diff. I can only demonstrate the mechanism on this one fixture plus synthetic variants of it; which branch each of your thousands took has to be measured there. Replacement for that section of the PR body: Why the segfault was as rare as it lookedThe crash needs two independent coincidences, and only one of them is rare. SIP acceptance is effectively guaranteed for these FrameSets. What decides crash versus silent corruption is whether Moving the knot region to the same 1.0522 deg from the reference point but along
The non-crashing branch is the worse one. That second row emitted 41 cards — |
|
Ok, that does match my experience: I had a workaround for the "write some headers, but not a valid WCS" problem on the branch when I ran this on the DP2 difference images, so I wouldn't have seen those failures in my counts. |
|
So since the code was testing the linear fit in a box that was in the wrong place it never encountered the splines. Looks like it's only using 4 points so even in the right box it's possible that those 4 points end up in places that do not have any correction. The fit sampled (±4072, 0) and (0, ±4072) around a reference point ~20000 pixels from the data. Those points never enter the spline's knots, OutUnit hands back the identity there, so map_upper reduces to the exact CD matrix: deviation exactly 0, offsets exactly 0.0. The test was measuring a Mapping the spline wasn't part of. → With the box derived from the image, the spline is in the fit. Measured, commits 1 and 2 reverted so a wrong acceptance would still crash: box [12103.9,13263.9]..[18783.1,17898.8] utol=5.55e-09 -> FAIL Same clean rejection for the onarm variant and the half-offset one. Since map_upper is map3 ∘ SplineMap and map3 is an exact matrix, every bit of that measured non-linearity is the spline. There is nothing else in there to fail. → The box is the load-bearing half of that commit, not the tolerance. With the old box the deviation is exactly zero, so no tolerance however tight would have caught it. With the correct box either half rejects on its own: the deviation exceeds the converted tolerance, and the origin offsets come out at -9.0e-6, -2.1e-5 deg, which trip the existing 1e-7 test even at the old unconverted tolerance. → Where "completely" is too strong, three residual holes. Worth knowing before you rely on it: The sampling is still sparse — 4 fit points at the box face centres plus the box vertices as test points. The fix corrects where they land, not how many there are. A perturbation that happens to be linear at exactly those points still slips through. The box is a bounding box of the transformed image, so when the mapping rotates or skews it is larger than the true footprint. Its face-centre and vertex samples can fall outside the footprint, and if a spline's knots covered only the footprint those samples would be out of range again. Doesn't bite on this fixture (the knots are generous), but the hole is real. Nothing validates the assembled SIP description end to end. MakeIntWorld still discards its own FitLine CRPIX and CD in favour of SIPIntWorld's, and no check compares the finished description against the FrameSet over the image. That's the future-work item that would close the class of bug rather than this instance. |
c84afc0 to
9c5cb80
Compare
MakeIntWorld applied the SIP CD values from SIPIntWorld to the "partmat" rows for the celestial axes without checking that the linearity test above had succeeded. A failed FitLine leaves those rows NULL, so the assignment dereferenced a null pointer. Gate it on "ret", as everything after it already is. That also covers FindBasisVectors failing before the loop runs, which leaves every row NULL. Reaching it needs NAXIS1/NAXIS2 set, a FitsTol tight enough that the fit over that box fails, and SIPIntWorld to have accepted a SIP description. Add fixtures/wcsconv/inputs/lsst2.ast, the pixels-to-sky FrameSet of a DP2 difference image (visit=2025042400322, detector=150) whose pixel-to-tangent-plane leg contains a SplineMap, with oracle coverage, a wcsconv case and a testfitschan check. Its wcsconv reference records a second defect, untouched here: with neither FitsTol nor NAXIS set, astWrite reports success and emits a header carrying only an alternate axis description and orphan SIP coefficients. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SIPIntWorld derives CRPIX by transforming the IWC origin back into grid coordinates. That inverse can be undefined, and the AST__BAD values astTran2 then returns were used as if they were good. GetMaxJM ignores trailing AST__BAD values, so WcsFromStore saw an axis count of zero for that co-ordinate version and wrote no per-axis cards, while the SIP and RADESYS cards went out anyway. Nothing marked the version as failed, so astWrite reported success on a header with SIP coefficients and no CTYPE, CRPIX or CD cards, which no FITS-WCS reader can use. Treat AST__BAD CRPIX values as a failure, so SIPIntWorld returns NULL and MakeIntWorld falls back to fitting the Mapping directly. For lsst2.ast that fallback succeeds at the default FitsTol of 0.1, to about 0.03 pixels over a 1000 pixel box, so its wcsconv reference is regenerated and is now a complete header. At the FitsTol of 1e-4 that lsst-images uses, no approximation is good enough and astWrite returns 0 for both the small and large boxes, which testfitschan now checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FsetToStore already refuses to build an alternate axis description unless the primary one was built. The card-writing stage had no matching rule: WcsFromStore and PCFromStore set the returned value from any co-ordinate version that succeeds, so an alternate description alone was enough to make astWrite report success on a header no FITS reader can use. The SIP defect fixed in the previous commit produced exactly that. Record whether the primary description was written and stop if it was not. This also makes PCFromStore behave as its prologue already claimed. The test for a missing CRPIX1 or CRVAL1 now applies to the primary description as well as to the alternates. Also give the third lsst2 case in testfitschan its own FrameSet rather than reusing the one embedded in the outer frame set built by the case before it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SIPIntWorld checks that the Mapping following the SIP polynomial is linear and has no shift of origin, and takes CRPIX and CD from that fit. The box it used was the image dimensions, positive and negative, about the SIP reference point, which covers the image only if that point lies within it; and FitsTol went straight to astLinearApprox, which measures its tolerance in the output space of the Mapping under test rather than in pixels. For lsst2.ast the two boxes are disjoint, [-4072,4072] against [12104,18783]x[13264,17899], and the default 0.1 pixels was applied as 0.1 degrees, some 1800 pixels. Derive the box by transforming a 3x3 grid spanning the image through the lower Mapping and the PolyMap, and scale the tolerance by the displacement a one pixel step produces in the upper Mapping's output. Give up if any of those positions cannot be transformed, since the Mapping cannot then be checked where it is used. Nothing else catches a bad fit: MakeIntWorld discards the CRPIX and CD values it derived itself in favour of these. A Mapping that is exactly a CD matrix, which is what the SIP conventions expect and what every existing fixture has, fits identically over any box at any tolerance, so no reference changes. The new testfitschan case puts the reference point outside the image and adds a cubic term that is negligible there but not over the image. It used to be written with SIP coefficients and a CD matrix fitted 20000 pixels away; it is now written as a plain linear approximation, which is validated over the image and here lands closer to the truth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SIP conventions need the Mapping between the SIP polynomial and IWC to be a matrix, and its fit supplies CDi_j. SIPIntWorld obtained that fit from astLinearApprox, which fits from the centres of the box faces and then checks the result at thirteen further fixed positions. Structure smaller than the gaps between those positions is invisible to it, however well placed the box is, so a Mapping can be reported as linear while departing from the returned fit by many times FitsTol in between. For a 4000 pixel image those positions leave gaps over 700 pixels wide. Add FitSipMatrix, which samples a 16x16 grid spanning the image, transforms it through the lower Mapping and the PolyMap to reach the space in which the upper Mapping is used, and fits a plane to each output by least squares about the mean position. The fit is rejected if the worst residual exceeds FitsTol, converted into the output space as before. Sixteen positions per axis resolves anything wider than about a seventh of the image, which covers a bi-cubic spline with up to about 30 coefficients per axis. Sampling the image directly also removes the bounding box, so no sample can fall outside the region the Mapping is used over. This costs 256 transforms per write, against the 101 per axis FitLine already uses; measured at 0.06 ms beside a 0.7 ms write. testfitschan gains a Mapping that is linear except for a Gaussian bump 700 pixels from the nearest position astLinearApprox samples, 20 times FitsTol at its peak. It used to be written with SIP coefficients and a CD matrix fitted as though the bump were not there. A second pass with a bump well below FitsTol checks that a Mapping which is linear enough is still accepted, so the test measures the tolerance rather than just refusing anything non-linear. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CRPIX, CDi_j and the SIP coefficients each come from a different part of the Mapping being written, and each part is tested on its own as it is derived. Nothing tests the description they form together, so an error in one part is invisible to the tests on the others. CRPIX is the weak one: it comes from the inverse transformation of the Mapping, which may be iterative or approximate, while everything else comes from forward transformations. MakeIntWorld then discards the CRPIX and CD values it derived from FitLine in favour of these, so no later test sees them either. Add CheckSipFit, which builds the Mapping the description implies - a shift to the reference pixel, the SIP polynomial, then the CDi_j matrix - and compares it with the Mapping it is meant to describe at a 16x16 grid of positions spanning the image. Reject the description if the worst discrepancy exceeds FitsTol, or if either Mapping fails at any of those positions. This is the only test in the write path that measures what actually goes into the header. testfitschan gains a PolyMap whose inverse is offset half a pixel from its forward transformation, five times FitsTol. astWrite used to emit CRPIX1 = 0.5 for a Mapping whose reference pixel is grid zero and report success; it now falls back to a plain linear approximation, which uses only forward transformations. A description that is exact, which is what every existing fixture has, passes unchanged, so no reference moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixed 16x16 lattice used to validate the post-SIP linear fit can miss structure between its sample positions. The assembled-description check also converts FitsTol into world coordinates using the larger CD axis scale. This can hide pixel errors when the axes have very different scales. Build a shared residual Mapping for both checks. Express its outputs in pixel coordinates with the inverse fitted CD matrix, probe it at 4096 low-discrepancy positions, and refine each coordinate's extrema with astMapBox. Reject a SIP description if either residual exceeds FitsTol or the image-wide result cannot be verified. Add regressions for a narrow Gaussian bump placed between the fitting grid and for an inverse error hidden by a 100:1 CD scale ratio. This stronger validation is slower. In repeated Release measurements, a representative SIP conversion increased from 11.9 ms to 94.5 ms, about 8 times slower. Testfitschan increased from 234 ms to 521 ms, about 2.2 times slower. The affected conversion therefore gains about 83 ms. Co-authored-by: OpenAI Codex <noreply@openai.com>
The full residual validation is performed once while fitting the Mapping after the SIP polynomial and again after assembling the complete SIP description. The assembled check includes the fitted Mapping, so it already detects any unacceptable non-linearity found by the earlier check. Leave the least-squares step responsible only for producing the CD fit and validate that fit as part of the assembled description. Convert the two pixel residuals to their maximum absolute value with a MathMap, then use one astMapBox call to find its upper bound. This replaces the two per-axis calls, each of which already searched both signed extrema. Keep all 4096 low-discrepancy probes and the continuous extrema search. astTranGrid was considered, but a coarse regular grid would restore the alignment blind spot and a full image grid would not cover fractional positions. In repeated Release measurements, a representative SIP conversion falls from 94.5 ms to 41.1 ms, a 57 percent reduction. It remains about 3.5 times the 11.9 ms implementation before full residual validation. The warmed testfitschan runtime falls from 521 ms to 268 ms. That is about 15 percent above its original 234 ms runtime. Co-authored-by: OpenAI Codex <noreply@openai.com>
9c5cb80 to
2861a52
Compare
No description provided.