All findings · Lucene finding · LFV-282 · geo3d-degenerate-polygon-query-npe

A Geo3DPoint polygon query for a degenerate polygon throws NullPointerException instead of matching nothing

A polygon whose corners all lie in one plane through the planet's centre (say, three points on the equator) encloses no area, so Geo3DPoint.newPolygonQuery turns it into an empty shape that should match nothing. Instead every search with it, on an index that has Geo3DPoint values, throws a NullPointerException: the empty shape's bounding box is null.

Summary

Priority
Low, rank 683Degenerate Geo3D polygon queries in the experimental spatial3d module throw a loud NPE, and only the deleteDocuments path closes the writer.
Impact
crash
Since
6.1.0 (2016, LUCENE-7192)introduced 2016-04-19 · over 10 years
Affected
every tested release from 6.1.0 to 10.6.0, and main
Status
documented
Found by
code reading of the spatial3d query code, confirmed with a public-API test

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

geo3d swaps a polygon that encloses no area for an empty shape, which contains no point. The query code then reads that shape's bounding box, which is empty, so all six of its coordinates are null, and unboxes them without a check.

Background: how a Geo3DPoint polygon query runs

  1. Geo3DPoint (module lucene-spatial3d) stores each point as x, y, z on the planet's surface (the WGS84 model by default), not as latitude and longitude.
  2. newPolygonQuery turns each lat/lon Polygon into a geo3d shape. Each edge lies on a plane through two corners and the planet's centre, so it is an arc of a great circle.
  3. At search time the query takes the shape's x, y, z bounding box and uses it as a cheap first test, for index cells and for each point; only points inside the box are tested against the shape.
Diagram of LFV-282: Geo3DPoint.newPolygonQuery for a Polygon with lat {0,0,0,0} and lon {0,1,2,0} (three corners on the equator). GeoPolygonFactory.makeGeoPolygon returns null, Geo3DUtil.fromPolygon substitutes an empty GeoCompositePolygon, its XYZBounds stay null in all six coordinates, and the PointInShapeIntersectVisitor constructor unboxes them at line 48: expected 0 hits, actual NullPointerException. A null-bounds guard in PointInGeo3DShapeQuery.scorerSupplier makes it return 0 hits.
From the user's code to the crash: the equator polygon, the factory's null, the empty shape and its six null bounds, the unboxing at line 48, and the three-line guard that makes the query match nothing. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with a Geo3DPoint at lat 0.5, lon 0.5. Build new Polygon(lats {0, 0, 0, 0}, lons {0, 1, 2, 0}): three corners on the equator. Polygon accepts it, and LatLonPoint.newPolygonQuery returns 0 hits for it.
  2. Geo3DPoint.newPolygonQuery("point", WGS84, polygon) accepts it too. geo3d sees that the ring encloses no area (// Polygon is degenerate) and swaps in an empty shape, GeoCompositePolygon: {[]}, which contains no point.
  3. You expect searcher.count(query) to be 0. Lift the middle corner to latitude 2 and the same call returns 1, the document inside the triangle.
  4. You get a NullPointerException ("XYZBounds.getMinimumX() is null") at PointInShapeIntersectVisitor.java:48, with assertions on and off. The query asks the empty shape for its bounding box, gets six nulls, and unboxes them.
  5. The same happens with newShapeQuery(field, new GeoCompositePolygon(WGS84)). Passed to IndexWriter.deleteDocuments, the query makes the next commit throw and close the writer, and the documents added since the last commit are lost.

Who is affected

Applications that index Geo3DPoint (opt-in spatial3d module) and build polygon queries from client or imported coordinates. Only polygons whose every ring encloses no area trigger it: a point or line sent as a polygon, a collapsed ring, corners on one great circle. Default settings, one document. The request fails loudly; a delete-by-query also closes the writer.

Mechanism, terms and numbers644 words

Geo3DPoint.newPolygonQuery converts each org.apache.lucene.geo.Polygon with GeoPolygonFactory.makeGeoPolygon, which documents that it returns null "if a valid polygon cannot be generated from this input". That is the case for a ring that encloses no area on the sphere, for example three vertices on the equator: all three lie in the equator's plane, which passes through the planet's centre, and the factory's coplanarity filter finds no valid polygon ("everything was coplanar").

Geo3DUtil.fromPolygon handles that null on purpose: under the comment // Polygon is degenerate it substitutes an empty GeoCompositePolygon, which contains no point. The query then computes the shape's bounding box. An empty composite adds nothing, so all six boxed Double bounds stay null, and the PointInShapeIntersectVisitor constructor unboxes them (PointInShapeIntersectVisitor.java:48 on main 645d4fe14a20). Every search or count that reaches the query's scorer on a segment that has the field throws, with assertions on and off. newShapeQuery with an empty GeoCompositePolygon fails the same way. The same Polygon gives 0 hits with LatLonPoint.newPolygonQuery and is rejected with IllegalArgumentException by newLargePolygonQuery. The failure has been there since 6.1.0 (LUCENE-7192, 2016).

How the code gets there (main 645d4fe14a20)

  1. Entry. Geo3DPoint.newPolygonQuery calls Geo3DUtil.fromPolygon(planetModel, polygons) and passes the shape to newShapeQuery (Geo3DPoint.java:168-172), which constructs PointInGeo3DShapeQuery (:241-243).
  2. The factory says "no polygon". The private Geo3DUtil.fromPolygon(PlanetModel, Polygon) copies the vertices into GeoPoints and calls GeoPolygonFactory.makeGeoPolygon(planetModel, points, holeList) (Geo3DUtil.java:197-227, call at :224). makeGeoPolygon returns null when filterPoints (identical points, too few points) or filterEdges (coplanar points) leaves no valid polygon (GeoPolygonFactory.java:310-320), as its javadoc says (:275-276). For the equator ring filterPoints keeps 3 points and filterEdges returns null ("No path found. This means that everything was coplanar.", :701): the three points all have z = 0: one plane through the planet's centre, a great circle on a sphere (executed).
  3. Deliberate substitution. Geo3DUtil.fromPolygon(PlanetModel, Polygon...) maps a null single polygon to new GeoCompositePolygon(planetModel) under // Polygon is degenerate (Geo3DUtil.java:77-84). For a multi-polygon it skips null components (:86-93): a multi-polygon made only of such rings is also an empty composite, while a degenerate ring next to a valid one is just dropped and the query works (executed).
  4. The empty shape is right by itself. GeoBaseCompositeShape.isWithin loops over zero shapes and returns false (GeoBaseCompositeShape.java:104-111); getBounds adds nothing (:134-138).
  5. Null bounds. The query constructor computes shapeBounds = new XYZBounds(); shape.getBounds(shapeBounds); (PointInGeo3DShapeQuery.java:53-58). XYZBounds keeps its six bounds as boxed Double fields that start null (XYZBounds.java:35-51, "Construct an empty bounds object", :53-54); for the empty composite all six stay null (executed).
  6. The unboxing. scorerSupplier returns early only when the segment has no point values for the field (PointInGeo3DShapeQuery.java:78-81); otherwise it builds new PointInShapeIntersectVisitor(result, shape, shapeBounds) (:107). The visitor constructor passes bounds.getMinimumX() and the other five getters to DocValueEncoder.roundDownX(double) and its siblings (PointInShapeIntersectVisitor.java:44-54; PlanetModel.java:771), which unboxes null at :48. Nothing on this path checks for null.
  7. Elsewhere null bounds mean "empty". GeoComplexPolygon says "If we have no bounds at all then the answer is \"false\"" (GeoComplexPolygon.java:776-784). The query and the visitor have no such check.

The trigger set (all executed)

Every ring of the query encloses no area on the sphere: three collinear vertices on the equator or on a meridian; all vertices identical; only two distinct vertices; an A-B-A backtrack; a multi-polygon made only of such rings; and a triangle that is valid and not self-crossing in lat/lon terms but whose vertices lie on one great circle (lat {0, 45.00000000000001, 37.76124390703503, 0}, lon {0, 90, 50.76847951640774, 0}, either winding), which no 2D area check would catch. Also newShapeQuery(field, new GeoCompositePolygon(planetModel)). Not triggered: a degenerate ring next to a valid polygon, a degenerate hole (dropped, count 1), and a segment without the field (count 0).

Exposure and likelihood267 words
  • Who is hit. Code that indexes Geo3DPoint (@lucene.experimental, Geo3DPoint.java:42) and builds newPolygonQuery from coordinates it does not control, or calls newShapeQuery with a GeoCompositePolygon that can end up empty. Default WGS84 model, codec and IndexWriterConfig; the module is opt-in.
  • What goes wrong on search. The request fails with an internal NullPointerException, not a clear argument error, so a server is likely to report it as its own failure. It is thrown before any point is visited: no hang, no extra or missing hits, no index change, and the query cache still answers a normal query afterwards (executed: 20 rounds on a 4-thread searcher with intra-segment partitions). Inside a BooleanQuery the clause makes search throw too.
  • Delete path, wider effect (executed on main). IndexWriter#deleteDocuments(query) returns normally because the query is buffered. The next commit() or DirectoryReader.open(writer) throws the same NPE, the writer closes with it as its tragic exception, and the document added after the last commit is lost (2 added, 1 in the last commit). The last commit stays readable and CheckIndex-clean. This is IndexWriter's general rule that an exception while applying a buffered delete is tragic (LUCENE-7868, 2017), not a second bug; with the guard fix the writer stays open.
  • Not triggered. A degenerate ring next to a valid polygon, a degenerate hole, or a segment without the field.
  • Scale. None: one document, one query, deterministic.
  • How likely: rare. Ordinary polygons never trigger it; the recorded searches found no user report in ten years; a read-only GitHub code search (not exhaustive) found no use of Geo3DPoint in Solr, Elasticsearch or OpenSearch.

Minimal repro

One JUnit method on the public API: one Geo3DPoint document, the equator polygon, assertEquals(0, searcher.count(query)). In-memory directory, default IndexWriterConfig, a plain new IndexSearcher(reader); single thread, deterministic, assertions on or off (the test framework still picks a random similarity, locale and time zone; every recorded seed fails the same way).

TestGeo3DDegeneratePolygonQuery.java (lucene/spatial3d, package org.apache.lucene.spatial3d, 1 test)46 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.spatial3d;

import org.apache.lucene.document.Document;
import org.apache.lucene.geo.Polygon;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGeo3DDegeneratePolygonQuery extends LuceneTestCase {

  public void testDegeneratePolygonMatchesNothing() throws Exception {
    try (ByteBuffersDirectory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new Geo3DPoint("point", PlanetModel.WGS84, 0.5, 0.5));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        // vertices on the equator, built as an empty GeoCompositePolygon: expected 0 hits, actual NPE
        Polygon degenerate = new Polygon(new double[] {0, 0, 0, 0}, new double[] {0, 1, 2, 0});
        Query q = Geo3DPoint.newPolygonQuery("point", PlanetModel.WGS84, degenerate);
        assertEquals(0, new IndexSearcher(reader).count(q));
      }
    }
  }
}
Command
cp $REPRO/TestGeo3DDegeneratePolygonQuery.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DDegeneratePolygonQuery -Ptests.seed=DEADBEEF
Expected
searcher.count(query) returns 0: the empty shape contains no point.
Observed
NullPointerException ("XYZBounds.getMinimumX() is null") at PointInShapeIntersectVisitor.java:48, called from PointInGeo3DShapeQuery.java:107.

All files of this repro: repro/LFV-282/

More files, environment, full output211 words

The test sits in the spatial3d test package like the upstream tests. The one document is needed: in a segment without the field, scorerSupplier returns before it reaches the visitor. It asserts 0 hits, the outcome the guard fix gives, so it is also the regression test for that fix; if maintainers choose to reject the polygon instead, its last two lines become expectThrows(IllegalArgumentException.class, () -> Geo3DPoint.newPolygonQuery(...)).

It needs the org.apache.lucene.tests packages, so it compiles on 9.1.0 and later; releases 6.1.0 to 9.0.0 were checked with a separate history probe on the public API. The test's line numbers in the output include the 16-line license header.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (plain Java logic, nothing platform-specific)
Command (environment)
cp $REPRO/TestGeo3DDegeneratePolygonQuery.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DDegeneratePolygonQuery -Ptests.seed=DEADBEEF

Observed output

TestGeo3DDegeneratePolygonQuery > testDegeneratePolygonMatchesNothing FAILED
    java.lang.NullPointerException: Cannot invoke "java.lang.Double.doubleValue()" because the return value of "org.apache.lucene.spatial3d.geom.XYZBounds.getMinimumX()" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:399007E39E65274F]:0)
        at org.apache.lucene.spatial3d.PointInShapeIntersectVisitor.<init>(PointInShapeIntersectVisitor.java:48)
        at org.apache.lucene.spatial3d.PointInGeo3DShapeQuery$1.scorerSupplier(PointInGeo3DShapeQuery.java:107)
        at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.LRUQueryCache$CachingWrapperWeight.scorerSupplier(LRUQueryCache.java:874)
        at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.searchLeaf(IndexSearcher.java:842)
        at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:814)
        ...
        at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.count(IndexSearcher.java:528)
        at org.apache.lucene.spatial3d.TestGeo3DDegeneratePolygonQuery.testDegeneratePolygonMatchesNothing(TestGeo3DDegeneratePolygonQuery.java:42)

:lucene:spatial3d:test (FAILURE): 1 test, 1  failure

# standalone runner, seed DEADBEEF: main -ea and -da, 10.6.0, 10.5.2: 1 of 1 fails at PointInShapeIntersectVisitor.java:48; 9.12.3: at :47

Why it looks unintended

  • The code handles this case on purpose: a degenerate polygon becomes an empty GeoCompositePolygon (// Polygon is degenerate, Geo3DUtil.java:79-81), which contains no point.
  • newPolygonQuery documents no exception, Polygon accepts the ring, and LatLonPoint returns 0 hits.
  • The query assumes bounds are never null: "They are not expected to be null" (Karl Wright, who wrote both code paths, in #7757).
The full argument400 words
  • Deliberate branch. Geo3DUtil.fromPolygon builds an empty GeoCompositePolygon under // Polygon is degenerate (Geo3DUtil.java:79-81) and skips degenerate components of a multi-polygon (:89-91). An empty composite contains no point (GeoBaseCompositeShape.java:104-111). That is consistent with "match nothing".
  • The factory's null is documented (GeoPolygonFactory.java:275-276), and Karl Wright confirmed on #9568 (2018-10-05) that "It's actually in the GeoPolygonFactory contract that null is returned for polygons that cannot be constructed". Five minutes earlier on the same issue he had written "I agree that the factory should never return null; it should return IllegalArgumentException instead. This would have to be a fix in the factory.", which supports the reject fix as much as it explains the null.
  • The query assumes non-null bounds. When the predecessor query (bkdtree3d PointInGeo3DShapeQuery$1.scorer, on the LUCENE-6699 branch) hit an NPE from null bounds in 2015 (#7757), Karl Wright answered "They are not expected to be null" and fixed the shapes. The empty-composite branch came later (LUCENE-7192, 2016) and breaks that assumption.
  • Contract. newPolygonQuery's javadoc says the polygon "should have a limited number of edges (less than 100) and be well-defined, with well-separated vertices" (Geo3DPoint.java:157-159): advice, with no @throws. newShapeQuery states no precondition (:235-243). Polygon checks only length, closure and coordinate ranges (Polygon.java:77-101).
  • Siblings. LatLonPoint.newPolygonQuery returns 0 hits for the same Polygon; newLargePolygonQuery rejects it when the query is built (IllegalArgumentException: cannot find a point that is inside the polygon); the spatial-extras wrapper throws InvalidShapeException("Invalid polygon, all points are coplanar") for the same factory null (Geo3dShapeFactory.java:350-353, the #9568 fix). None ends in an NPE at search time.
  • Tests. No upstream test builds a query from a degenerate polygon or an empty composite: TestGeo3DPoint's random polygon queries catch IllegalArgumentException and draw again (TestGeo3DPoint.java:771-793). With either fix all existing spatial3d tests pass (executed).
  • History. Before LUCENE-7192 the three inputs run at that commit (the equator ring, the all-identical ring and the two-distinct-vertex ring) were rejected with IllegalArgumentException when the query was built (executed on the spatial3d sources of branch_6x b95bbb80927; the A-B-A ring and the great-circle triangle were not run there). LUCENE-7192 ("Geo3d polygon creation should not get upset about co-linear points", for OpenStreetMap data) made the factory return null and added the empty-composite branch to be more tolerant; neither it nor LUCENE-7221 mentions a query-time failure.
The devil's-advocate check139 words

The strongest "works as designed / misuse" case (the devil's-advocate verifier and the cross-model reviewers): the javadoc says the polygon "should be well-defined"; Polygon says it "must not be self-crossing, otherwise may result in unexpected behavior" (Polygon.java:34), and a collinear or A-B-A ring retraces itself; maintainers prefer rejecting degenerate polygons (#9568, newLargePolygonQuery, the Tessellator's "at least three non-collinear points required", TestTessellator.java:200-207); and Geo3DPoint is @lucene.experimental.

Why it fails: all of that supports at most a clear IllegalArgumentException when the query is built, not an NPE at search time; the great-circle triangle does not self-cross in lat/lon terms and still throws; @lucene.experimental is about API stability, not a licence to crash; the code explicitly accepts and converts this input; and newShapeQuery with an empty composite involves no polygon at all. Verdict: not intended, a defect.

Suggested fix

Recommended Match nothing: return no scorer when the shape has no bounds

Return no scorer when the shape added nothing to its bounding box: three lines in PointInGeo3DShapeQuery.scorerSupplier, if (shapeBounds.getMinimumX() == null) return null;. Degenerate polygons and empty composites then match nothing, like LatLonPoint. The minimal test passes, and so does ./gradlew :lucene:spatial3d:test (182 tests, 3 skipped, no failure).

        if (shapeBounds.getMinimumX() == null) {
          // The shape added nothing to its bounds (e.g. the empty GeoCompositePolygon that
          // Geo3DUtil builds for a degenerate polygon): it contains no point.
          return null;
        }
All options and trade-offs2 options · 575 words

Both options were validated with gradle in a fresh worktree at 645d4fe14a20 (seed DEADBEEF). They differ in what a degenerate polygon means: no hits, or an error when the query is built. Both remove the NullPointerException for newPolygonQuery; only the guard also covers newShapeQuery with an empty composite, so the two can be combined.

Match nothing: return no scorer when the shape has no bounds (validated by the checks) (recommended)

In PointInGeo3DShapeQuery's scorerSupplier, right after the existing values == null check (PointInGeo3DShapeQuery.java:79-81). Returning null is the usual "no matches in this segment" answer, so the query cache and the delete path need nothing else.

        if (shapeBounds.getMinimumX() == null) {
          // The shape added nothing to its bounds (e.g. the empty GeoCompositePolygon that
          // Geo3DUtil builds for a degenerate polygon): it contains no point.
          return null;
        }

Trade-offs

  • Silent: a client that sends a broken polygon gets no hits and no error, as with LatLonPoint.
  • The guard checks getMinimumX() only, as the original sketch does. That is enough for the empty composite (all six stay null), but XYZBounds sets each axis separately (addXValue, addYValue, addZValue, XYZBounds.java:317, :340, :363), so checking all six bounds is equally cheap and more defensive.

What the checks validated

  • Gradle, fresh worktree at 645d4fe14a20, seed DEADBEEF: the minimal test fails before the change (1 test, 1 failure) and passes after it, with assertions on and with -Ptests.asserts=false; ./gradlew :lucene:spatial3d:test passes (182 tests: the module's 181 plus the new one, 3 skipped).
  • With the same guard on the stock jars: an independent six-test suite (two controls, equator and meridian rings, an all-degenerate multi-polygon, newShapeQuery with an empty composite) passes 6 of 6, upstream TestGeo3DPoint passes (15 passed, the 2 @Nightly tests skipped; assertions on), and the delete-path probe keeps the writer open and commits both documents.

Reject when the query is built (validated by the checks)

In the package-private varargs Geo3DUtil.fromPolygon(PlanetModel, Polygon...) (Geo3DUtil.java:71, called by Geo3DPoint.newPolygonQuery), throw instead of building an empty composite: for a single degenerate polygon, and for a multi-polygon whose components are all degenerate.

      if (component == null) {
        throw new IllegalArgumentException("polygon is degenerate: it encloses no area");
      }
      ...
      if (poly.size() == 0) {
        throw new IllegalArgumentException("all polygons are degenerate: they enclose no area");
      }

Trade-offs

  • Consistent with the #9568 fix in spatial-extras, newLargePolygonQuery and the Tessellator, and with Karl Wright's 2018 "the factory should never return null; it should return IllegalArgumentException instead" (#9568); it tells the caller what is wrong.
  • Does not cover newShapeQuery(field, new GeoCompositePolygon(pm)), which never passes through fromPolygon; the guard is still needed for that path.
  • Turns a call that works today into an error: on an index whose segments have no Geo3DPoint values the query currently returns 0 hits (executed).
  • Keeps the mixed case working (a degenerate ring next to a valid one is still dropped); a stricter variant that rejects every degenerate component would also break that case and degenerate holes (not run).
  • Also changes the other caller of fromPolygon, Geo3DDocValuesField.newOutsidePolygonSort (Geo3DDocValuesField.java:311-313): a degenerate polygon would throw there too when the sort is built (not run).

What the checks validated

  • Gradle, same worktree: newPolygonQuery throws IllegalArgumentException: polygon is degenerate: it encloses no area for the repro's input (so the minimal test, which asserts 0 hits, fails by design); ./gradlew :lucene:spatial3d:test without the minimal test passes (181 tests, 3 skipped).

Downstream note

A read-only GitHub code search (not exhaustive) found no use of Geo3DPoint in Solr, Elasticsearch or OpenSearch (Solr's geo3d support goes through spatial-extras, which has thrown InvalidShapeException for such polygons since #9568), so only applications that build Geo3DPoint queries directly are exposed.

How it was found

Found by reading the spatial3d query code while setting up a test harness for its geometry: the explicit degenerate-polygon branch produces a shape with no bounding box, and the query unboxes that box unconditionally. A public-API test then confirmed it. No formal method was involved.

The method in full120 words
  • Method: code reading (not a formal method). While building a metamorphic test harness for the spatial3d geometry code, an AI tool noticed the explicit // Polygon is degenerate branch in Geo3DUtil.fromPolygon and, separately, the unconditional unboxing of XYZBounds in the PointInShapeIntersectVisitor constructor, and asked what an empty shape's bounds are.
  • It ran a set of degenerate but legal polygons on the stock main jar, then wrote a public-API JUnit test, which failed on main with assertions on and off (20 of 20 iterations) and on 10.5.2 and 9.12.3, and passed with the null-bounds guard.
  • Independent checkers then reproduced it with their own tests, widened the trigger set (the great-circle triangle, newShapeQuery with an empty composite) and found the delete-path effect.

Prior reports, history and checks

Prior reports

No earlier report found (searched 2026-10-26, re-checked 2026-10-30) in GitHub issues and PRs including the JIRA archive, the mailing lists, downstream projects or the web. Closest, not this bug: #9568 (the same pattern in spatial-extras, fixed only there in 2018) and #7757 (a 2015 NPE at the equivalent place in the predecessor query, from wrong bounds in non-empty shapes).

Searches5 searches

No issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports this NullPointerException for a degenerate Geo3DPoint polygon or an empty composite passed to newShapeQuery; the recorded searches found no report or discussion by anyone.

  • #9568 / LUCENE-8522 (closed, fixed in 7.6): the same "factory returns null, an NPE follows" pattern, in the spatial-extras Geo3dShapeFactory path, fixed only there; the Geo3DPoint path still fails on every later release.
  • #7757 / LUCENE-6699 (2015): an NPE at the equivalent place in the predecessor query (bkdtree3d PointInGeo3DShapeQuery$1.scorer), from wrong getBounds in non-empty shapes, before the empty-composite branch existed.
  • #9893 / LUCENE-8850 (open) and apache/lucene-solr#709 (unmerged): reject zero-area Polygons in core. That would cover the equator, meridian, identical-vertex and A-B-A rings, but not the great-circle triangle or newShapeQuery.
  • #8247 / LUCENE-7192: the origin of the tolerant branch.
  • Open PRs: two touch PointInGeo3DShapeQuery.java, both only at the DocIdSetBuilder line (:105), neither in the bounds handling: #16847 (maxDoc() to maxLeafDoc()) and the stale #709 (LUCENE-10311, open since 2022-02-24). No open PR touches PointInShapeIntersectVisitor.java or Geo3DUtil.java.

Gaps: GitHub search matches tokens and was quota-limited; list search matches subjects and leading text; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch)2026-10-26three hunt prior-art runs (36 issue/PR, 7 commit and 7 downstream queries: the error text, XYZBounds, PointInShapeIntersectVisitor, makeGeoPolygon, GeoCompositePolygon, Geo3DUtil, newShapeQuery, coplanar, collinear, zero-area) plus manual searches; the changed files of the geo-related open PRs among all 413no report; closest #9568, #7757, #9893, lucene-solr#709, #8247
JIRA (LUCENE and SOLR)2026-10-2616 archive queries in the tool runs and 5 JQL queries by hand; comments of the candidates readno report
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2660 queries in the tool runsno report; only commit notifications for the issues above
Web2026-10-2617 queries with the error text, class names and downstream namesno relevant page
GitHub re-check (read-only)2026-10-30PointInShapeIntersectVisitor, XYZBounds NullPointerException, degenerate polygon Geo3DPoint, GeoCompositePolygon empty, Geo3DPoint NullPointerException, newPolygonQuery degenerate, Geo3DPoint collinear; open PRs (text search, and the changed files of all open PRs); upstream main 2f424fd428da1 unrelated hit (#8223); no open PR mentions the query class; two open PRs, #16847 and #709, change its DocIdSetBuilder line only; the three files unchanged upstream

History

Since 6.1.0 (2016): LUCENE-7192 (75cdcbab1094, follow-up 02f1dacc3d84) made degenerate polygons map to an empty shape instead of an IllegalArgumentException, and the bounds check added by LUCENE-7221 (1d76b2c49ba9) unboxes its null bounds. Every tested release from 6.1.0 to 10.6.0, and main, fails.

Versions tested5 rows

Introduced by 75cdcbab1094 (master, 2016-04-19, Karl Wright, "LUCENE-7192: Revamp how coplanar points are detected and filtered, for OpenStreetMap compatibility."; issue #8247, committed from JIRA, no PR): GeoPolygonFactory returns null instead of throwing for degenerate input, and newPolygonQuery maps a null polygon to an empty GeoCompositePolygon. The same-day follow-up 02f1dacc3d84 lets the three-collinear-vertex input through too. 6.1.0 shipped the branch_6x backports 01a7ee4eebbc and e4012c6b32ef.

The throwing line is older: 1d76b2c49ba9 (LUCENE-7221, #8276, 2016-04-14) made the visitor compare against the shape's XYZBounds; LUCENE-7430 (08ca33f57e5, 6.3.0) moved the unboxing into the visitor constructor, and LUCENE-9150 (a6e80d004d8, 8.5.0, "Restore support for dynamic PlanetModel in spatial3d") switched it from the static Geo3DDocValuesField.roundDownX(...) to the shape's getPlanetModel().getDocValueEncoder().roundDownX(...). It never had a null check. Old releases ran on JDK 25, not the JDK of their time; the failing frame moves with the line (compare:86 in 6.1.0-6.2.1, then <init>:45 to :48).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with gradle and with the standalone runner, assertions on and off
10.6.0, 10.5.2, 9.12.3 affectedthe minimal test fails (release jars)
6.1.0 - 10.5.2 (17 releases) affectedthe history probe fails on every one: 6.1.0, 6.2.0, 6.2.1, 6.3.0, 6.6.6, 7.0.0, 7.0.1, 7.1.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (assertions off too on 6.1.0, 10.5.2 and main)
branch_6x b95bbb80927 (just before the LUCENE-7192 backport) rejected at buildthe three degenerate polygons run there (the equator ring, the all-identical ring, two distinct vertices) are rejected with IllegalArgumentException when the query is built
6.0.0, 6.0.1 no polygon APIno newPolygonQuery; newShapeQuery with an empty composite already fails at search time, with IndexOutOfBoundsException

Independent checks

An independent reproducer, a devil's advocate, an adjudication and fresh-seed re-runs confirm it; reviewers from another model family, reading only, agreed on the root cause. History runs covered 17 releases and main; both fixes pass the spatial3d tests.

Checks and verdicts310 words

Each executing check ran tests against the stock jars; the cross-model reviewers (OpenAI GPT-5.6) could not run Java, so a separate run re-ran the repro for them.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCode reading, then a public-API JUnit test that fails on main (assertions on and off, 20 of 20 iterations), 10.5.2 and 9.12.3 and passes with the guard.
cross-model review, refute lens (OpenAI GPT-5.6) not real (low)Read only; it could not run Java. It agreed with the root cause and argued (moderate) that the zero-area input is outside newPolygonQuery's "well-defined" guidance, the misuse case that "Why it is a bug" rebuts; it pointed to newShapeQuery with an empty composite as an in-contract trigger. A separate run re-ran the repro: it fails.
Independent reproducer realIts own six tests (two controls, four claims) fail 4 of 6 on main, 10.5.2, 9.12.3 and 8.11.4, with the controls passing; 6 of 6 pass with the guard.
Cross-model adversary, intended lens (OpenAI GPT-5.6) real (medium)Works-as-designed argument moderate and not holding; inputs in contract. A separate test (newShapeQuery with an empty composite) throws the same NPE.
Devil's advocate real (high)Built the misuse case and rejected it; found the great-circle triangle, the LatLonPoint contrast and the mixed and hole cases.
adjudication confirmed (high)Re-ran the independent reproduction's tests with a new seed (assertions on and off: 4 of 6 fail, controls pass); class crash; not a duplicate.
history, prior art, security screen since 6.1.0; new; not security-sensitive17 releases plus main and commit-level runs; three prior-art runs; the delete-path probe.
brief (2026-10-30) reproduced; both fixes validatedThe minimal test on main 645d4fe14a20 (assertions on and off, gradle and standalone), 10.6.0, 10.5.2 and 9.12.3; the delete-path probe re-run; both fixes with the spatial3d test suite.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,532 words

Finding record

LFV-282, snapshot of 2026-11-06

Registry title
Geo3DPoint.newPolygonQuery turns a degenerate polygon (for example three vertices on the equator) into an empty GeoCompositePolygon that is meant to match nothing, but every search with the query throws NullPointerException; Geo3DPoint.newShapeQuery with an empty GeoCompositePolygon fails the same way
Area
spatial3d / Geo3DPoint polygon and shape queries (Geo3DUtil.fromPolygon, PointInGeo3DShapeQuery, PointInShapeIntersectVisitor)
Priority
Low, rank 683: Degenerate Geo3D polygon queries in the experimental spatial3d module throw a loud NPE, and only the deleteDocuments path closes the writer.
Impact class
crash
Impact note
Hits applications that use the Geo3DPoint field of the opt-in spatial3d module (@lucene.experimental; a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and build a polygon query in which every component is degenerate (vertices on one great circle, all vertices identical, only two distinct vertices, or an A-B-A backtrack), or call newShapeQuery with an empty GeoCompositePolygon: with the default WGS84 model, codec and IndexWriterConfig, every search of a segment that has the field throws NullPointerException, with -ea and -da; there is no hang, no wrong hit and no index damage, and only that request fails, except that the same query passed to IndexWriter#deleteDocuments(Query) makes the next commit, NRT reader open or flush throw, closes the writer with a tragic exception and loses the documents added since the last commit (executed; the last commit stays intact and CheckIndex-clean); no scale is needed, and every release since 6.1.0 is affected.
Summary
You build Geo3DPoint.newPolygonQuery from an org.apache.lucene.geo.Polygon that Polygon accepts but whose vertices all lie on one great circle, for example lat {0,0,0,0} and lon {0,1,2,0}, and expect a query that matches nothing: Geo3DUtil.fromPolygon recognizes the polygon as degenerate ('// Polygon is degenerate') and deliberately replaces it with an empty GeoCompositePolygon. Instead every search with the query throws NullPointerException ('Cannot invoke Double.doubleValue() because the return value of XYZBounds.getMinimumX() is null'), because the empty shape adds nothing to its XYZBounds and the PointInShapeIntersectVisitor constructor unboxes the null bounds. Geo3DPoint.newShapeQuery with an empty GeoCompositePolygon fails the same way, while LatLonPoint.newPolygonQuery returns 0 hits for the same Polygon and newLargePolygonQuery rejects it with IllegalArgumentException when the query is built.
Introduced
75cdcbab1094ce25a9a340f84f2101c5718635c0 (master, 2016-04-19, 'LUCENE-7192: Revamp how coplanar points are detected and filtered, for OpenStreetMap compatibility.', by Karl Wright (GitHub DaddyWri); LUCENE-7192 is apache/lucene#8247; committed from the JIRA issue, no pull request): GeoPolygonFactory.makeGeoPolygon and filterPoints return null instead of throwing IllegalArgumentException for degenerate input, and Geo3DPoint.newPolygonQuery maps a null polygon to an empty GeoCompositePolygon; the same-day follow-up 02f1dacc3d84a42f9359aedb0cc98ca4a6161d57 ('LUCENE-7192: Catch the case where we have two points in the same poly that are not adjacent but identical.') lets the three-collinear-vertex input through as well; 6.1.0 shipped the branch_6x backports 01a7ee4eebbc and e4012c6b32ef (identical patch-ids). The unboxing that throws is older: 1d76b2c49ba9c924b539f7d8a0f58c3e609656f1 (LUCENE-7221, #8276, 2016-04-14, backport 0b0e442010a6) made PointInShapeIntersectVisitor compare against the shape's XYZBounds; LUCENE-7430 (08ca33f57e5, 6.3.0) moved the unboxing into the visitor constructor and LUCENE-9150 (a6e80d004d8, 8.5.0) reworded it; it has never had a null check. [exec: the spatial3d sources of each probed branch_6x commit, shadowed over the 6.1.0 release jars (only the two query glue files ported to the 6.1.0 core API): b95bbb80927, just before the LUCENE-7192 backport, rejects the collinear, identical-vertex and two-vertex polygons at build time with IllegalArgumentException; 01a7ee4eebb turns the identical-vertex and two-vertex polygons into the search-time NPE; e4012c6b32e also the collinear one; read: git log -S, git blame, git show] (2016-04-19 (LUCENE-7192 and its follow-up; the unboxing site, LUCENE-7221, dates from 2016-04-14))
First affected version
6.1.0 [exec] (the first release with Geo3DPoint.newPolygonQuery, org.apache.lucene.geo.Polygon and the LUCENE-7192 commits; fails with -ea and -da); 6.0.0 and 6.0.1 have no newPolygonQuery, but there the newShapeQuery(empty GeoCompositePolygon) variant already fails at search time, with IndexOutOfBoundsException in GeoCompositeMembershipShape.getEdgePoints instead of the NPE [exec]
Affected versions
6.1.0 to 10.5.2 (the last release before 10.6.0) and main ea57360912ed. [exec] a version-neutral public-API main() (four API-era variants that differ only in marked API lines) fails with the same NPE for every degenerate input and for the empty-composite shape on 6.1.0 (-ea and -da), 6.2.0, 6.2.1, 6.3.0, 6.6.6, 7.0.0, 7.0.1, 7.1.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with the circle, box and box-plus-degenerate-component controls passing; the finder's JUnit fails on main (-ea, -da, 20 of 20 iterations), 10.5.2 and 9.12.3, and the independent reproduction's JUnit on main, 10.5.2, 9.12.3 and 8.11.4. [read] releases between the tested points are inferred: every executed release fails, including the releases on both sides of the two later changes to the unboxing (LUCENE-7430, LUCENE-9150). Not fixed upstream as of 2026-10-26.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: three scripted prior-report searches (36 GitHub issue/PR, 7 commit, 7 downstream-repository, 60 mailing-list queries over dev@, java-user@, issues@ and commits@, 16 JIRA-archive and 6 web queries; 297, 372 and 275 distinct hits, 0 high overlap), manual GitHub searches across apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, 5 JQL queries over LUCENE and SOLR plus JIRA comment reads, 8 manual web searches, the changed files of the geo-related ones among all 413 open apache/lucene PRs, and the code history); no issue, PR, JIRA entry, list thread or web page reports this NullPointerException, and spatial3d on upstream main still has no null-bounds guard. Related items, not reports of this bug: #9568 (LUCENE-8522, closed, fixed in 7.6 in spatial-extras only, commit a5665d8): the same pattern (makeGeoPolygon returns null, an NPE follows) in the sibling spatial-extras Geo3dShapeFactory path, which now throws InvalidShapeException; the Geo3DPoint path still fails on 8.11.4 to main, after that fix; #7757 (LUCENE-6699, 2015): an NPE at the same PointInGeo3DShapeQuery call site from null XYZBounds, caused then by a bounds-computation bug in non-empty shapes and fixed in the shapes, before the empty-composite branch existed (DaddyWri: bounds 'are not expected to be null'); #9893 (LUCENE-8850, open enhancement) and apache/lucene-solr#709 (open, unmerged): reject zero-area Polygons in core, which would cover part of the trigger set but not the great-circle triangle or newShapeQuery; #8247 (LUCENE-7192): the origin of the tolerant branch.
Lucene commit tested
main 645d4fe14a20 (2026-10-30): the minimal test with gradle and with the standalone runner, assertions on and off, and both fixes with ./gradlew :lucene:spatial3d:test; the minimal test on the 10.6.0, 10.5.2 and 9.12.3 release jars; the delete-path probe re-run. The history probe ran on 17 releases from 6.1.0 to 10.5.2 and on branch_6x commits around LUCENE-7192 and LUCENE-7221. Most of the hunt's verification ran on main ea57360912ed; the spatial3d sources are identical at both commits.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:71-95 (at ea57360912ed) (fromPolygon: degenerate single polygon -> empty GeoCompositePolygon at :78-82, '// Polygon is degenerate' at :80; null components of a multi-polygon skipped at :86-92)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:50-58 (at ea57360912ed) (shapeBounds = new XYZBounds(); shape.getBounds(shapeBounds)), :76-81 (segments without point values return null), :107 (values.intersect(new PointInShapeIntersectVisitor(result, shape, shapeBounds)))
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInShapeIntersectVisitor.java:44-54 (at ea57360912ed) (constructor unboxes bounds.getMinimumX() .. getMaximumZ() with no null check; :48 is the throwing line)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/XYZBounds.java:36-53 (at ea57360912ed) (boxed Double bounds start null; 'Construct an empty bounds object')
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoComplexPolygon.java:776-784 (at ea57360912ed) ('If we have no bounds at all then the answer is "false"': null bounds treated as empty elsewhere in geom)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:42 (at ea57360912ed) (@lucene.experimental), :156-171 (newPolygonQuery javadoc: 'should ... be well-defined, with well-separated vertices'; no @throws), :241 (newShapeQuery)
  • SECURITY.md lines 28 (downstream sanitizes untrusted input), 38, 42, 46 (out-of-scope issues may be public), 50 (in-scope reports), as cited in the follow-up the security record
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:168-172, :241-243
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:77-93, :197-227
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPolygonFactory.java:275-276, :310-320, :677-702
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseCompositeShape.java:104-111, :134-138
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:53-58, :76-111
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/XYZBounds.java:35-54
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInShapeIntersectVisitor.java:44-54; geom/PlanetModel.java:771
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoComplexPolygon.java:776-784
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Plane.java:72 ("Construct a plane through two points and origin"); geom/GeoConvexPolygon.java:236-249 and geom/SidedPlane.java:59-67 (the (Vector, Vector, Vector) constructor GeoConvexPolygon uses; edge planes include the origin), for the background
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:42
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:79-81, :89-91; geom/GeoBaseCompositeShape.java:104-111; geom/GeoPolygonFactory.java:275-276
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:157-172, :235-243
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:34, :77-101
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShapeFactory.java:350-353
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/test/org/apache/lucene/spatial3d/TestGeo3DPoint.java:771-793
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:200-207
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:35, :71 (package-private class and varargs method); Geo3DDocValuesField.java:311-313 (newOutsidePolygonSort calls fromPolygon); geom/XYZBounds.java:317, :340, :363 (per-axis setters)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and an executed probe on the stock main jars.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.