All findings · Lucene finding · LFV-631 · checkindex-interrupted-caller-npe

An interrupted multi-threaded CheckIndex throws NullPointerException instead of reporting the interrupt

Interrupting the thread that runs a multi-threaded CheckIndex (the default checkIndex() on a multi-core machine) while it waits for a segment's result gives a bare NullPointerException instead of a reported interrupt or a ThreadInterruptedException. Every release from 8.11.0 to 10.6.0 and main; the index is not touched.

Summary

Priority
Low, rank 833Interrupting 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
wrong error
Since
8.11.0 (LUCENE-9662, concurrent CheckIndex, 2021)introduced 2021-09-01 · about 5 years
Affected
every release from 8.11.0 to 10.6.0, and main
Status
documented
Found by
code reading of the interrupt handler, then a deterministic JUnit

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

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.

How a multi-threaded CheckIndex waits

  1. CheckIndex checks 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.
  2. The calling thread then waits for the results one by one, in segment order, with Future.get().
  3. In Java, Thread.interrupt() on a thread waiting in Future.get() makes that call throw InterruptedException: the standard way to cancel a blocking call.
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.
The one-segment example: the interrupted caller asks for the result of segment _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.

The finding, step by step

  1. Index one document, so the index has one segment, _0. Run checker.checkIndex(null, executor) with a thread pool whose only thread is busy, so the check of _0 is still queued. The default checkIndex() uses a pool too, with one thread per CPU core.
  2. Interrupt the thread that runs the check while it waits for the result of _0, for example to cancel the check.
  3. 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.
  4. 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's error field: NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null, with assertions on and off.
  5. 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).

Who is affected

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.

The path (file:line at 645d4fe14a20, lucene/core/src/java/org/apache/lucene/index/CheckIndex.java)

  1. Which path. checkIndex() calls checkIndex(null) (:577-579). With threadCount > 1 (default availableProcessors(), :541) it creates Executors.newFixedThreadPool(threadCount, ...) (:593-596) and calls checkIndex(onlySegments, executorService) (:600). With threadCount == 1 it passes null and 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.
  2. Submit. The concurrent branch submits one CompletableFuture.supplyAsync(...) per segment, larger segments first (:871-896, :987-990).
  3. Collect. For each segment, in order: Status.SegmentInfoStatus segmentInfoStatus = null; (:907), segmentInfoStatus = futures[i].get(); (:909).
  4. 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 or continue, so execution reaches processSegmentInfoStatusResult(result, info, segmentInfoStatus) (:929) with a null status.
  5. The crash. processSegmentInfoStatusResult adds the null to result.segmentInfos (:977) and reads segmentInfoStatus.error (:978): NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null, with no cause.

Side effects (observed; not the core claim)

  • Interrupt flag lost. Future.get cleared it when it threw; the catch does not restore it. The independent reproduction's test reads interruptFlagAfterCall=false on both routes.
  • Checks keep running on a caller-owned pool. The futures after i are never cancelled; the intended-behaviour check saw getActiveCount() = 2 right after the throw.
  • Up to 5 s wait on the default route. The finally of checkIndex(List) calls shutdown() and awaitTermination(5, TimeUnit.SECONDS) (:603-605). With the flag already gone it really waits, then calls shutdownNow() (: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 own catch (InterruptedException) (:606-610) also only logs.

A flag set before the wait is the same event

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 CheckIndex with more than one thread and stop it with Thread.interrupt(): a cancelled maintenance job, a shutdown hook, or shutdownNow() on a pool whose thread runs the check. Once the checks are submitted, the caller only collects results with futures[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 whenever availableProcessors() 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 -threadCount is given. Interrupting is the opt-in part.
  • Effect. An unchecked NullPointerException with no cause instead of an interrupt-type exception: code that catches ThreadInterruptedException, 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 Status is returned, so there is no false "clean" verdict and no -exorcise can 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 CheckIndex is unusual. CheckIndex is @lucene.experimental.
  • Future.cancel(true) caveat. If the interrupt comes from cancelling a task that runs checkIndex, the submitter's later get() throws CancellationException, not the NPE (JDK semantics; not executed). The NPE shows when the thread is interrupted directly or the task logs its own failure.

Minimal repro

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.

TestCheckIndexInterruptedCaller.java (lucene/core test, one method)56 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.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));
    }
  }
}
Command
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
checkIndex returns a Status or throws ThreadInterruptedException; with the fix it throws ThreadInterruptedException.
Observed
NullPointerException: Cannot read field "error" because "segmentInfoStatus" is null at CheckIndex.java:978 via :929, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

# 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

Why it looks unintended

  • 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 ThreadInterruptedException for interrupts (IndexWriter javadoc); 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; CheckIndex documents no interrupt outcome). IndexWriter.java:172-175: "If you call Thread.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 22 catch (InterruptedException sites in lucene/core/src/java, 18 throw it; the two in CheckIndex (:606, :910) are the only ones that log and fall through.
  • One thread vs several. With threadCount 1 and an interrupted caller the check completes clean and keeps the flag (executed by the intended-behaviour check with RAFDirectory and by a cross-model review with ByteBuffersDirectory; 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 CheckIndex interrupt outcome; CHANGES.txt and MIGRATE.md say 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 throw ThreadInterruptedException; 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.

Suggested fix

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 InterruptedException as cause, and keeps its flag on a caller-owned pool. It is a RuntimeException like today's NPE, so no catch (IOException) changes meaning.
  • Not covered: on a caller-owned pool the remaining checks still run; on the default route the finally block's own catch (:606) clears the restored flag again (flag true on a caller's pool, false on 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 IndexWriter javadoc says IndexWriter clears it. Throwing ThreadInterruptedException without 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 645d4fe14a20 with the repro test added, seed DEADBEEF: the repro passes with -Ptests.asserts=true and false (the test also accepts a Status; the ThreadInterruptedException itself 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:checkGoogleJavaFormat passes.
  • The mid-wait test passes 2 of 2 with the fix shadowed (ThreadInterruptedException on 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.

C. Complete like the one-thread path

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits), the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, downstream repositories (Elasticsearch, OpenSearch, Solr), the web2026-10-27a 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-only2026-10-31gh 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_6nothing new (one unrelated 2009 hit, #2699); the code is unchanged upstream

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
parent 9c7f0d45eef, 8.10.1 before the featurethe concurrent API does not exist (compile error)
424192e1704 (#128) introducing commitboth repros fail; the two-line fix makes the executor route pass
8.11.0 first releaseNPE 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 affectedas 8.11.0
9.1.0 first with #637the null-status NPE on every executed concurrent route
9.12.3, 10.0.0, 10.5.2 affectedthe null-status NPE (10.5.2 with -ea and -da)
10.6.0 affectedminimal test fails, -ea and -da (CheckIndex.java:942 via :893)
main a9779ef3c193, 645d4fe14a20 affectedevery repro fails, -ea and -da (CheckIndex.java:978 via :929); uninterrupted controls pass
upstream main 2f424fd428da, branch_11x, branch_10_6 source readsame code (2026-10-31)
main 645d4fe14a20 + fix A fixThreadInterruptedException; minimal test and 129 suite tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (code reading during an executor-behaviour sweep, then a JUnit) candidateGated-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, highOwn 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, highOwn 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, highRAFDirectory 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, highRe-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 validatedGradle 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.

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,319 words

Finding record

LFV-631, snapshot of 2026-11-06

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; plus TestCheckIndex* and TestIndexWriter (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 main a9779ef3c193: the finder, the independent reproduction, the intended-behaviour check, the adjudication, the registry re-run and the credibility audit; the cross-model reviews on main ea57360912ed (CheckIndex.java is the same blob at all three commits); the history runs on 8.11.0 to 10.5.2 and the introducing commit.

Lucene and upstream sources

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-918
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:172-175
  • lucene/core/src/java/org/apache/lucene/util/ThreadInterruptedException.java:20-23
  • lucene/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-1037
  • https://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

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 the executed runs.

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.