Skip to content

Commit 2afbd09

Browse files
committed
feat(reactive-routes,rest): fix CDI request ctx deactivation race
1 parent 26c975f commit 2afbd09

File tree

8 files changed

+194
-4
lines changed

8 files changed

+194
-4
lines changed

extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/runtime/RouteHandler.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.quarkus.security.identity.CurrentIdentityAssociation;
99
import io.quarkus.security.identity.SecurityIdentity;
1010
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
11+
import io.quarkus.vertx.http.runtime.RoutingUtils;
1112
import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
1213
import io.quarkus.vertx.web.Route;
1314
import io.vertx.core.AsyncResult;
@@ -22,7 +23,7 @@
2223
public abstract class RouteHandler implements Handler<RoutingContext> {
2324

2425
private static final String REQUEST_CONTEXT_STATE = "__cdi_req_ctx";
25-
26+
private static final String REACTIVE_ROUTES_KEY = "quarkus-reactive-routes";
2627
private final Event<SecurityIdentity> securityIdentityEvent;
2728
private final CurrentIdentityAssociation currentIdentityAssociation;
2829
private final CurrentVertxRequest currentVertxRequest;
@@ -69,6 +70,7 @@ public void handle(RoutingContext context) {
6970
ContextState state = context.get(REQUEST_CONTEXT_STATE);
7071
// Activate the context, i.e. set the thread locals, state can be null
7172
requestContext.activate(state);
73+
RoutingUtils.assumeCdiRequestContext(context, REACTIVE_ROUTES_KEY);
7274
currentVertxRequest.setCurrent(context);
7375
if (currentIdentityAssociation != null) {
7476
if (user != null) {
@@ -90,14 +92,18 @@ public void handle(RoutingContext context) {
9092
context.addEndHandler(new Handler<AsyncResult<Void>>() {
9193
@Override
9294
public void handle(AsyncResult<Void> result) {
93-
requestContext.destroy(endState);
95+
if (RoutingUtils.isCdiRequestContextOwner(context, REACTIVE_ROUTES_KEY)) {
96+
requestContext.destroy(endState);
97+
}
9498
}
9599
});
96100
}
97101
invoke(context);
98102
} finally {
99-
// Deactivate the context, i.e. cleanup the thread locals
100-
requestContext.deactivate();
103+
if (RoutingUtils.isCdiRequestContextOwner(context, REACTIVE_ROUTES_KEY)) {
104+
// Deactivate the context, i.e. cleanup the thread locals
105+
requestContext.deactivate();
106+
}
101107
}
102108
}
103109
}

extensions/resteasy-reactive/rest-servlet/runtime/src/main/java/io/quarkus/resteasy/reactive/server/servlet/runtime/ServletRequestContext.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import io.quarkus.resteasy.reactive.server.runtime.ResteasyReactiveSecurityContext;
4848
import io.quarkus.runtime.BlockingOperationControl;
4949
import io.quarkus.security.identity.SecurityIdentity;
50+
import io.quarkus.vertx.http.runtime.RoutingUtils;
5051
import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
5152
import io.undertow.server.HttpServerExchange;
5253
import io.undertow.server.ResponseCommitListener;
@@ -60,6 +61,7 @@
6061
public class ServletRequestContext extends ResteasyReactiveRequestContext
6162
implements ServerHttpRequest, ServerHttpResponse, ResponseCommitListener {
6263

64+
private static final String QUARKUS_REST_SERVLET_KEY = "quarkus-rest-servlet";
6365
private static final LazyValue<Event<SecurityIdentity>> SECURITY_IDENTITY_EVENT = new LazyValue<>(
6466
ServletRequestContext::createEvent);
6567
final RoutingContext context;
@@ -138,6 +140,11 @@ protected void handleRequestScopeActivation() {
138140
}
139141
}
140142

143+
@Override
144+
protected void onPreRequestScopeActivation() {
145+
RoutingUtils.assumeCdiRequestContext(context, QUARKUS_REST_SERVLET_KEY);
146+
}
147+
141148
static void fireSecurityIdentity(SecurityIdentity identity) {
142149
SECURITY_IDENTITY_EVENT.get().fire(identity);
143150
}

extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/QuarkusResteasyReactiveRequestContext.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
import io.quarkus.security.identity.CurrentIdentityAssociation;
1717
import io.quarkus.security.identity.SecurityIdentity;
1818
import io.quarkus.vertx.core.runtime.context.VertxContextSafetyToggle;
19+
import io.quarkus.vertx.http.runtime.RoutingUtils;
1920
import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
2021
import io.smallrye.common.vertx.VertxContext;
2122
import io.vertx.core.http.impl.HttpServerRequestInternal;
2223
import io.vertx.ext.web.RoutingContext;
2324

2425
public class QuarkusResteasyReactiveRequestContext extends VertxResteasyReactiveRequestContext {
2526

27+
private static final String QUARKUS_REST_KEY = "quarkus-rest";
2628
final CurrentIdentityAssociation association;
2729
boolean userSetup = false;
2830

@@ -51,6 +53,11 @@ protected void handleRequestScopeActivation() {
5153
}
5254
}
5355

56+
@Override
57+
protected void onPreRequestScopeActivation() {
58+
RoutingUtils.assumeCdiRequestContext(context, QUARKUS_REST_KEY);
59+
}
60+
5461
@Override
5562
protected void requestScopeDeactivated() {
5663
// we intentionally don't call 'CurrentRequestManager.set(null)'

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RoutingUtils.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package io.quarkus.vertx.http.runtime;
22

3+
import java.util.Objects;
34
import java.util.Set;
45

6+
import org.jboss.logging.Logger;
7+
58
import io.vertx.core.http.HttpHeaders;
69
import io.vertx.core.http.impl.MimeMapping;
710
import io.vertx.core.net.impl.URIDecoder;
@@ -10,10 +13,39 @@
1013

1114
public final class RoutingUtils {
1215

16+
private static final String CURRENT_CDI_REQUEST_CTX_OWNER = "io.quarkus.vertx.http.runtime#current-cdi-req-ctx-owner";
17+
private static final Logger LOG = Logger.getLogger(RoutingUtils.class);
18+
1319
private RoutingUtils() throws IllegalAccessException {
1420
throw new IllegalAccessException("Avoid direct instantiation");
1521
}
1622

23+
/**
24+
* Assumes ownership of the currently active CDI request context.
25+
* Thus, code invoked (even asynchronously) from previous route handlers shouldn't deactivate it.
26+
*
27+
* @param ctx RoutingContext
28+
* @param newOwner typically a route handler that needs CDI request context active
29+
*/
30+
public static void assumeCdiRequestContext(RoutingContext ctx, String newOwner) {
31+
var previousOwner = ctx.data().put(CURRENT_CDI_REQUEST_CTX_OWNER, Objects.requireNonNull(newOwner));
32+
if (previousOwner != null && LOG.isDebugEnabled()) {
33+
LOG.debugf("CDI request context owner has changed from '%s' to '%s'", previousOwner, newOwner);
34+
}
35+
}
36+
37+
/**
38+
* Enables route handlers to determine if they can deactivate/destroy CDI request context without impacting
39+
* any other extension.
40+
*
41+
* @param ctx RoutingContext
42+
* @param owner typically a route handler that needs CDI request context active
43+
* @return true if the CDI request context is owned by the {@code owner}
44+
*/
45+
public static boolean isCdiRequestContextOwner(RoutingContext ctx, String owner) {
46+
return owner.equals(ctx.get(CURRENT_CDI_REQUEST_CTX_OWNER));
47+
}
48+
1749
/**
1850
* Get the normalized and decoded path:
1951
* - normalize based on RFC3986

independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/core/AbstractResteasyReactiveContext.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ public void requireCDIRequestScope() {
246246
if (requestScopeActivated) {
247247
return;
248248
}
249+
onPreRequestScopeActivation();
249250
if (isRequestScopeManagementRequired()) {
250251
if (requestContext.isRequestContextActive()) {
251252
// req. context is already active, just reuse existing one
@@ -274,6 +275,10 @@ public ThreadSetupAction.ThreadState captureCDIRequestScope() {
274275

275276
protected abstract void handleRequestScopeActivation();
276277

278+
protected void onPreRequestScopeActivation() {
279+
// by default do nothing
280+
}
281+
277282
/**
278283
* Restarts handler chain processing on a chain that does not target a specific resource
279284
* <p>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.quarkus.it.openapi.security;
2+
3+
import jakarta.enterprise.context.ApplicationScoped;
4+
5+
import io.quarkus.logging.Log;
6+
7+
@ApplicationScoped
8+
public class FailureStorage {
9+
10+
private volatile Throwable throwable = null;
11+
12+
public Throwable getThrowable() {
13+
return throwable;
14+
}
15+
16+
public void setThrowable(Throwable throwable) {
17+
Log.info("Setting throwable value to " + throwable);
18+
this.throwable = throwable;
19+
}
20+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,91 @@
11
package io.quarkus.it.openapi.security;
22

33
import jakarta.annotation.security.RolesAllowed;
4+
import jakarta.enterprise.event.Observes;
5+
import jakarta.inject.Inject;
6+
import jakarta.ws.rs.DELETE;
47
import jakarta.ws.rs.GET;
58
import jakarta.ws.rs.Path;
69
import jakarta.ws.rs.core.Context;
10+
import jakarta.ws.rs.core.HttpHeaders;
711
import jakarta.ws.rs.core.SecurityContext;
812

913
import io.quarkus.vertx.web.RouteFilter;
14+
import io.vertx.core.Handler;
15+
import io.vertx.ext.web.Router;
1016
import io.vertx.ext.web.RoutingContext;
1117

1218
@Path("/security")
1319
public class TestSecurityResource {
1420

21+
public static final String TEST_HEADER_NAME = "test-security-header";
22+
public static final String TEST_HEADER_VALUE = "hush-hush";
23+
public static int REQUEST_TIMEOUT = 3;
24+
25+
@Inject
26+
FailureStorage failureStorage;
27+
28+
@Context
29+
HttpHeaders httpHeaders;
30+
1531
@RolesAllowed("admin")
1632
@GET
1733
@Path("reactive-routes")
1834
public String reactiveRoutes(@Context SecurityContext securityContext) {
1935
return securityContext.getUserPrincipal().getName();
2036
}
2137

38+
@RolesAllowed("admin")
39+
@GET
40+
@Path("reactive-routes-with-delayed-response")
41+
public String reactiveRoutesWithDelayedResponse(@Context SecurityContext securityContext) throws InterruptedException {
42+
Thread.sleep(REQUEST_TIMEOUT);
43+
try {
44+
// ATM this code is not invoked every single time, it is timing issue
45+
// but on my laptop it is invoked 7 times out of 10, therefore, if re-run multiple times,
46+
// it sufficiently reproduces our original issue with accessing CDI request context
47+
var headerValue = httpHeaders.getHeaderString(TEST_HEADER_NAME);
48+
49+
// just to do something - check the expected value
50+
if (!TEST_HEADER_VALUE.equals(headerValue)) {
51+
throw new IllegalStateException(
52+
"Invalid header value, got '%s',but expected '%s' ".formatted(headerValue, TEST_HEADER_VALUE));
53+
}
54+
} catch (Throwable t) {
55+
failureStorage.setThrowable(t);
56+
}
57+
return securityContext.getUserPrincipal().getName();
58+
}
59+
60+
@Path("throwable")
61+
@GET
62+
public String getThrowable() {
63+
return String.valueOf(failureStorage.getThrowable());
64+
}
65+
66+
@Path("empty-failure-storage")
67+
@DELETE
68+
public void emptyFailureStorage() {
69+
failureStorage.setThrowable(null);
70+
}
71+
2272
@RouteFilter(401)
2373
public void doNothing(RoutingContext routingContext) {
2474
// here so that the Reactive Routes extension activates CDI request context
2575
routingContext.response().putHeader("reactive-routes-filter", "true");
2676
routingContext.next();
2777
}
2878

79+
void addFailureHandler(@Observes Router router) {
80+
// this is necessary, because before the fix, the ContextNotActiveException was sometimes
81+
// thrown in the CDI interceptors and handled by the QuarkusErrorHandler
82+
router.route().order(Integer.MAX_VALUE - 100).failureHandler(new Handler<RoutingContext>() {
83+
@Override
84+
public void handle(RoutingContext routingContext) {
85+
failureStorage.setThrowable(routingContext.failure());
86+
routingContext.end();
87+
}
88+
});
89+
}
90+
2991
}

integration-tests/openapi/src/test/java/io/quarkus/it/openapi/security/TestSecurityReactiveRoutesTest.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
package io.quarkus.it.openapi.security;
22

3+
import static io.quarkus.it.openapi.security.TestSecurityResource.TEST_HEADER_NAME;
4+
import static io.quarkus.it.openapi.security.TestSecurityResource.TEST_HEADER_VALUE;
5+
import static org.apache.http.params.CoreConnectionPNames.SO_TIMEOUT;
36
import static org.hamcrest.Matchers.is;
47

8+
import java.net.SocketTimeoutException;
9+
import java.time.Duration;
10+
11+
import org.assertj.core.api.Assertions;
512
import org.junit.jupiter.api.Test;
613

714
import io.quarkus.test.common.http.TestHTTPEndpoint;
815
import io.quarkus.test.junit.QuarkusTest;
916
import io.quarkus.test.security.TestSecurity;
1017
import io.restassured.RestAssured;
18+
import io.restassured.config.HttpClientConfig;
1119

1220
@QuarkusTest
1321
@TestHTTPEndpoint(TestSecurityResource.class)
@@ -23,4 +31,47 @@ public void testSecurityWithReactiveRoutesAndQuarkusRest() {
2331
.body(is("Martin"));
2432
}
2533

34+
/**
35+
* This verifies that CDI request context activated by Reactive Routes is not deactivated/destroyed while
36+
* Quarkus REST needs it. Before the fix, there was a racy behavior. Sometimes during the CDI interceptors processing,
37+
* sometimes after the socket timeout when resource method was executed, CDI request context was not active.
38+
* Depending on the speed of a test executor, you may need to execute this test couple of times in order to reproduce
39+
* the original issue.
40+
*/
41+
@TestSecurity(user = "Martin", roles = "admin")
42+
@Test
43+
public void testCdiRequestActiveAfterTimeout() throws InterruptedException {
44+
RestAssured.delete("empty-failure-storage").then().statusCode(204);
45+
46+
int valueLesserThanDelay = TestSecurityResource.REQUEST_TIMEOUT - 2;
47+
// using deprecated constant due to https://github.com/rest-assured/rest-assured/issues/497
48+
var config = RestAssured.config()
49+
.httpClient(HttpClientConfig.httpClientConfig().setParam(SO_TIMEOUT, valueLesserThanDelay));
50+
try {
51+
RestAssured.given()
52+
.config(config)
53+
.header(TEST_HEADER_NAME, TEST_HEADER_VALUE)
54+
.get("reactive-routes-with-delayed-response")
55+
.then()
56+
.statusCode(200)
57+
.header("reactive-routes-filter", is("true"))
58+
.body(is("Martin"));
59+
Assertions.fail("HTTP request didn't result in a socket timeout exception");
60+
} catch (Exception socketTimeoutException) {
61+
// yes, this checked exception is thrown even though no method signature declares it
62+
if (!(socketTimeoutException instanceof SocketTimeoutException)) {
63+
// socket timeout exception is required to verify what happens with the CDI request context after the timeout
64+
Assertions.fail("Expected a SocketTimeoutException but got " + socketTimeoutException);
65+
}
66+
}
67+
int timeoutRemainder = TestSecurityResource.REQUEST_TIMEOUT - valueLesserThanDelay + 1;
68+
Thread.sleep(Duration.ofSeconds(timeoutRemainder).toMillis());
69+
RestAssured.given()
70+
.get("throwable")
71+
.then()
72+
.statusCode(200)
73+
.header("reactive-routes-filter", is("true"))
74+
.body(is("null"));
75+
}
76+
2677
}

0 commit comments

Comments
 (0)