Concurrency

InterruptedException swallowed, breaking cancellation

Written and reviewed by Sahil Srivastav

ConcurrencyCancellationLifecycle
2026-05-09T04:11:07.552Z WARN  PollingWorker - interrupted
java.lang.InterruptedException: sleep interrupted
	at java.base/java.lang.Thread.sleep(Native Method)
	at com.example.poll.PollingWorker.run(PollingWorker.java:34)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)

# the same warning repeats every 5 seconds forever; the worker never exits
2026-05-09T04:11:12.558Z WARN  PollingWorker - interrupted
2026-05-09T04:11:17.561Z WARN  PollingWorker - interrupted

# and Future.cancel(true) reports success while the task keeps running:
cancelled=true  isDone=true  worker still executing (thread poll-worker-1 RUNNABLE)

What this error actually means

Interruption in Java is a single boolean flag on the `Thread` object plus a convention about how to respond to it. Every blocking method that declares `InterruptedException` does the same thing when it sees the flag set: it **clears the flag** and throws. That clearing is the part almost everyone misses. By the time your `catch` block runs, the thread no longer remembers that it was interrupted, and the only record of the request is the exception object you are holding.

So catching it and logging it does not merely hide an error — it destroys the cancellation request. The exception is the delivery mechanism, and dropping it is equivalent to receiving a shutdown signal and deciding not to pass it on. Every later check of `isInterrupted()` returns false, every subsequent blocking call proceeds normally, and the task continues indefinitely while the code that requested cancellation believes it succeeded.

This is what makes `Future.cancel(true)` report `true` for a task that keeps running. `cancel` marks the future cancelled and interrupts the worker thread; it makes no attempt to verify the task actually stopped, because it cannot. The return value means "the cancellation was requested and the task had not already completed", not "the task has stopped". A task that swallows the exception makes the two permanently different.

The failure then propagates outward into things that look unrelated. `ExecutorService.shutdownNow()` relies on interruption, so the pool never terminates and the JVM will not exit. Timeout wrappers that cancel on expiry leak the work they meant to abandon. A retry loop wrapping a swallowed interrupt becomes a busy loop that survives SIGTERM, which is how one swallowed exception turns into a pod that must be SIGKILLed on every deploy.

Causes, most common first

  1. 1Caught, logged, and execution continues. The single most common form. `catch (InterruptedException e) { log.warn("interrupted", e); }` inside a loop. The flag is cleared, the loop iterates, the next blocking call succeeds, and the task is now uncancellable. It looks like diligent error handling, which is why it survives review.
  2. 2Caught and swallowed entirely. An empty catch block, or one with a comment saying it cannot happen. Identical effect with no log line, so the only visible symptom is a pool that never terminates — with nothing to grep for.
  3. 3Wrapped in a runtime exception without restoring the flag. Rethrowing as `RuntimeException` propagates the failure but still leaves the flag clear. Any code above the catch that inspects interrupt status — a framework, a retry policy, a pool worker loop — concludes the thread was not interrupted and may retry the operation.
  4. 4Caught inside a broad `catch (Exception e)` with retry. A generic handler that retries on any exception treats the interrupt as a transient failure and immediately re-enters the blocking call. This produces the worst variant: an unstoppable retry loop that also floods the logs.
  5. 5Swallowed in a `finally` or cleanup path. `awaitTermination` or `join` inside cleanup, wrapped in a catch that ignores the exception to "keep shutdown simple". Shutdown is precisely when interruption carries information, so this is where losing it hurts most.
  6. 6A task that never blocks and never checks the flag. A pure CPU loop receives no `InterruptedException` at all, because nothing throws it. Interruption sets the flag and nothing more, so a compute-bound task is uncancellable unless it polls `isInterrupted()` itself. Not a swallowed exception, but the same observable failure.

When you see it

  • The same "interrupted" warning repeats on a fixed cadence forever instead of once
  • `Future.cancel(true)` returns `true` and the work visibly continues
  • `shutdownNow()` has no effect and `awaitTermination` always times out
  • The process ignores SIGTERM and is killed by the orchestrator after the grace period
  • A timeout fires, the caller returns an error, and the abandoned work keeps consuming a connection or a pool slot
  • A thread that should have exited at shutdown appears in the next dump doing normal work
  • Tests that cancel a task pass because they assert on the return value of `cancel`, not on the task stopping

How to diagnose it

Step 1

Find every catch of the exception and read what follows

This is a static bug with a static signature. For each site, the next statements must either exit the method or restore the flag. Anything else is a defect, and the grep finds all of them in seconds.

grep -rn -A 4 "catch (InterruptedException" src/main/java | grep -v "interrupt()" | grep -v "throw"

Step 2

Assert the flag is still set after cancellation

The decisive test. Cancel the task, then check from inside the task that it observes its own interruption. A task that reports `false` after a `cancel(true)` has swallowed it somewhere in the stack.

log.info("loop top: interrupted={}", Thread.currentThread().isInterrupted());

Step 3

Test cancellation by asserting the task stops

Never assert on the return value of `cancel`. Assert that the task’s side effects stop and the thread exits within a deadline — that is the property you care about, and the only one that catches this bug.

f.cancel(true);
assertTrue(stoppedLatch.await(2, TimeUnit.SECONDS), "task ignored cancellation");

Step 4

Look for the repeating-warning signature in logs

A single interrupt that produces the same log line on a regular cadence is proof. One interruption should yield one message and then silence as the thread exits; a repeating one means the loop is consuming the request and continuing.

grep -c "interrupted" app.log   # compare against the number of cancellations issued

Step 5

Check the thread is gone after shutdown

Dump after `shutdownNow()` and search for the worker by name. Its continued presence, especially in `RUNNABLE` doing ordinary work, closes the case.

jcmd <pid> Thread.print | grep -A 6 "poll-worker"

The fix

Prefer propagating the exception. If the method can declare `throws InterruptedException`, let it — the caller is better placed to decide whether to stop, and propagation preserves the flag semantics for free. Declaring it is a design statement that the method is cancellable, which is information callers need.

Where you cannot propagate — a `Runnable`, a framework callback, a constructor — restore the flag before leaving: `Thread.currentThread().interrupt()` and then return or break out of the loop. Restoring without exiting is only half the fix; the point of the flag is that the next blocking call or the pool worker sees it and stops.

Structure worker loops so the interrupt condition is the loop condition: `while (!Thread.currentThread().isInterrupted())`. That way both a thrown `InterruptedException` and a bare flag set on a compute-bound task terminate the loop, and the cancellation path does not depend on which blocking call happened to be in flight.

For CPU-bound work with no blocking call, poll the flag at a granularity that matches your cancellation deadline — once per batch, per row chunk, per iteration of the outer loop. There is no other way to make compute-bound work cancellable, and unbounded compute is a common reason shutdowns miss their budget.

Never let `InterruptedException` be caught by a broad `catch (Exception e)` that retries. Catch it first and separately, above the general handler, so a cancellation can never be mistaken for a transient failure. This ordering is what prevents the unstoppable retry loop.

In cleanup and shutdown code, restore the flag and force the pool down: catch, call `shutdownNow()`, restore the interrupt, and return. Shutdown paths are the last place you can afford to lose the signal, because a swallowed interrupt there means the process cannot exit.

Prefer timeouts over cancellation where the semantics allow it. A blocking call with a bounded wait ends on its own and needs no cooperation, which removes an entire category of "the task ignored us" problems.

// Broken: the flag is cleared, the loop continues, the task is uncancellable
while (true) {
    try {
        poll();
        Thread.sleep(5_000);
    } catch (InterruptedException e) {
        log.warn("interrupted", e);       // flag already cleared; loop continues
    }
}

// Also broken: propagates the failure but loses the cancellation request
try {
    queue.take();
} catch (InterruptedException e) {
    throw new IllegalStateException(e);   // flag still clear for callers above
}

// Correct: exit the loop, and restore the flag for whoever runs next on this thread
while (!Thread.currentThread().isInterrupted()) {
    try {
        poll();
        Thread.sleep(5_000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();   // restore
        break;                                // and stop
    }
}

// Correct: propagate where the signature allows it
Item next() throws InterruptedException {
    return queue.poll(1, TimeUnit.SECONDS);   // caller decides
}

// Correct: compute-bound work must poll, because nothing will throw
for (int i = 0; i < rows.size(); i++) {
    if ((i & 0x3FF) == 0 && Thread.currentThread().isInterrupted()) {
        throw new CancellationException("aborted at row " + i);
    }
    transform(rows.get(i));
}

How to stop it coming back

  • Make "catch of `InterruptedException` must restore the flag or exit" an enforced static-analysis rule, not a review convention
  • Order catch blocks so `InterruptedException` is always handled before any broad `catch (Exception)` that retries
  • Write cancellation tests that assert the task stops within a deadline, never that `cancel` returned true
  • Shape every worker loop as `while (!Thread.currentThread().isInterrupted())` so both delivery mechanisms terminate it
  • Poll the interrupt flag inside long compute loops at a known granularity, and document what that granularity costs you at shutdown
  • Include a SIGTERM-under-load test that fails if the process does not exit inside the grace period — that is what catches the remaining cases

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Why does catching the exception clear the interrupt status?

Every blocking method that throws `InterruptedException` clears the flag as it throws, because the exception *is* the delivery of the signal — carrying it in both places would mean a caller that handles the exception is also left with a flag it must clear. The design assumes you will either propagate the exception or restore the flag, which is why swallowing it is uniquely destructive.

Why does `Future.cancel(true)` return true when nothing stops?

Because the return value means the cancellation was requested and the task had not already finished. It interrupts the worker and marks the future cancelled; it has no way to verify the task honoured it. Cooperative cancellation cannot be confirmed by the canceller, so asserting on this boolean tests nothing.

Is restoring the flag enough, or must I also exit?

Both, in almost every case. Restoring tells the next blocking call and the pool worker that cancellation was requested; exiting is what actually stops the work you were asked to stop. Restoring and then continuing the loop leaves you doing unwanted work until the next blocking call happens to notice.

How do I cancel a task that never blocks?

You cannot, unless it cooperates. Interruption of a compute-bound thread only sets the flag — nothing throws, because nothing is waiting. The task must poll `Thread.currentThread().isInterrupted()` periodically and abandon its work. Choose the polling interval from your shutdown budget rather than arbitrarily.

Does this change with virtual threads?

The interrupt mechanism is identical: the flag is per-thread, blocking operations clear it and throw, and a swallowed exception is just as destructive. What changes is that structured concurrency makes cancellation propagate through a scope automatically, so a task that ignores it is contained rather than silently keeping a whole shutdown open.

Related

Other errors engineers hit next to this one

Full error and symptom index →