CheckIndex collects each segment's result with futures[i].get(). When that wait is interrupted, the catch (InterruptedException) only logs: it neither throws nor fills in a result. So the null result goes on to processSegmentInfoStatusResult, which reads segmentInfoStatus.error and throws.
CheckIndexchecks every segment of the last commit. With more than one thread (by default one per CPU core) it hands each segment's check to a thread pool.- The calling thread then waits for the results one by one, in segment order, with
Future.get(). - In Java,
Thread.interrupt()on a thread waiting inFuture.get()makes that call throwInterruptedException: the standard way to cancel a blocking call.
_0, get() throws, the handler logs the error and passes the missing (null) result on, which throws. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document, so the index has one segment,
_0. Runchecker.checkIndex(null, executor)with a thread pool whose only thread is busy, so the check of_0is still queued. The defaultcheckIndex()uses a pool too, with one thread per CPU core. - Interrupt the thread that runs the check while it waits for the result of
_0, for example to cancel the check. - You expect the check to report the interrupt, as its own handler is written to ("ERROR: Interrupted exception occurred when getting segment check result for segment _0"), or to throw Lucene's
ThreadInterruptedException. - Instead the handler only logs (when an infoStream is set) and carries on with no result for
_0(null), and the next step,processSegmentInfoStatusResult, reads that result'serrorfield:NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null, with assertions on and off. - The index files are unchanged (probes), but the interrupt flag is lost, checks on your own pool keep running, and the default
checkIndex()first waits up to 5 s for its pool (5.2 s in a test, 0.19 s with the fix).
Applications that run CheckIndex with more than one thread (the default on a multi-core machine, or their own pool) and stop it with Thread.interrupt(), for example a cancelled maintenance job. They get an NPE with no cause instead of an interrupt exception. Rare; no wrong verdict and no index change.
Mechanism, terms and numbers587 words
CheckIndex checks segments in parallel whenever it has an executor: the default checkIndex() creates a pool when threadCount > 1, and threadCount defaults to availableProcessors() (CheckIndex.java:541, :593-596 at 645d4fe14a20); checkIndex(List, ExecutorService) uses the caller's pool when it is given a non-null one. The calling thread collects each segment's result with segmentInfoStatus = futures[i].get() (:907-909). If it is interrupted while it waits, the catch (InterruptedException) (:910-918) prints "ERROR: Interrupted exception occurred when getting segment check result for segment ..." and falls through: no throw, no assignment, no continue. The null status goes to processSegmentInfoStatusResult (:929), which reads segmentInfoStatus.error (:978) and throws NullPointerException with no cause. The interrupt is swallowed and the remaining checks are not cancelled: on a caller-owned pool they keep running; on the default route the internal pool's finally waits up to 5 s for them, then calls shutdownNow(). No Status is returned and the index files are unchanged (probes). It came with concurrent CheckIndex (LUCENE-9662, #128, 2021), so every release from 8.11.0 to 10.6.0 and main are affected; restoring the flag and throwing ThreadInterruptedException (two lines) fixes it.
- Which path.
checkIndex()callscheckIndex(null)(:577-579). WiththreadCount > 1(defaultavailableProcessors(),:541) it createsExecutors.newFixedThreadPool(threadCount, ...)(:593-596) and callscheckIndex(onlySegments, executorService)(:600). WiththreadCount == 1it passesnulland the check runs on the caller, one segment after the other (:823-844).checkIndex(List, ExecutorService)is public, so a caller can also pass its own pool. - Submit. The concurrent branch submits one
CompletableFuture.supplyAsync(...)per segment, larger segments first (:871-896,:987-990). - Collect. For each segment, in order:
Status.SegmentInfoStatus segmentInfoStatus = null;(:907),segmentInfoStatus = futures[i].get();(:909). - The bug.
catch (InterruptedException e)(:910-918) writes to the infoStream, when one is set, the segment's buffered output, "ERROR: Interrupted exception occurred when getting segment check result for segment " + the name, and a stack trace. It does not throw, assign orcontinue, so execution reachesprocessSegmentInfoStatusResult(result, info, segmentInfoStatus)(:929) with a null status. - The crash.
processSegmentInfoStatusResultadds the null toresult.segmentInfos(:977) and readssegmentInfoStatus.error(:978):NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null, with no cause.
- Interrupt flag lost.
Future.getcleared it when it threw; the catch does not restore it. The independent reproduction's test readsinterruptFlagAfterCall=falseon both routes. - Checks keep running on a caller-owned pool. The futures after
iare never cancelled; the intended-behaviour check sawgetActiveCount()= 2 right after the throw. - Up to 5 s wait on the default route. The
finallyofcheckIndex(List)callsshutdown()andawaitTermination(5, TimeUnit.SECONDS)(:603-605). With the flag already gone it really waits, then callsshutdownNow()(:612). The independent reproduction's test: 5.2 s, against 0.19 s with the fix; the intended-behaviour check: about 5,015 ms against 4-8 ms for an uninterrupted control. Its owncatch (InterruptedException)(:606-610) also only logs.
CompletableFuture.get on a pending future throws InterruptedException whether the flag is already set when it starts to wait or arrives while it waits (JDK semantics). The minimal test sets it just before the call; the independent reproduction's test interrupts the caller only once it is seen parked in CompletableFuture.get. Both fail at the same two lines.
Exposure and likelihood342 words
- Who is hit. Applications that run
CheckIndexwith more than one thread and stop it withThread.interrupt(): a cancelled maintenance job, a shutdown hook, orshutdownNow()on a pool whose thread runs the check. Once the checks are submitted, the caller only collects results withfutures[i].get(), so on a long multi-segment check it can spend much of its time waiting there, and an interrupt in that phase lands in this catch (reasoned from the code, not measured). Any index with at least one segment to check; assertions on and off. - Default vs opt-in. The default
checkIndex()takes the executor path wheneveravailableProcessors()is greater than 1 (normally a machine with more than one core, though a container or JVM setting can lower it);checkIndex(List, ExecutorService)does whenever it is given a non-null executor. The command-line tool uses the same default unless-threadCountis given. Interrupting is the opt-in part. - Effect. An unchecked
NullPointerExceptionwith no cause instead of an interrupt-type exception: code that catchesThreadInterruptedException, or checks the interrupt flag, to recognise a cancellation sees an unexplained crash instead. Plus the side effects: the flag is lost, checks on a caller's pool keep running, and the default route waits up to 5 s. - What does not happen. No
Statusis returned, so there is no false "clean" verdict and no-exorcisecan follow. A security probe found the original index files unchanged (every file on the default route; on a caller-owned pool, where it commits a new document on purpose, the original segment files), nothing left open once the leftover checks finished, and the index still checking clean. These are the probes run, not a guarantee for every directory or filesystem. - Likelihood: rare. Interrupt-based cancellation of a running
CheckIndexis unusual.CheckIndexis@lucene.experimental. Future.cancel(true)caveat. If the interrupt comes from cancelling a task that runscheckIndex, the submitter's laterget()throwsCancellationException, not the NPE (JDK semantics; not executed). The NPE shows when the thread is interrupted directly or the task logs its own failure.
One JUnit test in lucene/core: a one-document index, a single-thread pool kept busy so the segment check stays queued, and an interrupted caller of checkIndex(null, executor). Deterministic, no timing, no interrupter thread. It fails on main and on 10.6.0, with assertions on and off.
/*
* 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.index;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ThreadInterruptedException;
public class TestCheckIndexInterruptedCaller extends LuceneTestCase {
public void testInterruptedCallerWithBusyExecutor() throws Exception {
Directory dir = new ByteBuffersDirectory();
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
}
ExecutorService executor = Executors.newSingleThreadExecutor();
CountDownLatch busy = new CountDownLatch(1);
executor.submit(
() -> {
busy.await();
return null;
});
try (CheckIndex checker = new CheckIndex(dir)) {
Thread.currentThread().interrupt();
checker.checkIndex(null, executor);
} catch (NullPointerException e) {
throw new AssertionError("expected a Status or ThreadInterruptedException, got: " + e, e);
} catch (ThreadInterruptedException expected) {
} finally {
Thread.interrupted();
busy.countDown();
executor.shutdown();
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
}
}
}
cp $REPRO/TestCheckIndexInterruptedCaller.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestCheckIndexInterruptedCaller -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
- Expected
checkIndexreturns aStatusor throwsThreadInterruptedException; with the fix it throwsThreadInterruptedException.- Observed
NullPointerException: Cannot read field "error" because "segmentInfoStatus" is nullatCheckIndex.java:978via:929, with assertions on and off.
More files, environment, full output282 words
Public API only, one method, single caller thread. The pool's thread is part of the API under test; a latch only keeps it busy, so the segment's future is still pending when get() is called. The test accepts both non-buggy outcomes (a returned Status, or ThreadInterruptedException); the NPE fails it with an explaining assertion (any other exception would fail it too). It does not check the interrupt flag, the leftover checks or the shutdown wait.
If a reviewer asks for an interrupt that arrives during the wait: the independent verifier's test interrupts the caller only after it sees it parked in CompletableFuture.get, on a busy newFixedThreadPool(1) and on the default checkIndex() with setThreadCount(2); both fail with the same NPE on 645d4fe14a20. It is a backup, not the regression test.
Suggested regression test: this same test, as its own class or as one method in TestCheckIndex. With the recommended fix it passes through the ThreadInterruptedException branch.
Seeds: DEADBEEF, and 5EED1234 with 5 iterations (5 of 5 fail, -ea and -da); the result does not depend on the seed.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true
TestCheckIndexInterruptedCaller > testInterruptedCallerWithBusyExecutor FAILED
java.lang.AssertionError: expected a Status or ThreadInterruptedException, got: java.lang.NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B7CC9B6393C858AA]:0)
at org.apache.lucene.index.TestCheckIndexInterruptedCaller.testInterruptedCallerWithBusyExecutor(TestCheckIndexInterruptedCaller.java:47)
Caused by:
java.lang.NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null
at org.apache.lucene.index.CheckIndex.processSegmentInfoStatusResult(CheckIndex.java:978)
at org.apache.lucene.index.CheckIndex.checkIndex(CheckIndex.java:929)
at org.apache.lucene.index.TestCheckIndexInterruptedCaller.testInterruptedCallerWithBusyExecutor(TestCheckIndexInterruptedCaller.java:45)
1 test completed, 1 failed
# the same with -Ptests.asserts=false; 10.6.0 jars (-ea, -da): CheckIndex.java:942 <- :893
# mid-wait test, both routes: thrown=java.lang.NullPointerException ... interruptFlagAfterCall=false- The handler expects interrupts: it logs "ERROR: Interrupted exception occurred ..." and means to go on, not to dereference the null it leaves.
- Lucene documents the unchecked
ThreadInterruptedExceptionfor interrupts (IndexWriterjavadoc); 18 of 22 interrupt handlers in lucene-core throw it. - An earlier NPE in this catch block was fixed in 9.1.0 (#637); with one thread the interrupted call completes.
The full argument300 words
- The handler's intent. The catch logs the interrupt, with a comment about ordering that output (
CheckIndex.java:911-917), then hands on the local it knows is null. Nothing marks the fall-through as deliberate. - The documented convention (stated for
IndexWriter;CheckIndexdocuments no interrupt outcome).IndexWriter.java:172-175: "If you callThread.interrupt()on a thread that's within IndexWriter, IndexWriter will try to catch this ..., and will then throw the unchecked exception ThreadInterruptedException and clear the interrupt status on the thread."ThreadInterruptedException.java:20: "Thrown by lucene on detecting that Thread.interrupt() had been called." Of the 22catch (InterruptedExceptionsites inlucene/core/src/java, 18 throw it; the two inCheckIndex(:606,:910) are the only ones that log and fall through. - One thread vs several. With
threadCount1 and an interrupted caller the check completes clean and keeps the flag (executed by the intended-behaviour check withRAFDirectoryand by a cross-model review withByteBuffersDirectory; other directories not run). So the same call behaves differently with one setting of one knob. - History. #637 ("null check on infoStream", 9.1.0) fixed an earlier NPE in this same block (an unguarded print with no infoStream set; its diff also guarded the print right after the try/catch, which by code reading threw on every concurrent check without an infoStream) and left the null status. The introducing PR #128 (98 review comments) and LUCENE-9662 (#10701, 15 comments) never discuss interrupts.
- Tests and docs. No upstream test asserts a
CheckIndexinterrupt outcome;CHANGES.txtandMIGRATE.mdsay nothing about it. - Acceptable outcomes. The sequential path ignores the interrupt; it does not throw
ThreadInterruptedException. Either of two outcomes avoids the NPE: report as the handler intends, or throwThreadInterruptedException; no single choice (a particular exception type, keeping the interrupt flag) is the only correct one.
The devil's-advocate check150 words
The strongest case for "not a bug" (the devil's-advocate verifier and the cross-model reviews): CheckIndex is experimental and no javadoc states an interrupt policy; the Directory javadocs warn that interrupting a thread blocked on NIO I/O closes the channel (FSDirectory.java:77-82), so Lucene does not support interrupts; and TestIndexWriter clears the flag before it calls TestUtil.checkIndex (TestIndexWriter.java:1036-1037).
Why it fails: the interrupted thread is parked in Future.get and does no I/O. The javadoc's remedy for applications that use Thread.interrupt() is RAFDirectory, and the verifier's probe with RAFDirectory fails the same way; the minimal test uses ByteBuffersDirectory. "Experimental" allows API changes, not a dereference of a known-null local. The TestIndexWriter line dates from 2010, 11 years before CheckIndex became concurrent, and serves the writer rollback. What the argument does show is that there is no written contract: the defect is the NPE, not a broken promise.
Recommended A. Restore the interrupt flag and throw ThreadInterruptedException (recommended)
At the end of the catch (InterruptedException), restore the flag and throw Lucene's interrupt exception: Thread.currentThread().interrupt(); throw new ThreadInterruptedException(e); (+2 lines and an import). The test then passes with assertions on and off, and TestCheckIndex and TestIndexWriter pass (129 tests, 0 failures).
if (infoStream != null) e.printStackTrace(infoStream);
Thread.currentThread().interrupt();
throw new ThreadInterruptedException(e);
// plus: import org.apache.lucene.util.ThreadInterruptedException;All options and trade-offs3 options ยท 614 words
Option A was validated with gradle in a scratch worktree of 645d4fe14a20, and option B the same way. Do not record an error status for the interrupted segment: processSegmentInfoStatusResult keeps only error-free segments in newSegments (CheckIndex.java:978-984) and exorciseIndex commits newSegments (:4606-4614), so -exorcise and repair tools would then drop an unchecked, possibly healthy segment. Do not silently skip it either: the Status could then say "clean" for an index that was not fully checked.
A. Restore the interrupt flag and throw ThreadInterruptedException (recommended) (validated by the checks)
At the end of the catch (InterruptedException e) around futures[i].get() (CheckIndex.java:910-918), add two lines and the import. One file, +3 lines.
if (infoStream != null) e.printStackTrace(infoStream);
Thread.currentThread().interrupt();
throw new ThreadInterruptedException(e);
// plus: import org.apache.lucene.util.ThreadInterruptedException;Trade-offs
- The caller gets Lucene's usual unchecked interrupt exception, with the
InterruptedExceptionas cause, and keeps its flag on a caller-owned pool. It is aRuntimeExceptionlike today's NPE, so nocatch (IOException)changes meaning. - Not covered: on a caller-owned pool the remaining checks still run; on the default route the
finallyblock's own catch (:606) clears the restored flag again (flagtrueon a caller's pool,falseon the default route). That route no longer waits 5 s:shutdownNow()runs at once (0.19 s against 5.2 s) and interrupts any check still running, as it already did after the wait. - Restoring the flag is a choice for the maintainers: the
IndexWriterjavadoc saysIndexWriterclears it. ThrowingThreadInterruptedExceptionwithout restoring it would match that wording; restoring it lets callers that check the flag see the cancellation.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF: the repro passes with-Ptests.asserts=trueandfalse(the test also accepts aStatus; theThreadInterruptedExceptionitself is shown by the mid-wait test and the diagram probe's fix runs). --tests 'org.apache.lucene.index.TestCheckIndex*' --tests org.apache.lucene.index.TestIndexWriter: 129 tests (9 + the repro + 119), 0 failures, 1 skipped; the same run without the fix has exactly 1 failure, the repro.:lucene:core:checkGoogleJavaFormatpasses.- The mid-wait test passes 2 of 2 with the fix shadowed (
ThreadInterruptedExceptionon both routes). - Earlier: the same two lines passed the independent verifier's test, the adjudication's re-run, the history A/B at the introducing commit and the registry re-run.
B. A, plus cancel the checks not yet collected and keep the flag on the default route (validated by the checks)
Before the throw, cancel futures[j] for j >= i (queued checks then never start; a running check still finishes, because CompletableFuture.cancel does not interrupt; from CompletableFuture semantics, not measured), and add Thread.currentThread().interrupt(); to the catch at :606-610. One file, +9 lines.
for (int j = i; j < numSegments; j++) {
if (futures[j] != null) {
futures[j].cancel(false);
}
}
Thread.currentThread().interrupt();
throw new ThreadInterruptedException(e);
// and in the catch at :606-610: Thread.currentThread().interrupt();Trade-offs
Fixes the side effects too, at the cost of more code for things that are not the reported bug; a check that is already running still goes on until it finishes (read-only, the work it would have done anyway), and on a caller-owned pool nothing stops it earlier.
What the checks validated
Gradle, same worktree and seed: the repro passes (-Ptests.asserts=true; B was not run with assertions off); the same suites pass (129 tests, 0 failures, 1 skipped); checkGoogleJavaFormat passes. The mid-wait test passes 2 of 2 and keeps the interrupt flag on both routes.
Wait for every result without giving up on the interrupt, then restore the flag before returning. Consistent with threadCount 1, but a long check could then not be cancelled at all. Not run.
Of the downstream code read, Elasticsearch runs its online index checks with one thread (not affected); the offline shard-repair tools of Elasticsearch and OpenSearch use the default thread count but stop on the exception before writing anything.
Code reading of CheckIndex's interrupt handler during a sweep of how Lucene APIs behave with caller-supplied executors, then a deterministic JUnit. The sweep's own interrupt cells interrupted a pool thread, not the caller, and stayed clean.
The method in full91 words
Non-formal. A hunt unit swept Lucene APIs that accept an Executor with a zoo of executor behaviours (rejecting, caller-runs, interrupting a worker, and so on) and compared their results. Its interrupt cells for CheckIndex interrupted a pool thread, which CheckIndex handles, and stayed clean. Reading the code next to those cells showed that the caller's own InterruptedException handler falls through with a null status. The finder then wrote a deterministic JUnit (a gated executor, the caller interrupted before the call), which failed on the first run with assertions on and off.
No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-27, re-checked 2026-10-31). Closest: #637, which made only the logging in the same catch block null-safe.
Searches2 searches
Context, not reports: #637 ("null check on infoStream", merged 2022-02-02, 9.1.0; empty body, no interrupt discussion; left the null status); #128 and #10701 (LUCENE-9662, the introducing change; nobody discussed interrupts); LUCENE-10071 and LUCENE-10092 (other concurrent-CheckIndex follow-ups); #16474 and #16476 (CheckIndex work on unreadable .si and commit files). Limits: keyword search only; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits), the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, downstream repositories (Elasticsearch, OpenSearch, Solr), the web | 2026-10-27 | a search script with 55 queries (20 GitHub, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA, 6 web); the finder's run and the devil's advocate's 35-query run; manual gh searches; the touched files of all 409 open apache/lucene PRs (10 touch CheckIndex.java, none this block) | no report of this bug; 429 distinct hits, 0 high overlap |
| GitHub (apache/lucene), read-only | 2026-10-31 | gh search issues and prs for "CheckIndex interrupted", "CheckIndex InterruptedException", "CheckIndex NullPointerException", "CheckIndex interrupt", "segmentInfoStatus" and the handler's message; the CheckIndex.java blob and newest commit on upstream main, branch_11x and branch_10_6 | nothing new (one unrelated 2009 hit, #2699); the code is unchanged upstream |
Introduced with concurrent CheckIndex itself (LUCENE-9662, #128, merged 2021-09-01; 8.x backport lucene-solr#2567) and first released in 8.11.0. Every release since, 10.6.0 and main are affected; never fixed.
Versions tested10 rows
Introduced by 424192e1704 (LUCENE-9662 "CheckIndex should be concurrent - parallelizing index check across segments", apache/lucene#128 by zacharymorn, merged 2021-09-01), backported to 8.x as apache/lucene-solr#2567 (965b54ce3fd, 2021-09-10). Before it, CheckIndex ran only sequentially on the caller, with no Future.get. At the introducing commit both repros fail and the two-line fix makes the verifier's executor route pass; at the parent 9c7f0d45eef the concurrent API does not exist. Symptom by version: from 9.1.0 on, all three executed concurrent routes give the null-status NPE (the sequential path never enters this catch). In 8.11.x, 9.0.0 and the introducing commit the handler's first line was an unguarded infoStream.println: with no infoStream (the default) that line throws an NPE first; with an infoStream set, the null-status NPE follows. #637 (9.1.0) made only the logging null-safe; it also guarded the print right after the try/catch, so by code reading a concurrent check without an infoStream threw an NPE in those earlier versions even with no interrupt. Upstream main 2f424fd428da, branch_11x and branch_10_6 are unchanged (2026-10-31).
| Version or commit | Verdict | Result |
|---|---|---|
parent 9c7f0d45eef, 8.10.1 | before the feature | the concurrent API does not exist (compile error) |
424192e1704 (#128) | introducing commit | both repros fail; the two-line fix makes the executor route pass |
| 8.11.0 | first release | NPE with -ea and -da: the null-status NPE with an infoStream set, otherwise the unguarded infoStream print throws first |
| 8.11.4, 9.0.0 | affected | as 8.11.0 |
| 9.1.0 | first with #637 | the null-status NPE on every executed concurrent route |
| 9.12.3, 10.0.0, 10.5.2 | affected | the null-status NPE (10.5.2 with -ea and -da) |
| 10.6.0 | affected | minimal test fails, -ea and -da (CheckIndex.java:942 via :893) |
main a9779ef3c193, 645d4fe14a20 | affected | every repro fails, -ea and -da (CheckIndex.java:978 via :929); uninterrupted controls pass |
upstream main 2f424fd428da, branch_11x, branch_10_6 | source read | same code (2026-10-31) |
main 645d4fe14a20 + fix A | fix | ThreadInterruptedException; minimal test and 129 suite tests pass |
The finder, cross-model reviews, independent verifiers (one a devil's advocate), an adjudication, a registry re-run, a credibility audit and this doc's runs all reproduced it; none judged it intended behaviour.
Checks and verdicts315 words
Every executed check hit the same NPE at processSegmentInfoStatusResult from checkIndex (on main CheckIndex.java:978 <- :929; the line numbers differ on older releases), and every uninterrupted control passed. The credibility audit kept the bug and lowered its score because CheckIndex is experimental.
| Check | Verdict | What it found |
|---|---|---|
| finder (code reading during an executor-behaviour sweep, then a JUnit) | candidate | Gated-executor JUnit, caller interrupted before the call: fails 1 of 1 on main (-ea, -da), 10.5.2 and 9.12.3. |
| cross-model review, refute lens (OpenAI GPT-5.6, executing) | real, high | Own JUnit fails on a caller-owned ThreadPoolExecutor and on the default checkIndex(); controls pass; intended-behaviour argument weak. |
| cross-model review, intended lens (OpenAI GPT-5.6, executing) | real, high | Own JUnit with a fixed pool fails; the uninterrupted control passes; with one thread the interrupted check completes clean and keeps the flag. |
| Independent reproducer | real (low severity) | Mid-wait interrupt on two routes: 2 of 2 fail (-ea, -da), 20 of 20 with --iters 10, also 10.5.2 and 9.12.3; passes with the two-line fix. |
| Intended-behaviour devil's advocate | real, high | RAFDirectory probe fails 2 of 4 (control and sequential observation pass) on main, 10.5.2 and 9.12.3; default route about 5,015 ms; getActiveCount() 2 after the throw. |
| adjudication | confirmed, high | Re-ran the independent reproduction: 2 of 2 fail (-ea), 10 of 10 (-da --iters 5); with the fix 2 of 2 pass. |
| registry re-run, credibility audit, minimisation | keep (score lowered: experimental API) | Fresh seeds: the independent reproduction 2 of 2 fail (-ea, -da), the fix 2 of 2 pass; the one-method test fails on 645d4fe14a20 (-ea, -da) and passes with the fix. |
| this doc (2026-10-31) | reproduced, fix validated | Gradle stock fails / fix A passes (-ea, -da); 129 suite tests, 0 failures with fix A; 10.6.0 fails; the independent reproduction's mid-wait test fails stock and passes with fixes A and B. |
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.
- PNG 492 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,319 words
- Registry title
- CheckIndex with more than one thread (the default checkIndex() on a multi-core machine, or checkIndex(onlySegments, executor)) throws NullPointerException when the checking thread is interrupted while it waits for a segment result: the InterruptedException handler logs the interrupt and then processes the still-null segment status
- Area
- index / CheckIndex concurrent segment check
- Priority
- Low, rank 833: Interrupting a multi-threaded CheckIndex gives an NPE instead of an interrupt exception without touching the index, a narrower interrupt-only wrong error than LFV-636.
- Impact class
- wrong error
- Impact note
- Applications that run CheckIndex with more than one thread (the default checkIndex(), because threadCount defaults to availableProcessors, CheckIndex.java:541, or checkIndex(List, ExecutorService)) and interrupt the checking thread while it waits in Future.get get an unchecked NullPointerException with no cause instead of an interrupt-type exception or the report-and-continue outcome the handler is written for; no Status is returned, so there is no false clean verdict, no exorcise and no index change [exec, security probe]; observed side effects: the interrupt flag ends up cleared, on a caller-owned pool the remaining segment checks keep running after the throw (getActiveCount() 2), and on the default route the internal pool's awaitTermination waits its full 5 s before shutdownNow (about 5,000 ms against a few ms for the uninterrupted control); any index size; every release since 8.11.0 [exec].
- Summary
- You run CheckIndex with more than one thread (the default checkIndex() on a multi-core machine, or checkIndex(onlySegments, executor)) and your application interrupts the checking thread while it waits for a segment's result, for example to cancel the check. You expect either the outcome the code's own handler is written for (it logs 'ERROR: Interrupted exception occurred when getting segment check result for segment ...' and goes on) or an interrupt-type exception such as ThreadInterruptedException, the convention IndexWriter documents. Instead the handler logs and then passes the still-null segment status to processSegmentInfoStatusResult, which throws NullPointerException ('Cannot read field "error" because "segmentInfoStatus" is null'), with the interrupt flag cleared and, on a caller-owned pool, the remaining segment checks still running.
- Introduced
- 424192e1704664dc0ebc55109feaad5990b945cb (LUCENE-9662 'CheckIndex should be concurrent - parallelizing index check across segments', apache/lucene#128 by zacharymorn; parent 9c7f0d45eef) on main, backported to 8.x as apache/lucene-solr#2567 (commit 965b54ce3fd, branch_8x) [exec: both repros fail at the javac-built introducing commit, and the two-line fix (restore the interrupt flag, throw ThreadInterruptedException) makes the independent reproduction's executor route pass there; at the parent the concurrent API does not exist (compile error); read: git blame at main puts the null initialisation, the catch block, the call and the dereference in 424192e1704, git log -S on the handler message finds only that commit]. Born with the feature: before it, CheckIndex checked segments sequentially on the caller thread with no Future.get. Later changes to the block: 56f49257ed6 (#637, 9.1.0, made the logging in the same catch null-safe and left the null status) and 64cf54a4bff (#12979, charset cleanup) (2021-09-01 (merged to main; commit dated 2021-08-31 19:24 -0700); 8.x backport 2021-09-10)
- First affected version
- 8.11.0 [exec] (first release with concurrent CheckIndex, through the 8.x backport); first 9.x release 9.0.0 [exec]; 8.10.1 is not affected [exec: the API does not exist, both repros fail to compile]
- Affected versions
- every release from 8.11.0 through 10.5.2, and main a9779ef3c193; never fixed. [exec] 8.11.0 (-ea and -da), 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), plus the introducing commit (follow-up history runs with the finder's and the independent reproduction's tests); the independent reproduction and the intended-behaviour check also executed 10.5.2 and 9.12.3 with their own tests. [read] the catch block and processSegmentInfoStatusResult have the same logic at every tag 9.0.0-10.5.2 and on main; branch_8_11 has no CheckIndex.java commit after the backport. Symptom by version: from 9.1.0 on every route gives the null-status NPE; in 8.11.x, 9.0.0 and the introducing commit the null-status NPE is executed with an infoStream set (the independent reproduction executor route), while with no infoStream (the CheckIndex default) an unguarded infoStream print in the same code throws an NPE first, the defect #637 fixed in 9.1.0 (per the prior-art read of the 9.0.0 source and the 8.11.0 bytecode, that unguarded print also breaks the uninterrupted concurrent path there). Not affected: 8.10.1 and earlier (no concurrent CheckIndex).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, 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-27, read-only; the prior-report record verdict new): a scripted prior-report search with 55 queries (20 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA archive, 6 web), 429 distinct hits, 0 high overlap, plus the finder's run, the intended-behaviour check's 35-query run, manual GitHub searches in apache/lucene and downstream repositories (Elasticsearch, OpenSearch, Solr), the JIRA archive and direct list queries; the files of all 409 open apache/lucene PRs were listed and the 10 that touch CheckIndex.java (#16847, #16474, #16475, #12872, #15376, #12194, #12636, #13948, #14716, #12314) were read: none changes the InterruptedException catch, segmentInfoStatus, processSegmentInfoStatusResult or the futures loop. Related, not this bug: #637 ('null check on infoStream', merged 2022-02-02, 9.1.0; made the logging in the same catch block null-safe for a null infoStream, no interrupt discussion, left the null status); #128 and #10701 (LUCENE-9662, the introducing change; no review or issue comment discusses interrupts or the null status); LUCENE-10071 and LUCENE-10092 (concurrent-CheckIndex follow-ups, no interrupt mention); #16474 and #16476 (CheckIndex NPE-adjacent work on unreadable .si and commit files, not this block). Not fixed upstream: the newest commit touching CheckIndex.java on main is f30bd1b9b4f1 (#16476, 2026-10-05), older than the commit under test; branch_10x and branch_10_5 have nothing newer touching the block
- Lucene commit tested
- Main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-31: the minimal test through gradle in a scratch worktree, stock and with fix A with assertions on and off, with fix B with assertions on; plusTestCheckIndex*andTestIndexWriter(129 tests, assertions on); the 10.6.0 release jars; the independent reproduction's mid-wait test stock and with fixes A and B. Earlier, on maina9779ef3c193: the finder, the independent reproduction, the intended-behaviour check, the adjudication, the registry re-run and the credibility audit; the cross-model reviews on mainea57360912ed(CheckIndex.javais the same blob at all three commits); the history runs on 8.11.0 to 10.5.2 and the introducing commit.
- Finding record
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:110 (at a9779ef3c193) (@lucene.experimental), :541 (threadCount = availableProcessors), :577-578 (checkIndex() -> checkIndex(null)), :588-616 (threadCount > 1 creates the internal pool; finally: shutdown, awaitTermination(5, SECONDS) at :605, its own InterruptedException catch at :606, shutdownNow), :618-625 (checkIndex(List, ExecutorService) javadoc: only the no-writer warning), :907 (segmentInfoStatus = null), :909 (futures[i].get()), :910-918 (catch InterruptedException: logs, no throw, no assignment), :929 (processSegmentInfoStatusResult(result, info, segmentInfoStatus)), :975-978 (segmentInfoStatus.error dereferenced); CheckIndex.java has no diff between ea57360912ed and a9779ef3c193 (git diff)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:172-175 (at a9779ef3c193) (Thread.interrupt -> unchecked ThreadInterruptedException, the documented interrupt convention); lucene/core/src/java/org/apache/lucene/util/ThreadInterruptedException.java (unchecked, 'Thrown by lucene on detecting that Thread.interrupt() had been called', per the intended-behaviour check)https://github.com/apache/lucene/pull/128 (introducing PR), https://github.com/apache/lucene/pull/637 (same catch block, logging only), https://github.com/apache/lucene/issues/10701 (LUCENE-9662)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:541, :577-579, :593-600, :603-612, :823-844, :871-896, :907-929, :975-985, :987-990
- Who is affected
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:110 (@lucene.experimental), :541, :593-596, :4892-4894 (command line)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:606-610, :910-918lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:172-175lucene/core/src/java/org/apache/lucene/util/ThreadInterruptedException.java:20-23lucene/core/src/java (grep: 22 catch (InterruptedException sites, 18 throw ThreadInterruptedException)lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:77-82; lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:1036-1037https://github.com/apache/lucene/pull/637
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:603-612, :910-918, :975-985, :4606-4614
- History
https://github.com/apache/lucene/pull/128, https://github.com/apache/lucene/pull/637, https://github.com/apache/lucene/issues/10701
- Diagrams
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:907-929, :975-978
- TestCheckIndexInterruptedCaller.java (lucene/core test, one method):
repro/LFV-631/src/org/apache/lucene/index/TestCheckIndexInterruptedCaller.java(56 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-631/bug.png(492 KB)
![LFV-631: a one-document index with segment _0 and a busy single-thread pool, so the check of _0 is still queued. The interrupted caller of checkIndex(null, executor) calls futures[0].get(); with the flag already set, get() clears it and throws InterruptedException at once (an interrupt that arrives while the caller waits does the same). The catch block logs 'ERROR: Interrupted exception occurred when getting segment check result for segment _0' and falls through with segmentInfoStatus = null; processSegmentInfoStatusResult reads segmentInfoStatus.error (CheckIndex.java:978) and throws NullPointerException, where Status or ThreadInterruptedException was expected.](../assets/LFV-631/bug.png)