Software engineer reviewing Java CompletableFuture timeout and supplier execution logs on a desktop monitor.

Java CompletableFuture Timed Out. Did the Supplier Stop?

Fri, Nov 6, 2026

A CompletableFuture that has already failed with TimeoutException can coexist with supplier code that is still executing. That is the operational boundary this playbook tests. Java SE 21 documents orTimeout as exceptionally completing the same CompletableFuture when it has not otherwise completed before the timeout; it does not document termination, interruption, rollback, or cancellation of the computation that was trying to complete that future. CompletableFuture additionally documents that it has no direct control over the computation producing its completion and that its own cancel(boolean) does not use interruption to control processing.

That difference matters whenever the supplier can mutate state after the caller has treated the timeout as a terminal business outcome. A returned timeout can stop a request path while a worker later writes a file, invokes another service, reserves capacity, updates a record, or emits a message. The question is therefore not simply whether future.isDone() became true. The release question is whether the lifecycle and effects promised by the application agree with the lifecycle the implementation actually owns.

The playbook below uses a disposable Java 21 fixture, named worker threads, CountDownLatch barriers, synthetic local files, explicit Future ownership for the repair case, and a case-by-case evidence ledger. The supplied preparation run used no production systems, customer data, credentials, remote services, or fabricated output. Every claim is separated into documented contract, predicted outcome, and captured local observation.

Establish the acceptance boundary before interpreting a timeout

Start by defining what a timeout is supposed to mean in the application. The Java API already answers a narrower question. Java SE 21 says CompletableFuture.orTimeout(long, TimeUnit) “exceptionally completes this CompletableFuture with a TimeoutException if not otherwise completed” before the deadline, returns this, and has existed since Java 9. supplyAsync, separately, runs the supplier asynchronously and uses its returned value to complete the future. Those two contracts describe future completion; neither sentence says that a timeout terminates the supplier.

That distinction should become the acceptance contract:

Evidence dimension

Question the gate must answer

Future identity

Is the object returned by orTimeout the same future?

Future terminal state

Did it complete normally, exceptionally with TimeoutException, or by cancellation?

Supplier lifecycle

Had the supplier returned when the future became terminal?

Interruption

Was the supplier thread interrupted?

Effect state

Had the synthetic effect happened before timeout? Did it happen later?

Execution ownership

Is there a separately owned task handle capable of requesting interruption?

Reconciliation

Did any earlier effect survive even though later work was stopped?

A release whose only contract is “after the deadline, callers receive a terminal timeout outcome” can potentially ACCEPT ordinary orTimeout behavior even while underlying work continues. A release whose contract is “no supplier effect may occur after timeout” cannot infer that property from orTimeout; the implementation needs REPAIR. If task termination or effect state cannot be established, the disposition is HOLD. If a timeout arrives after an earlier externally meaningful effect has already occurred, the work requires RECONCILE, because cancellation cannot retroactively erase that effect.

This is also the correct place to distinguish orTimeout from Future.get(timeout, unit). Future.get(timeout, unit) limits how long that particular caller waits; its contract says it throws TimeoutException when the wait times out. orTimeout instead changes the CompletableFuture into an exceptional terminal state. Those are materially different observations, and neither, by itself, says that worker execution stopped.

Refonte’s Python Subprocess Exit Is Not Pipe EOF separates direct-child termination from pipe EOF and downstream output completion; its experiment concerns process and file-descriptor ownership, not Java future completion or supplier execution. Refonte’s asyncio.wait_for article covers Python’s cooperative task-cancellation handshake and explicitly explains that the wrapper can remain pending while cancellation resolves. It does not answer whether Java CompletableFuture.orTimeout interrupts a supplier.

Refonte’s Java periodic-task article studies an exception that permanently suppresses later executions of one ScheduledFuture while the executor remains usable. It uses lifecycle separation similar to this playbook, but the controlled state is different: a recurring scheduled task has failed, whereas here a one-shot supplier can remain active after its CompletableFuture has already timed out.

Before any test, capture the runtime rather than writing “Java 21” from memory:

date -u +%Y-%m-%dT%H:%M:%SZ
java -version
javac -version
uname -a
cat /etc/os-release
stat -f -c '%T' /tmp
ldd --version | head -1

The preparation execution for this article recorded:

2026-11-06T10:53:35Z
openjdk version "21.0.12.1" 2026-08-18
OpenJDK Runtime Environment (build 21.0.12.1+1-1-deb13u1-Debian)
OpenJDK 64-Bit Server VM (build 21.0.12.1+1-1-deb13u1-Debian, mixed mode, sharing)
javac 21.0.12.1
Linux ... 6.18.57 ... x86_64 GNU/Linux
Debian GNU/Linux 13 (trixie)
overlayfs
ldd (Debian GLIBC 2.41-12+deb13u3) 2.41

Those are actual preparation observations, not a portability claim. The API contract comes from Java SE 21 documentation; thread scheduling latency, filesystem behavior and exact implementation details remain properties of the captured environment unless separately qualified.

Build a barrier-controlled fixture and pin what it actually proves

The fixture should remove “the supplier was probably still running” from the discussion. CountDownLatch supplies the ordering. Each main case starts one worker, waits until that worker announces that it has started, leaves it blocked at a barrier, waits for the future to reach the relevant terminal state, records whether the supplier has finished and whether the effect exists, and only then releases or interrupts the worker.

The use of an explicit executor is important for reproducibility. Java documents that the no-executor overload of supplyAsync normally uses ForkJoinPool.commonPool(). For this laboratory, a single named executor makes worker ownership obvious and prevents unrelated common-pool activity from becoming part of the evidence.

Save the following as CompletableFutureTimeoutLab.java:

import java.nio.file.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public final class CompletableFutureTimeoutLab {
    static final long SHORT_MS = 200;
    static final long WAIT_SECONDS = 5;

    record Obs(
            String id,
            String futureOutcome,
            boolean futureDone,
            boolean futureExceptional,
            boolean supplierDoneAtFuture,
            boolean interrupted,
            boolean effectBeforeRelease,
            boolean effectAfterSupplier,
            boolean taskCancelled,
            boolean prepared,
            boolean committed) {

        void print() {
            System.out.printf(
                "%s future=%s done=%s exceptional=%s " +
                "supplierDoneAtFuture=%s interrupted=%s " +
                "effectBeforeRelease=%s effectAfterSupplier=%s " +
                "taskCancelled=%s prepared=%s committed=%s%n",
                id, futureOutcome, futureDone, futureExceptional,
                supplierDoneAtFuture, interrupted,
                effectBeforeRelease, effectAfterSupplier,
                taskCancelled, prepared, committed);
        }
    }

    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("cf-timeout-lab-");
        try {
            caseTimeoutContinues(root).print();
            caseCompletesBeforeTimeout(root).print();
            caseCancelCompletableFutureDoesNotInterrupt(root).print();
            caseOwnedTaskCancellationStopsEffect(root).print();
            casePartialEffectNeedsReconciliation(root).print();
        } finally {
            try (var paths = Files.walk(root)) {
                paths.sorted((a, b) -> b.compareTo(a)).forEach(p -> {
                    try {
                        Files.deleteIfExists(p);
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                });
            }
        }
    }

    static ExecutorService single(String name) {
        return Executors.newSingleThreadExecutor(r -> {
            Thread t = new Thread(r, name);
            t.setDaemon(false);
            return t;
        });
    }

    static String outcome(CompletableFuture<?> cf) throws Exception {
        try {
            Object v = cf.get(WAIT_SECONDS, TimeUnit.SECONDS);
            return "VALUE:" + v;
        } catch (ExecutionException e) {
            return "EX:" + e.getCause().getClass().getSimpleName();
        } catch (CancellationException e) {
            return "CANCELLED";
        }
    }

    static Obs caseTimeoutContinues(Path root) throws Exception {
        ExecutorService pool = single("case-A-worker");
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(1);
        AtomicBoolean interrupted = new AtomicBoolean();
        Path effect = root.resolve("A-effect.txt");

        try {
            CompletableFuture<String> cf =
                CompletableFuture.supplyAsync(() -> {
                    started.countDown();
                    try {
                        release.await();
                        Files.writeString(effect, "A\n");
                        return "A-ok";
                    } catch (InterruptedException e) {
                        interrupted.set(true);
                        Thread.currentThread().interrupt();
                        return "A-interrupted";
                    } catch (Exception e) {
                        throw new CompletionException(e);
                    } finally {
                        done.countDown();
                    }
                }, pool).orTimeout(SHORT_MS, TimeUnit.MILLISECONDS);

            must(started.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "A did not start");

            String out = outcome(cf);
            boolean supplierDoneAtFuture = done.getCount() == 0;
            boolean before = Files.exists(effect);

            release.countDown();
            must(done.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "A did not finish");

            return new Obs(
                "A_TIMEOUT_CONTINUES",
                out,
                cf.isDone(),
                cf.isCompletedExceptionally(),
                supplierDoneAtFuture,
                interrupted.get(),
                before,
                Files.exists(effect),
                false, false, false);
        } finally {
            release.countDown();
            pool.shutdownNow();
            pool.awaitTermination(WAIT_SECONDS, TimeUnit.SECONDS);
        }
    }

    static Obs caseCompletesBeforeTimeout(Path root) throws Exception {
        ExecutorService pool = single("case-B-worker");
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(1);
        Path effect = root.resolve("B-effect.txt");

        try {
            CompletableFuture<String> cf =
                CompletableFuture.supplyAsync(() -> {
                    started.countDown();
                    try {
                        release.await();
                        Files.writeString(effect, "B\n");
                        return "B-ok";
                    } catch (Exception e) {
                        throw new CompletionException(e);
                    } finally {
                        done.countDown();
                    }
                }, pool).orTimeout(
                    WAIT_SECONDS, TimeUnit.SECONDS);

            must(started.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "B did not start");

            release.countDown();
            String out = outcome(cf);

            must(done.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "B did not finish");

            return new Obs(
                "B_BEFORE_TIMEOUT",
                out,
                cf.isDone(),
                cf.isCompletedExceptionally(),
                true,
                false,
                Files.exists(effect),
                Files.exists(effect),
                false, false, false);
        } finally {
            release.countDown();
            pool.shutdownNow();
            pool.awaitTermination(WAIT_SECONDS, TimeUnit.SECONDS);
        }
    }

    static Obs caseCancelCompletableFutureDoesNotInterrupt(Path root)
            throws Exception {

        ExecutorService pool = single("case-C-worker");
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(1);
        AtomicBoolean interrupted = new AtomicBoolean();
        Path effect = root.resolve("C-effect.txt");

        try {
            CompletableFuture<String> cf =
                CompletableFuture.supplyAsync(() -> {
                    started.countDown();
                    try {
                        release.await();
                        Files.writeString(effect, "C\n");
                        return "C-ok";
                    } catch (InterruptedException e) {
                        interrupted.set(true);
                        Thread.currentThread().interrupt();
                        return "C-interrupted";
                    } catch (Exception e) {
                        throw new CompletionException(e);
                    } finally {
                        done.countDown();
                    }
                }, pool);

            must(started.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "C did not start");
            must(cf.cancel(true), "C cancellation did not win");

            String out = outcome(cf);
            boolean before = Files.exists(effect);
            boolean supplierDoneAtFuture = done.getCount() == 0;

            release.countDown();
            must(done.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "C did not finish");

            return new Obs(
                "C_CF_CANCEL_CONTINUES",
                out,
                cf.isDone(),
                cf.isCompletedExceptionally(),
                supplierDoneAtFuture,
                interrupted.get(),
                before,
                Files.exists(effect),
                false, false, false);
        } finally {
            release.countDown();
            pool.shutdownNow();
            pool.awaitTermination(WAIT_SECONDS, TimeUnit.SECONDS);
        }
    }

    static Obs caseOwnedTaskCancellationStopsEffect(Path root)
            throws Exception {

        ExecutorService pool = single("case-D-worker");
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(1);
        AtomicBoolean interrupted = new AtomicBoolean();
        Path effect = root.resolve("D-effect.txt");

        try {
            CompletableFuture<String> result =
                new CompletableFuture<>();

            Future<?> task = pool.submit(() -> {
                started.countDown();
                try {
                    release.await();
                    Files.writeString(effect, "D\n");
                    result.complete("D-ok");
                } catch (InterruptedException e) {
                    interrupted.set(true);
                    Thread.currentThread().interrupt();
                    result.completeExceptionally(e);
                } catch (Exception e) {
                    result.completeExceptionally(e);
                } finally {
                    done.countDown();
                }
            });

            CompletableFuture<String> timed =
                result.orTimeout(
                    SHORT_MS, TimeUnit.MILLISECONDS);

            timed.whenComplete((v, t) -> {
                if (t instanceof TimeoutException)
                    task.cancel(true);
            });

            must(started.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "D did not start");

            String out = outcome(timed);

            must(done.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "D did not stop");

            boolean before = Files.exists(effect);
            release.countDown();

            return new Obs(
                "D_OWNED_TASK_CANCELS",
                out,
                timed.isDone(),
                timed.isCompletedExceptionally(),
                done.getCount() == 0,
                interrupted.get(),
                before,
                Files.exists(effect),
                task.isCancelled(),
                false, false);
        } finally {
            release.countDown();
            pool.shutdownNow();
            pool.awaitTermination(WAIT_SECONDS, TimeUnit.SECONDS);
        }
    }

    static Obs casePartialEffectNeedsReconciliation(Path root)
            throws Exception {

        ExecutorService pool = single("case-E-worker");
        CountDownLatch preparedLatch = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(1);
        AtomicBoolean interrupted = new AtomicBoolean();
        Path prepared = root.resolve("E-prepared.txt");
        Path committed = root.resolve("E-committed.txt");

        try {
            CompletableFuture<String> result =
                new CompletableFuture<>();

            Future<?> task = pool.submit(() -> {
                try {
                    Files.writeString(prepared, "prepared\n");
                    preparedLatch.countDown();

                    release.await();

                    Files.writeString(committed, "committed\n");
                    result.complete("E-ok");
                } catch (InterruptedException e) {
                    interrupted.set(true);
                    Thread.currentThread().interrupt();
                    result.completeExceptionally(e);
                } catch (Exception e) {
                    result.completeExceptionally(e);
                } finally {
                    done.countDown();
                }
            });

            CompletableFuture<String> timed =
                result.orTimeout(
                    SHORT_MS, TimeUnit.MILLISECONDS);

            timed.whenComplete((v, t) -> {
                if (t instanceof TimeoutException)
                    task.cancel(true);
            });

            must(preparedLatch.await(
                    WAIT_SECONDS, TimeUnit.SECONDS),
                 "E did not prepare");

            String out = outcome(timed);

            must(done.await(WAIT_SECONDS, TimeUnit.SECONDS),
                 "E did not stop");

            release.countDown();

            return new Obs(
                "E_PARTIAL_RECONCILE",
                out,
                timed.isDone(),
                timed.isCompletedExceptionally(),
                done.getCount() == 0,
                interrupted.get(),
                Files.exists(committed),
                Files.exists(committed),
                task.isCancelled(),
                Files.exists(prepared),
                Files.exists(committed));
        } finally {
            release.countDown();
            pool.shutdownNow();
            pool.awaitTermination(WAIT_SECONDS, TimeUnit.SECONDS);
        }
    }

    static void must(boolean condition, String message) {
        if (!condition)
            throw new AssertionError(message);
    }
}

Compile and capture the source identity before running:

sha256sum CompletableFutureTimeoutLab.java
javac CompletableFutureTimeoutLab.java
java -ea CompletableFutureTimeoutLab \
  | tee completablefuture-timeout-evidence.txt

The exact source used in the preparation execution above had SHA-256:

2e7f7f4d75bbc857718b764af3dcde70a586dd0350690412e242d56abbb78111

The fixture deliberately does not use an unbounded Thread.sleep as its ordering primitive. The timeout itself necessarily depends on Java’s timer machinery, but the important lifecycle points are protected by latches: the supplier must announce that it started, its effect is unreachable until the release barrier, and every wait in the harness has an upper bound. Java’s CompletableFuture documentation notes that timeout support uses a daemon thread for triggering and cancelling timeout actions rather than running the asynchronous computation itself.

The Python Path Containment: Prefix Checks Can Admit Escapes article uses a different domain but the same evidence discipline: one representation that “looks safe” is not substituted for the boundary that actually matters. Here, isDone() is the potentially misleading representation; supplier termination and effect state are the independent boundary. This comparison illustrates evidence discipline; it does not establish Java semantics.

Reproduce the timeout, then attack the most attractive false explanations

The documented prediction for Case A is intentionally modest: once the short deadline expires, the CompletableFuture should become exceptionally completed with TimeoutException if nothing else completed it first. Nothing in that documented orTimeout contract predicts that the supplier thread receives an interrupt. The broader CompletableFuture class contract reinforces the separation by stating that the future has no direct control over the computation causing its completion.

The captured preparation output was:

A_TIMEOUT_CONTINUES future=EX:TimeoutException done=true exceptional=true supplierDoneAtFuture=false interrupted=false effectBeforeRelease=false effectAfterSupplier=true taskCancelled=false prepared=false committed=false
B_BEFORE_TIMEOUT future=VALUE:B-ok done=true exceptional=false supplierDoneAtFuture=true interrupted=false effectBeforeRelease=true effectAfterSupplier=true taskCancelled=false prepared=false committed=false
C_CF_CANCEL_CONTINUES future=CANCELLED done=true exceptional=true supplierDoneAtFuture=false interrupted=false effectBeforeRelease=false effectAfterSupplier=true taskCancelled=false prepared=false committed=false
D_OWNED_TASK_CANCELS future=EX:TimeoutException done=true exceptional=true supplierDoneAtFuture=true interrupted=true effectBeforeRelease=false effectAfterSupplier=false taskCancelled=true prepared=false committed=false
E_PARTIAL_RECONCILE future=EX:TimeoutException done=true exceptional=true supplierDoneAtFuture=true interrupted=true effectBeforeRelease=false effectAfterSupplier=false taskCancelled=true prepared=true committed=false

Case A is the central observation. At the moment the future was already terminal with TimeoutException, supplierDoneAtFuture=false and the effect did not yet exist. The harness then opened the barrier. The supplier continued and created A-effect.txt. The final effectAfterSupplier=true therefore cannot be explained by an effect that happened just before the timeout but was observed late. The barrier establishes ordering: the file was impossible before release and appeared afterward.

That captured run is consistent with the Java 21 contract; it is not presented as a universal scheduler-timing guarantee. On an extremely overloaded or defective environment, a 200 ms timer might be delayed. Such a failure would leave the run unqualified rather than justify changing the expected lifecycle. Re-run with the environment recorded and with a larger laboratory timeout if necessary; do not convert the timeout duration into a latency SLA.

Case B is the positive control. The same supplier pattern is released before a five-second timeout. The future returns B-ok, the supplier finishes, and the effect exists. This proves that the fixture can observe the normal completion path; Case A is not simply a fixture that always forces exceptional completion.

Case C attacks the common repair assumption: “Fine, call cancel(true).” Java SE 21 explicitly says that CompletableFuture.cancel(boolean) completes the future with CancellationException and that mayInterruptIfRunning has no effect in this implementation because interrupts are not used to control processing. That is unusually direct documentation. The preparation run matched it: the future was cancelled, the supplier was still blocked, no interruption was observed, and after release the supplier still produced its effect.

This is where CompletableFuture differs from the more general Future abstraction. Future.cancel(true) defines an attempt to interrupt a running task when the implementation knows its executing thread, but CompletableFuture documents its own stronger limitation for this parameter. Treating the interface-level idea of cancellation as if every implementing class owns a stoppable task is an unsupported generalization.

The timelines are therefore:

Case A: orTimeout alone

supplier starts
     |
     v
waits at release barrier
     |
     |                 timer fires
     |                     |
     |                     v
     |             CompletableFuture = TimeoutException
     |                     |
     |              supplier still waiting
     |                     |
release barrier -----------+
     |
     v
supplier writes effect
     |
     v
supplier returns
     |
     v
future remains timed out
Case C: CompletableFuture.cancel(true)

supplier starts
     |
     v
waits at barrier
     |
     |            cancel(true)
     |                 |
     |                 v
     |          CompletableFuture cancelled
     |                 |
     |          no worker interrupt
     |
release barrier
     |
     v
supplier writes effect

The relevant false-positive hypothesis is now testable: “A terminal CompletableFuture means its supplier is terminal.” Case A falsifies that hypothesis in the captured Java 21 environment. Case C additionally shows that substituting cf.cancel(true) for orTimeout does not repair the execution-lifecycle boundary for CompletableFuture.

The comparison with Python tarfile Errors: Validate Staged Extraction is conceptual only. That article demonstrates that an extraction exception can coexist with files already written and insists on inspecting staging state independently of the exception. Here, a future timeout can coexist with supplier execution and later effects. Both reject “the API reported failure, therefore no effect survived” as an acceptance oracle, but their language runtime and mechanisms are unrelated.

Measure future state, execution state and effects as separate evidence

A useful incident ledger should not collapse all evidence into status=timeout. Java’s Future.isDone() returns true after normal completion, exceptional completion, or cancellation. CompletableFuture.isCompletedExceptionally() is similarly broad: it covers cancellation, explicit exceptional completion and abrupt exceptional completion of a stage. Those methods classify the future; they are not worker-lifecycle probes.

The ledger from the preparation run can be normalized as follows:

Case

Future observation

Supplier at future terminal point

Interrupt observed

Effect outcome

Interpretation

A

TimeoutException

Not done

No

Appears after release

Timeout completed future; underlying supplier continued

B

B-ok

Done

No

Present

Positive control: supplier completed before timeout

C

Cancelled

Not done

No

Appears after release

CompletableFuture.cancel(true) did not interrupt supplier

D

TimeoutException

Done after cancellation path

Yes

Absent

Separately owned task received interruption and stopped before effect

E

TimeoutException

Done after cancellation path

Yes

Prepared exists; committed absent

Later work stopped, but earlier effect survives

Case A is the most important release-gate row because it identifies the exact boundary. The future does not merely “return an error”; it enters a terminal exceptional state while the supplier remains pre-effect and alive. The later marker establishes continuation. Case C independently validates that the absence of interruption is not a special property of orTimeout alone; even direct cancellation of the CompletableFuture follows the class’s documented exceptional-completion model rather than task interruption.

Do not replace these direct observations with pool statistics. ThreadPoolExecutor exposes getActiveCount(), getTaskCount() and getCompletedTaskCount(), but the documentation labels these quantities approximate because task and thread states can change concurrently. They are useful monitoring signals; they are not the synchronization primitive for this acceptance test. A latch owned by the exact supplier is stronger evidence for “this supplier returned” than an aggregate pool count.

Likewise, logging "timeout" from the request thread proves almost nothing about the worker lifecycle. A thread dump can be useful supporting evidence during incident diagnosis, but it should not replace deterministic fixture ownership. In the laboratory, the supplier announces startup and completion through latches, and its effect is an independently observable file. That is enough to answer the narrow question without relying on thread-name heuristics.

There is another subtle state distinction. When the supplier eventually returns in Case A, the normal result it tries to contribute cannot replace the already-established timeout outcome. CompletableFuture documents that when multiple threads attempt completion, exceptional completion or cancellation, only one succeeds. The supplier’s computation can therefore finish successfully as computation while its associated CompletableFuture remains exceptionally completed. That is exactly why “supplier returned a value” and “future reports that value” are separate evidence fields.

The CloudFront Cache Isolation: Audit Personalized Responses playbook uses the same style of independent evidence: a response status alone is not enough; identity, cache key, origin behavior and returned marker are recorded separately. Its subject is HTTP caching, not Java concurrency, so it is not proof of CompletableFuture behavior. This approach makes the release decision from the boundary-specific ledger rather than one successful or failed surface signal.

For a real Java service, the local marker should be replaced by a safe domain-specific oracle: a synthetic database row in an isolated test database, a local mock receiving a request, an in-memory journal protected by synchronization, or another owned effect. Do not run the acceptance experiment against a live payment, email, queue, customer record, or production database merely to prove that timeouts can outlive supplier execution.

Repair execution ownership only when the application actually needs stop semantics

If the application’s real contract is only “the caller must stop waiting and receive a timeout result,” orTimeout already expresses that future-level behavior. Adding task cancellation merely because continued work looks untidy can create a new failure mode. The repair is required only when the application contract says that work must stop, must not cross a particular effect boundary after timeout, or must enter an explicit reconciliation workflow.

Case D demonstrates one bounded repair. Instead of allowing supplyAsync to hide execution control behind the CompletableFuture, the fixture separately owns the executing task returned by ExecutorService.submit. Java documents submit as returning a Future representing that submitted task. The result channel remains a CompletableFuture, so orTimeout still supplies the timeout outcome, but a timeout completion now invokes task.cancel(true) against the separately owned execution handle.

The captured Case D sequence was:

future=EX:TimeoutException
taskCancelled=true
interrupted=true
effectAfterSupplier=false

The supplier was waiting on CountDownLatch.await(), an interruptible operation. The task’s cancellation therefore had a cooperative point at which execution could stop before the marker write. This repair should be interpreted narrowly: it establishes that this owned task, in this fixture, responded to the cancellation request before crossing the protected effect boundary.

Do not rewrite that as “Future.cancel(true) guarantees the task stopped.” The Future contract describes an attempt to interrupt a running task; Java’s executor documentation likewise warns that stopping active tasks is best-effort and that code failing to respond to interruption may never terminate. Interruption is a cooperative mechanism. Code can catch InterruptedException and continue, fail to test its interrupted status during CPU-bound work, block in operations with different cancellation semantics, or invoke an external system that has already accepted an irreversible operation.

That is why the repaired orchestration must still have an independently stated effect boundary. For an interrupt-aware supplier, that might be “check cancellation before publication,” “do not send the final message after interruption,” or “abort before committing the synthetic transaction.” The cancellation request is control evidence; the absence or presence of the actual effect is acceptance evidence.

Case E shows why interruption is not rollback. Before waiting at the interruptible barrier, the task writes E-prepared.txt. After timeout, the owned task is cancelled and interrupted before it can write E-committed.txt. The captured row therefore says:

taskCancelled=true
interrupted=true
prepared=true
committed=false

This is RECONCILE, not a clean ACCEPT. The repair successfully prevented the later commit-like effect, but an earlier effect still exists. Whether that earlier state can be deleted, compensated, resumed, or retained depends on the owner’s business contract. Java concurrency APIs do not define that policy.

The safest design therefore treats timeout, cancellation and rollback as three separate mechanisms:

timeout:
    change the caller-visible outcome

cancellation:
    request that owned execution stop

reconciliation:
    classify and repair effects that already happened

That model also avoids overusing executor-wide shutdown. ExecutorService.shutdownNow() attempts to stop actively executing tasks and stops queued tasks from starting, but it operates on the executor rather than one business operation and is explicitly best-effort. A shared production pool is usually the wrong cancellation boundary for one timed-out request unless pool ownership was deliberately scoped that way.

The repair should be rechecked with the original negative case unchanged. The release test is not “did we add cancel(true)?” It is “after the future reaches its timeout state, can the protected effect still occur?” Case D answers that question for one interrupt-cooperative controlled supplier. Add a deliberately non-cooperative control before making a stronger application claim.

Turn the ledger into ACCEPT, REPAIR, HOLD or RECONCILE

The release decision follows the declared contract, not the emotional weight of seeing TimeoutException.

Evidence state

Decision

Reason

The application promises only bounded future completion; continued supplier work is explicitly allowed, bounded, owned and observed

ACCEPT

orTimeout behavior matches the stated future-level contract

The application promises no post-timeout effect, but Case A-style evidence shows the supplier can continue

REPAIR

The implementation does not own the execution boundary it promises

A separately owned task is cancelled, cooperates with interruption, and independent evidence proves the protected effect cannot occur after timeout

ACCEPT for that qualified boundary

The repaired mechanism and effect oracle agree

Cancellation was requested but supplier termination or effect state is unknown

HOLD

A cancellation request is not sufficient execution evidence

Some effect occurred before cancellation, even though later work was stopped

RECONCILE

Execution control cannot retroactively erase prior effects

Fixture source, runtime identity, case ordering, or output is incomplete

HOLD and rerun after fixing evidence

Release acceptance cannot be inferred from partial laboratory data

A non-cooperative supplier ignores interruption and crosses the effect boundary

REPAIR or HOLD, depending on ownership

The proposed cancellation design does not satisfy the application contract

This matrix should also protect against a dangerous semantic substitution: “future timed out” must never be silently rewritten as “operation was rolled back.” The Java CompletableFuture contract gives no rollback semantics. Its cancellation model is exceptional completion, and its own cancel(true) does not interrupt processing.

Similarly, “task was interrupted” should not be rewritten as “all effects were undone.” Case E is the negative control that prevents that conclusion. Reconciliation belongs to the domain owner: a database operation may need transaction rollback or a compensating update; a reservation may need release; an outbound message may be impossible to retract; a local temporary object may simply need deletion. This playbook does not pretend that one Java API can supply those semantics.

The HTTP Cache Revalidation: Validate Conditional Requests article likewise refuses to equate one protocol signal, such as a 304, with complete correctness. Its acceptance boundary includes representation selection, authorization and freshness state. The present article applies that evidence habit to a different subject: a TimeoutException is one future-state fact, not proof of supplier termination or effect rollback.

Operational ownership should be explicit before approval. The caller/API owner defines what a timeout promises. The execution owner decides whether task cancellation is required and provides an owned cancellation mechanism. The supplier owner defines interruption points and whether they are safe. The domain owner defines which effects are reversible and how partial work is reconciled. The release reviewer verifies the exact runtime, fixture identity, source hash, case ledger and cleanup status.

Do not generalize this fixture beyond those boundaries. It does not qualify virtual threads, structured concurrency, reactive-stream cancellation, network-client cancellation, JDBC statement cancellation, distributed transactions, message-broker delivery semantics, fork/join task interruption, or every executor implementation. It also does not establish a 200 ms real-time scheduling guarantee. Each of those would need its own documented contract and controlled evidence.

The OpenJDK implementation is consistent with the API-level interpretation: timeout machinery completes the CompletableFuture rather than acting as the supplier’s executing task. Consult the source and Oracle Java 21 documentation when evaluating implementation details: implementation code is supporting evidence, while the public API contract is the portability boundary. Java SE 21 documents timeout completion of the future, not automatic termination of its underlying supplier.

A release evidence bundle should therefore retain the runtime transcript, source SHA-256, source file, compilation result, complete fixture output, case ledger, declared effect boundary, cleanup result and the acceptance decision. If the implementation changes, repeat the same cases rather than carrying forward a previous “passed on Java 21” label.

Preserve the narrow scope when documenting the result

The incident statement that survives technical review is not “Java timeouts do not work.” orTimeout does what its documented contract says: if the future has not otherwise completed, it exceptionally completes that future with TimeoutException. The mistake is assigning a stronger lifecycle promise to that method than the API makes.

Likewise, the conclusion is not “never use CompletableFuture.supplyAsync.” It is appropriate when completion-stage orchestration is the required abstraction and continued underlying execution is acceptable or otherwise controlled. The problem appears when the application interprets future completion as task termination or rollback without owning evidence for either.

The strongest handover sentence from the preparation run is therefore:

Under OpenJDK 21.0.12.1 on Debian 13, the barrier-controlled Case A captured a CompletableFuture already exceptionally completed with TimeoutException while its supplier was still blocked and unfinished; after the fixture released the barrier, that supplier created its synthetic effect. Case C separately captured that CompletableFuture.cancel(true) did not interrupt the supplier. Case D showed a narrower repair using a separately owned submitted task whose interruption prevented the protected post-timeout effect, while Case E showed that an earlier effect still required reconciliation.

That paragraph is a statement about one captured laboratory execution. The portable part is narrower and comes from the Java SE documentation: orTimeout completes the future exceptionally; CompletableFuture does not directly control the computation that causes its completion; and its cancel(true) does not use interruption to control processing.

The practical release rule is compact. ACCEPT when the declared contract allows continued work or when a separately qualified cancellation design proves the required stop boundary. REPAIR when the application promises supplier termination or post-timeout effect suppression but only owns orTimeout or CompletableFuture.cancel(true). HOLD when execution or effects cannot be independently established. RECONCILE whenever meaningful work survived before cancellation, even if later work stopped cleanly.

Apply the same evidence discipline in Refonte Learning Software Engineering

Refonte Learning’s current Software Engineering program page describes coverage including software-engineering foundations, frontend and backend development, cloud architecture and microservices, real-time data processing, performance optimization, application security, scalable systems and a capstone project. It currently lists a three-month period and 12–14 hours per week, and describes practical software-engineering projects.

The page’s “Tools Taught” section does not explicitly list Java, CompletableFuture, JDK concurrency tooling, or this specific fixture. The published page therefore does not establish that the program teaches this laboratory or this API. The relevant connection is narrower: engineers practicing reproducible debugging, controlled negative tests, explicit lifecycle boundaries and release evidence can apply that discipline to the broader software-engineering work the program describes.

For readers evaluating that curriculum, the appropriate next step is simply to review the current Software Engineering program page against their own learning goals rather than infer specialized Java coverage that the published page does not explicitly state.