You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by rs...@apache.org on 2026/01/15 20:42:02 UTC

(httpcomponents-core) branch 5.4.x updated: ComplexCancellable: Fix race condition

This is an automated email from the ASF dual-hosted git repository.

rschmitt pushed a commit to branch 5.4.x
in repository https://gitbox.apache.org/repos/asf/httpcomponents-core.git


The following commit(s) were added to refs/heads/5.4.x by this push:
     new 15e9de48f ComplexCancellable: Fix race condition
15e9de48f is described below

commit 15e9de48fa13f269879cadb729b5c017d47e9c93
Author: Ryan Schmitt <rs...@apache.org>
AuthorDate: Wed Jan 14 21:08:56 2026 -0800

    ComplexCancellable: Fix race condition
    
    I discovered a race condition while investigating a sporadic test
    failure in the client:
    
    ```
    [ERROR]   HttpMinimalIntegrationTests$H2Tls>AbstractHttpAsyncFundamentalsTest.testSequentialGetRequests:86 ยป Execution org.apache.hc.core5.http2.H2StreamResetException: Cancelled
    ```
    
    I discovered that two threads are somehow racing when calling
    `ComplexCancellable.setDependency()`:
    
    ```
    setDependency dep=org.apache.hc.client5.http.impl.async.MinimalH2AsyncClient$$Lambda$655/251536271@633478cb ct=ForkJoinPool-1-worker-2
    setDependency dep=[id=1, reserved=false, removeClosed=false, localClosed=false, localReset=false] ct=httpclient-dispatch-1
    setDependency dep=org.apache.hc.client5.http.impl.async.MinimalH2AsyncClient$$Lambda$655/251536271@2e66671a ct=ForkJoinPool-1-worker-2
    setDependency dep=[id=3, reserved=false, removeClosed=false, localClosed=false, localReset=false] ct=httpclient-dispatch-1
    setDependency dep=org.apache.hc.client5.http.impl.async.MinimalH2AsyncClient$$Lambda$655/251536271@32f4e96 ct=ForkJoinPool-1-worker-2
    setDependency dep=[id=5, reserved=false, removeClosed=false, localClosed=false, localReset=false] ct=httpclient-dispatch-1
    ```
    
    The implementation of `setDependency` was performing a single CAS and
    interpreting failure to mean that the mark bit had been set -- in other
    words, that another thread had called `cancel()`, rather than
    `setDependency()`.
    
    This change corrects the implementation of `setDependency` by having it
    run the CAS in a loop. On each update failure, it checks if the mark bit
    has been set (nothing ever unsets it). If so, it calls
    `dependency.cancel()` and returns; else, it just tries again.
---
 .../hc/core5/concurrent/ComplexCancellable.java    |  8 ++++---
 .../core5/concurrent/TestComplexCancellable.java   | 27 ++++++++++++++++++++--
 2 files changed, 30 insertions(+), 5 deletions(-)

diff --git a/httpcore5/src/main/java/org/apache/hc/core5/concurrent/ComplexCancellable.java b/httpcore5/src/main/java/org/apache/hc/core5/concurrent/ComplexCancellable.java
index 1b4321975..eabfc530a 100644
--- a/httpcore5/src/main/java/org/apache/hc/core5/concurrent/ComplexCancellable.java
+++ b/httpcore5/src/main/java/org/apache/hc/core5/concurrent/ComplexCancellable.java
@@ -53,9 +53,11 @@ public boolean isCancelled() {
     @Override
     public void setDependency(final Cancellable dependency) {
         Args.notNull(dependency, "dependency");
-        final Cancellable actualDependency = dependencyRef.getReference();
-        if (!dependencyRef.compareAndSet(actualDependency, dependency, false, false)) {
-            dependency.cancel();
+        while (!dependencyRef.compareAndSet(dependencyRef.getReference(), dependency, false, false)) {
+            if (dependencyRef.isMarked()) {
+                dependency.cancel();
+                return;
+            }
         }
     }
 
diff --git a/httpcore5/src/test/java/org/apache/hc/core5/concurrent/TestComplexCancellable.java b/httpcore5/src/test/java/org/apache/hc/core5/concurrent/TestComplexCancellable.java
index 42806b9b8..e728bc645 100644
--- a/httpcore5/src/test/java/org/apache/hc/core5/concurrent/TestComplexCancellable.java
+++ b/httpcore5/src/test/java/org/apache/hc/core5/concurrent/TestComplexCancellable.java
@@ -27,11 +27,13 @@
 package org.apache.hc.core5.concurrent;
 
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 
 import org.hamcrest.CoreMatchers;
-import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.util.concurrent.CountDownLatch;
+
 class TestComplexCancellable {
 
     @Test
@@ -41,7 +43,7 @@ void testCancelled() {
         final BasicFuture<Object> dependency1 = new BasicFuture<>(null);
         cancellable.setDependency(dependency1);
 
-        Assertions.assertFalse(cancellable.isCancelled());
+        assertFalse(cancellable.isCancelled());
 
         cancellable.cancel();
         assertThat(cancellable.isCancelled(), CoreMatchers.is(true));
@@ -52,4 +54,25 @@ void testCancelled() {
         assertThat(dependency2.isCancelled(), CoreMatchers.is(true));
     }
 
+    @Test
+    void testSetDependencyRace() throws InterruptedException {
+        final ComplexCancellable cancellable = new ComplexCancellable();
+        final BasicFuture<Object> dependency1 = new BasicFuture<>(null);
+        final BasicFuture<Object> dependency2 = new BasicFuture<>(null);
+        final CountDownLatch latch = new CountDownLatch(2);
+
+        for (int i = 0; i < 2; i++) {
+            new Thread(() -> {
+                for (int j = 0; j < 5_000; j++) {
+                    cancellable.setDependency(dependency1);
+                    cancellable.setDependency(dependency2);
+                }
+                latch.countDown();
+            }).start();
+        }
+        latch.await();
+
+        assertFalse(dependency1.isCancelled());
+        assertFalse(dependency2.isCancelled());
+    }
 }