All findings · Lucene finding · LFV-056 · taskexecutor-invokeall-interrupt

TaskExecutor#invokeAll returns while a task is still running when the calling thread is interrupted

When the thread running a concurrent search is interrupted, TaskExecutor#invokeAll stops waiting for the task it was blocked on and returns while that task still runs. With -ea Lucene's own AssertionError: Some tasks are still running? fires; with -da the search throws ThreadInterruptedException and the running slice's exception is lost.

Summary

Priority
Low, rank 831An interrupted caller of concurrent search returns while a task still runs, losing its exception and letting it touch a released reader (AlreadyClosedException, no memory unsafety), but Lucene does not support interrupt-based cancellation.
Impact
wrong error
Since
9.9.0 (#12523, #12689); the early return itself is olderintroduced 2023-10-05 · about 3 years
Affected
every release 9.9.0 to 10.6.0, and main
Status
documented
Found by
formal: TLA+ model checking (TLC), then a public-API JUnit

AI-assisted page: this finding was first signalled by a formal tool's own output 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

invokeAll waits for its tasks one future at a time. When the waiting thread is interrupted, Future.get() throws at once; the catch records the interrupt and moves to the next future, and never waits for the interrupted one again.

How concurrent search waits for its slices

  1. new IndexSearcher(reader, executor) splits the segments into slices and runs one task per slice through TaskExecutor#invokeAll.
  2. invokeAll submits count - 1 workers to the executor, which take tasks one at a time, and the calling thread takes tasks too until none is left. Then it calls Future.get() on each task in turn, so it returns only when every task is done.
  3. If the calling thread is interrupted while it waits, the JDK's Future.get() on a task that is still not done throws InterruptedException at once and clears the interrupt flag.
Diagram of the bug: TaskExecutor#invokeAll runs task 0 on an executor thread and task 1 on the calling thread; task 1 leaves the calling thread interrupted, so Future.get() on task 0 throws InterruptedException at once; collectResults records it and moves on, and invokeAll ends with 'AssertionError: Some tasks are still running?' (assertions on) or ThreadInterruptedException (assertions off) while task 0 is still running; with the fix, invokeAll keeps waiting for task 0 and reports the interrupt only after it completes.
invokeAll runs task 0 on an executor thread and task 1 on the calling thread; the caller is interrupted, get() on task 0 throws at once, and invokeAll ends while task 0 is still running. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You search with new IndexSearcher(reader, executor) and 2 slices: one slice runs on an executor thread, the other on your request thread, which then waits for the first one inside TaskExecutor#invokeAll.
  2. While the executor's slice is still running, the request is cancelled with Future.cancel(true), which interrupts the request thread.
  3. You expect the search to throw ThreadInterruptedException only after both slices have finished: the javadoc says invokeAll will "wait for them to complete", and the code asserts that no task is left running.
  4. Instead it ends at once. In the repro the executor's task waits on a latch for up to 10 s, yet invokeAll ends long before that bound (17-18 ms in a timing probe; with the fix it waits the full 10 s). With -ea: AssertionError: Some tasks are still running?; with -da: ThreadInterruptedException.
  5. The abandoned slice keeps running: its exception is never reported to you, and if you close the reader while the slice still reads it, the slice fails with an AlreadyClosedException that is never reported either.

Who is affected

Applications that run concurrent search (an IndexSearcher with an executor and at least 2 slices) and interrupt the searching thread, for example Future.cancel(true) for a request timeout or shutdownNow on the request pool. The default IndexSearcher without an executor is not affected. Rare: interrupt-based cancellation is unusual, and a Lucene committer said Lucene will not support it.

Mechanism, terms and numbers589 words

TaskExecutor#invokeAll runs some tasks on the executor and the rest on the calling thread, then waits for each future in turn. If the calling thread is interrupted while it waits, Future.get() throws InterruptedException; collectResults records it as a ThreadInterruptedException and moves on to the next future, but never waits for that one again. So invokeAll ends while the task is still running, against its javadoc ("wait for them to complete"), a code comment ("invokeAll does not leave any running tasks behind when it returns") and its own assertion, which fires with -ea. With -da the call throws ThreadInterruptedException; through IndexSearcher#search a slice keeps collecting after search has thrown, its exception is lost, and if the caller closes the reader while the slice still reads it, the slice fails with an AlreadyClosedException that is never reported. It needs concurrent search with an executor and an application that interrupts the searching thread. Present in this form since 9.9.0, when #12523 made invokeAll wait for all tasks on failure but left the interrupt path behind; every release through 10.6.0 and main.

The code (main 645d4fe14a20, TaskExecutor.java)

// invokeAll: count - 1 workers go to the executor, the caller runs tasks until none is left (:91-114)
return collectResults(futures);                                          // :115

for (Future<T> future : futures) {                                      // collectResults, :121
  try {
    results.add(future.get());                                           // :123
  } catch (InterruptedException e) {
    exc = IOUtils.useOrSuppress(exc, new ThreadInterruptedException(e)); // :124-125, then the next future
  } catch (ExecutionException e) {
    exc = IOUtils.useOrSuppress(exc, e.getCause());                      // :126-127
  }
}
assert assertAllFuturesCompleted(futures) : "Some tasks are still running?";   // :130
if (exc != null) { throw IOUtils.rethrowAlways(exc); }                   // :131-133
  • Every task is claimed before the wait. The caller keeps claiming task ids until none is left (:107-114), so in collectResults a future that is not done has been claimed by another thread, which is running it or about to start it.
  • Only the interrupted future is abandoned (one per interrupt). FutureTask.get() on a task that is still not done checks and clears the interrupt flag and throws. The loop records it and calls get() on the next future, which now blocks normally. An independent test with 3 tasks shows it: after the interrupt invokeAll kept waiting while both pool tasks were blocked, and returned as soon as task 2 was released, with task 0 still blocked ("done at return: [false, true, true]"). A second interrupt during a later wait would abandon a second future.
  • Outcome. -ea: the assertion at :130 fails. -da: :131-133 rethrow ThreadInterruptedException. The abandoned task's value or exception is never read.
  • Through IndexSearcher. search(Query, CollectorManager) runs one task per slice through taskExecutor.invokeAll(listTasks) (IndexSearcher.java:778-789). The throw skips reduce; the abandoned slice keeps collecting and reading the reader.
  • Not without an executor. new IndexSearcher(reader) uses new TaskExecutor(Runnable::run) (IndexSearcher.java:223): every task runs on the calling thread before the wait, so nothing is left running.

The numbers

mainwith the fix
minimal repro: when invokeAll endsat once (17-18 ms in a timing probe), task 0 still runningafter task 0 completes (its 10 s bound; 10.2 s through gradle)
what it throws, -eaAssertionError: Some tasks are still running?ThreadInterruptedException
what it throws, -daThreadInterruptedExceptionThreadInterruptedException
a later exception of task 0never reportedattached as suppressed
IndexSearcher#search, 2 slices, Future.cancel(true) on the requestthrows while the pool slice runs; the test then closes the reader and lets the slice read it: AlreadyClosedException, unreportedends after the slice completed; no AlreadyClosedException
Exposure and likelihood251 words
  • Who. TaskExecutor with a real executor, in practice new IndexSearcher(reader, executor) (opt-in, @lucene.experimental) with 2 or more slices, plus an application that interrupts the thread that called search. Other invokeAll callers, by reading only: kNN query rewrite (AbstractKnnVectorQuery), match highlighting (MatchRegionRetriever), the sandbox PostCollectionFaceting, the concurrent HNSW merge (HnswConcurrentMergeBuilder), and BP index reordering in lucene/misc (BPIndexReorderer).
  • With assertions on (test suites, CI): an AssertionError from inside Lucene instead of the ThreadInterruptedException the code means to throw.
  • With assertions off (production): the call throws while a slice still runs. That slice's exception is never reported to the caller, the slice may keep running (and using CPU, read from the code, not measured) after the request was cancelled, and if the caller closes the reader while the slice still reads it, the slice fails with an AlreadyClosedException that is never reported (observed with ByteBuffersDirectory; MMapDirectory and NIOFSDirectory not tested). No wrong result is returned, because the call throws.
  • Scale. Any index with 2 or more slices and one interrupt that reaches get() while that task is still not done; it reproduced in every controlled iteration (20 of 20 and 10 of 10).
  • Likelihood: rare. A Lucene committer said Lucene will not support interrupting long-running queries ("not going to support Thread.interrupt or any nonsense like that. you already have the exitable reader: use that", #12023), and the IndexSearcher javadoc warns that shutdownNow interrupts can close NIOFSDirectory file descriptors. No report was found.

Minimal repro

One JUnit class, one method, public API only, no index. Two tasks: the executor's task waits on a latch (up to 10 s), the caller's task interrupts the calling thread. The test checks that invokeAll ended only after the executor's task finished. It fails with assertions on and off.

TestTaskExecutorInterruptedCaller.java (one test class)53 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.search;

import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTaskExecutorInterruptedCaller extends LuceneTestCase {

  public void testInvokeAllWaitsForRunningTaskWhenCallerIsInterrupted() throws Exception {
    Semaphore started = new Semaphore(0);
    CountDownLatch release = new CountDownLatch(1);
    AtomicBoolean task0Done = new AtomicBoolean();
    Executor executor = r -> { new Thread(r).start(); started.acquireUninterruptibly(); };
    Callable<Void> task0 = () -> {
      started.release();
      release.await(10, TimeUnit.SECONDS);
      task0Done.set(true);
      return null;
    };
    Callable<Void> task1 = () -> { Thread.currentThread().interrupt(); return null; };
    Throwable thrown = null;
    try {
      new TaskExecutor(executor).invokeAll(List.of(task0, task1));
    } catch (Throwable t) {
      thrown = t;
    }
    boolean task0DoneAtReturn = task0Done.get();
    release.countDown();
    // expected: invokeAll ends only after task 0 has completed; actual: it ends while task 0 still runs
    assertTrue("expected invokeAll to wait for task 0, but it ended with " + thrown + " while task 0 was still running", task0DoneAtReturn);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTaskExecutorInterruptedCaller -Ptests.seed=DEADBEEF
Expected
invokeAll ends, normally or with ThreadInterruptedException, only after task 0 has completed.
Observed
It ends at once while task 0 still runs: AssertionError: Some tasks are still running? with -ea, ThreadInterruptedException with -da.

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

More files, environment, full output331 words

The class sits in org.apache.lucene.search next to TestTaskExecutor and uses only TaskExecutor(Executor) and invokeAll. Concurrency is the bug, so it needs one executor thread, but the order is fixed by a Semaphore and a latch, without sleeps: the executor starts a new thread per execute() and returns only once that thread's task has started, so the executor thread always runs task 0 and the calling thread runs task 1, the last one. Task 1 sets the calling thread's interrupt flag, standing for a cancellation interrupt that lands while the caller runs its own share; the independent test shows the same with a real Future.cancel(true) from another thread. Task 0's 10 s bound only lets a correct invokeAll, which keeps waiting for it, end.

The test applies to 9.12.0 and later, where the caller runs the last task itself; before 9.12.0 a port was used (run on 9.8.0, 9.9.0 and 9.11.1).

Regression test: this same class (or its one method in TestTaskExecutor); with the recommended fix it passes in about 10 s, the length of task 0's bound.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 9.12.3, 10.5.2, 10.6.0: fail; with the fix: passes
jdkOpenJDK 25.0.3
osLinux, aarch64
Command (environment)
cp $REPRO/TestTaskExecutorInterruptedCaller.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTaskExecutorInterruptedCaller -Ptests.seed=DEADBEEF
# assertions off fails too, with the other message: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (stack trimmed)
TestTaskExecutorInterruptedCaller > testInvokeAllWaitsForRunningTaskWhenCallerIsInterrupted FAILED
    java.lang.AssertionError: expected invokeAll to wait for task 0, but it ended with java.lang.AssertionError: Some tasks are still running? while task 0 was still running
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B96B8E1E2B8E23BD]:0)
        at org.apache.lucene.search.TestTaskExecutorInterruptedCaller.testInvokeAllWaitsForRunningTaskWhenCallerIsInterrupted(TestTaskExecutorInterruptedCaller.java:51)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false
    java.lang.AssertionError: expected invokeAll to wait for task 0, but it ended with org.apache.lucene.util.ThreadInterruptedException: java.lang.InterruptedException while task 0 was still running

# standalone runner: main fails with -ea and -da (20 of 20 derived seeds with -da); 10.5.2 (-ea, -da), 10.6.0 and 9.12.3 (-ea) fail the same way

Why it looks unintended

  • The javadoc says invokeAll will "wait for them to complete", a comment says it "does not leave any running tasks behind", and it asserts exactly that.
  • Since #12523 invokeAll waits for all tasks when a task fails; only the interrupt path still skips one.
  • Not a return-at-once policy either: after the interrupt it keeps waiting for the other tasks.
The full argument251 words
  • The class states the opposite. Class javadoc: "then waits for all tasks that have been executed in parallel on the executor to be completed and then returns" (TaskExecutor.java:41-43). invokeAll: "wait for them to complete and return the obtained results" (:71). Task#cancel comment: "We rather want to make sure that invokeAll does not leave any running tasks behind when it returns." (:185-186). The assertion: assertAllFuturesCompleted(futures) : "Some tasks are still running?" (:130).
  • History. #12523 (2023, first in 9.9.0): "This commit ensures that it instead waits for all tasks to complete before it re-throws the exception." It turned throw new ThreadInterruptedException(e) into record-and-continue, without waiting for the interrupted future again. #12689 (2023, 9.9.0) added the assertion and the comment. None of the PR threads that shaped the class (#12523, #12689, #13472, #13609, #13622, #13861) mentions interrupts.
  • The expectation upstream. #12278 (open): "Is it a reasonable expectation that once IndexSearcher#search(Query, CollectorManager) returns, that specific search has completed and has left no threads behind still working on it? I would expect this to be true". It is about task exceptions, not interrupts.
  • Tests. TestTaskExecutor has 10 tests; none mentions interrupts.
  • Not a coherent alternative. After the interrupt the loop still waits for every other running task and abandons only the one it was blocked on (executed).
  • A legal, anticipated trigger. Lucene's javadoc addresses "Applications using Thread#interrupt() or Future#cancel(boolean)" (FSDirectory.java:65-67, :77-82; NIOFSDirectory.java:41-46), and IndexWriter documents its own interrupt handling (IndexWriter.java:172-175).
The devil's-advocate check207 words

The strongest case for "intended" (a read-only cross-model review and the tie-break): Lucene does not support Thread#interrupt as cancellation (#12023), TaskExecutor is @lucene.experimental, and the JDK's own ExecutorService#invokeAll returns at once when interrupted, so an interrupted caller cannot expect the wait-for-all guarantee.

Why it fails: #12023 rejects making Lucene stop its work on interrupt; this report asks only that the existing interrupt handling keeps the invariant the class states, comments and asserts. @lucene.experimental marks API instability, not a licence to break a stated invariant. The JDK cancels the remaining tasks with cancel(true) before it returns (cooperatively, so a task that ignores interrupts can outlive its invokeAll too, but every task is asked to stop); Lucene's Task#cancel rejects that (assert mayInterruptIfRunning == false : "cancelling tasks that are running is not supported", :183), and today's code does wait for the other tasks, so it follows neither policy. The reviewers' one request, a genuine interrupt from another thread with standard pools and a control, was executed and fails the same way. Still, whether invokeAll should wait for all tasks or return at once after an interrupt is a policy choice for maintainers, not an established Lucene-wide guarantee; the report is that today's code follows neither.

Suggested fix

Recommended After an interrupt, keep waiting for the same future and report the interrupt at the end (validated)

In collectResults, when Future.get() throws InterruptedException, remember the interrupt once and call get() again on the same task; throw ThreadInterruptedException after every task is done (+16/-6 in TaskExecutor.java). The repro, TestTaskExecutor and TestIndexSearcher pass. Trade-off: an interrupted caller now waits for its running tasks.

while (true) {
  try {
    results.add(future.get());
    break;
  } catch (InterruptedException e) {
    if (interrupted == false) {   // report the interrupt once, keep waiting
      interrupted = true;
      exc = IOUtils.useOrSuppress(exc, new ThreadInterruptedException(e));
    }
  } catch (ExecutionException e) {
    exc = IOUtils.useOrSuppress(exc, e.getCause());
    break;
  }
}
All options and trade-offs2 options · 444 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20. The alternative, returning at once and documenting it, is a policy choice for maintainers; it was not run.

After an interrupt, keep waiting for the same future and report the interrupt at the end (validated) (recommended)

Wrap the get() in a loop: on InterruptedException record one ThreadInterruptedException and retry; on a result or an ExecutionException leave the loop.

while (true) {
  try {
    results.add(future.get());
    break;
  } catch (InterruptedException e) {
    if (interrupted == false) {   // report the interrupt once, keep waiting
      interrupted = true;
      exc = IOUtils.useOrSuppress(exc, new ThreadInterruptedException(e));
    }
  } catch (ExecutionException e) {
    exc = IOUtils.useOrSuppress(exc, e.getCause());
    break;
  }
}

Trade-offs

  • An interrupt no longer frees the calling thread before its running tasks end. It waits as long as it already waits for the other tasks today, and as after a task failure. Cancellation stays with the supported mechanisms (query timeouts, ExitableDirectoryReader).
  • The interrupt flag stays cleared, as today and as IndexWriter documents for its own interrupts; restoring it before the throw is a separate choice (not run). Further interrupts during the same wait are not recorded separately, and the flag is not restored for them.
  • Cancelling not-yet-started tasks would not help: when collectResults runs every task is already claimed, and cancel(false) cannot stop a task that is running (at most it would skip one claimed in the instant before its run() starts).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31): stock, the minimal test fails with assertions on and off. With the fix: the minimal test passes (10.2 s), TestTaskExecutor 10/10 and TestIndexSearcher 10 passed, 1 skipped by an assumption, 0 failures; the minimal test also passes with assertions off; TestTaskExecutor with seed 5EEDCAFE and 3 iterations: 30 tests, 0 failures. Formatting was not checked.
  • Earlier checks: the study's 5 tests pass (the interrupt is still reported; task 0's late exception is attached as suppressed), the independent tie-break's 3 tests pass with assertions on and off, and the TLA+ model with the fix is clean (73 distinct states).

Return at once on interrupt, and document it (not run)

On InterruptedException, stop waiting and throw; change the javadoc and the cancel comment to say that running tasks may outlive the call, and make the assertion skip the interrupted case.

Trade-offs

Frees the interrupted caller at once, like the JDK's invokeAll, but brings back what #12523 removed for task failures: tasks that outlive the call, lost exceptions, and slices that can touch a reader the caller closes next. Running tasks cannot be interrupted either, because Task#cancel rejects mayInterruptIfRunning.

Downstream note

Downstream, this matters to a search service that cancels requests by interrupting threads that run concurrent search: the cancelled request returns while a slice still runs on the shared pool, and that slice's failure is never reported.

How it was found

TLA+ model checking (TLC) of TaskExecutor#invokeAll, with an interrupt of the calling thread as one modelled event. TLC found a 10-state trace in which invokeAll returns while a task is still running (invariant NoRunningAfterReturn). An independent verifier reproduced it in Java; a one-test repro and a tie-break test with standard pools followed.

The method in full161 words

Method: formal (TLA+ model checking, the tool's own counterexample, then public-API JUnit tests).

  • Model. TaskExec.tla models invokeAll with nesting, rejecting executors, exceptions, cancellation and interrupts of the application thread (the JDK's FutureTask.get() throws only if the task is not done and the flag is set).
  • Counterexamples. Configuration te_intr: the worker runs task 1, the caller runs task 2, the caller is interrupted while it waits on future 1, and invokeAll completes while task 1 is still running: invariant NoRunningAfterReturn violated, 10-state trace. te_intr_exc_lost: the task then throws, and the exception is never reported (NoLostException). With the fix (keep waiting after an interrupt) every configuration is clean.
  • Confirmation. A separate verifier wrote a 5-test class (4 fail, the control passes, on 10.1.0, 10.5.2 and main). The repro was reduced to one test; a later tie-break wrote an independent test with standard JDK pools and a real Future.cancel(true), which fails 10 of 10 on main, 10.5.2 and 10.6.0.

Prior reports, history and checks

Prior reports

No prior report found (183 logged queries over GitHub, the JIRA archive, the mailing lists, downstream projects and the web, 2026-10-31, repeating the study's searches). Closest: open #12278, which states the "no threads left behind" expectation for task exceptions, and #12523, which fixed that case.

Searches2 searches

Verdict new. Related: #12278 (the expectation), #12523 (the change that left the interrupt path behind) and #12689 (the assertion). Open PR #16778 would make collectResults public with the same interrupt handling. Policy context: #12023 (interrupts as cancellation) and #5714 / LUCENE-4649 (throwing ThreadInterruptedException clears the flag, a separate point).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), the JIRA archive, dev@, java-user@, issues@ and commits@, downstream repositories, the web2026-10-31 (study searches 2026-10-22/23)a scripted prior-report search (85 queries) and own GitHub searches (76 queries), including the exact assertion text, assertAllFuturesCompleted, TaskExecutor ThreadInterruptedException and collectResults InterruptedException; 55 items read in fullno report; precursors #12278, #12523, #12689
Every PR behind the 20 commits on TaskExecutor.java; all 410 open PRs2026-10-31bodies, comments and review comments read; open PRs listed with their filesno interrupt discussion; four open PRs touch the file (#16778, #15208, #14524, #13860) and none changes the interrupt branch

History

In this form since 9.9.0: #12523 made invokeAll wait for all tasks on failure and left the interrupt path behind, and #12689 added the assertion. Every release 9.9.0 to 10.6.0 and main fails. 9.8.0 and the earlier IndexSearcher threw at once on interrupt, when every failure still returned early.

Versions tested7 rows

In the reported form, since 9.9.0. #12523 (73714934787 on main, backport 50f9a6731c2, 2023-10-05; first release 9.9.0) made invokeAll wait for all tasks before rethrowing and turned the interrupt branch into record-and-continue without a second get(); #12689 (1200ecce3a2, 2023-10-24) added the assertion and the cancel comment. #13861 (7757d43f4c9, 2024) moved the loop into collectResults; nothing since has touched it, and upstream main 2f424fd428d has the same file.

Older, the same early return without the invariant. 9.8.0, where TaskExecutor was created by #12398 (f44cc45cf8b), threw ThreadInterruptedException at once, as did IndexSearcher's concurrent search since LUCENE-2837 (4.0.0). Then every task failure returned early too, which #12278 reported and #12523 fixed. The registry's pin (#12523, 9.9.0) is the start of the broken invariant.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with -ea and -da (gradle and the standalone runner); the tie-break test fails 10 of 10
10.6.0, 10.5.2 affectedminimal repro fails (10.5.2 with -ea and -da, 10.6.0 with -ea, and with -da in the fact check); the tie-break test fails (-da)
9.12.0, 9.12.3, 10.0.0, 10.1.0 affectedminimal repro fails (10.1.0: the study's test)
9.9.0, 9.11.1 port9.9.0: -ea gives the assertion, -da ThreadInterruptedException; 9.11.1: -ea gives the assertion (-da not run). The caller does not run the last task before 9.12.0, so a port is used; 9.9.1, 9.9.2, 9.10.0 and 9.11.0 were read, not run
9.8.0 before the invariantthrows ThreadInterruptedException at once while task 0 runs (port); then every task failure returned early too
9.7.0 (and 4.0.0+ by reading) IndexSearcher, before TaskExecutorconcurrent search throws ThreadInterruptedException while a slice runs; the no-interrupt control passes
the recommended fix on main not affectedminimal repro, TestTaskExecutor and TestIndexSearcher pass; the tie-break and study tests pass

Independent checks

The study's verifier, a re-verification and an independent tie-break test (standard JDK pools, a real Future.cancel(true), 10 of 10) reproduced it on main and releases; a read-only cross-model review gave no verdict on the merits. This doc's gradle run reproduced it and validated the fix.

Checks and verdicts196 words

Every executed check ran on stock builds; the cross-model reviewer could only read code.

Each independent check and its verdict
CheckVerdictWhat it found
Study model (TLA+, TLC) counterexamplete_intr: NoRunningAfterReturn violated (10 states); te_intr_exc_lost: NoLostException violated; fix configurations clean.
Study verifier new bugOwn 5-test class: early return, lost exception, asynchronous interrupt and IndexSearcher with AlreadyClosedException fail; the control passes.
Re-verification realMinimal test on main with -ea and -da (20 of 20), 10.5.2, 10.6.0, 9.12.3; the fix passes.
Cross-model review (OpenAI GPT-5.6, refute lens) no verdictTwo attempts failed to start and one could only read the code; every 'not real' is labelled procedural; nothing was executed.
Second adjudication realIndependent test with standard JDK pools and Future.cancel(true): the cancelled search and the selective test fail 10 of 10, the control passes; 10.5.2 and 10.6.0 fail; the fix passes 3/3.
Follow-up (history, prior art) since 9.9.0 in this form; new9.7.0 to main executed; 183-query prior-art search.
This doc's runs reproduced, fix validatedGradle on 645d4fe14a20: the minimal test fails with -ea and -da; with the fix it, TestTaskExecutor and TestIndexSearcher pass.

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 notes593 words

Finding record

LFV-056, snapshot of 2026-11-06

Registry title
TaskExecutor#invokeAll returns while a task is still running if the caller is interrupted
Area
search / concurrency
Priority
Low, rank 831: An interrupted caller of concurrent search returns while a task still runs, losing its exception and letting it touch a released reader (AlreadyClosedException, no memory unsafety), but Lucene does not support interrupt-based cancellation.
Impact class
wrong error
Impact note
Applications that run concurrent search (IndexSearcher with an executor) and interrupt the calling thread (Future.cancel(true), shutdownNow); released 9.9.0-10.5.2.
Summary
You run a concurrent search and the calling thread is interrupted. You expect invokeAll to wait for its tasks or to propagate their failures. Instead it returns while a task still runs: with assertions on an AssertionError fires, and with assertions off the task's exception is lost and the task may keep reading a reader the caller has released.
Introduced
#12523 (73714934787) made invokeAll record an interrupt and continue without retrying get() [read] (2023-10-05)
First affected version
9.9.0 [read]
Affected versions
9.9.0-10.5.2 [read]; 10.1.0, 10.5.2 and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle in a scratch worktree with assertions on and off, and passes with the recommended fix, as do TestTaskExecutor and TestIndexSearcher. The standalone runner fails it on main (20 of 20 seeds), 10.5.2, 10.6.0 and 9.12.3; 9.8.0, 9.9.0 and 9.11.1 fail a port of it (the test itself needs 9.12.0+). The fact check also ran it on 10.6.0 with assertions off: it fails. An independent test with standard JDK pools and a real Future.cancel(true) fails on main, 10.5.2 and 10.6.0. Upstream main 2f424fd428d (2026-10-30) has the same TaskExecutor.java (read from the source).

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:91-115 (fork, caller loop, collectResults call), :118-135 (collectResults; :123 get, :124-125 record and continue, :130 assertion, :131-133 rethrow)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:222-223 (TaskExecutor(Runnable::run) without an executor), :778-789 (one task per slice, invokeAll, reduce)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:194-199, :207-212 (shutdownNow warnings; @lucene.experimental)
  • a Lucene main checkout (645d4fe14a20) git grep 'invokeAll(' over lucene/*/src/java: AbstractKnnVectorQuery.java:221, MatchRegionRetriever.java:306, PostCollectionFaceting.java:263, HnswConcurrentMergeBuilder.java:215, :254, :391, :437, lucene/misc BPIndexReorderer.java:186 (a TaskExecutor, :160, :800); not TaskExecutor calls: DrillSideways.java:508, PartitionMatcher.java:90 (ExecutorService), BpVectorReorderer (ForkJoinTask)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:41-43, :45 (@lucene.experimental), :71, :183, :185-186, :130
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestTaskExecutor.java (10 tests; no 'interrupt')
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:65-67, :77-82; store/NIOFSDirectory.java:41-46; index/IndexWriter.java:172-175
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:107-114 (every task claimed before collectResults), :94-96 and :170 (claim, then start), :183 (cancel rejects mayInterruptIfRunning), :191-195 (cancel completes only a task not yet started)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:172-175 (interrupt status cleared)

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 minimal repro.

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.