Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.quarkus.security.identity.CurrentIdentityAssociation;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
import io.quarkus.vertx.http.runtime.RoutingUtils;
import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
import io.quarkus.vertx.web.Route;
import io.vertx.core.AsyncResult;
Expand All @@ -22,7 +23,7 @@
public abstract class RouteHandler implements Handler<RoutingContext> {

private static final String REQUEST_CONTEXT_STATE = "__cdi_req_ctx";

private static final String REACTIVE_ROUTES_KEY = "quarkus-reactive-routes";
private final Event<SecurityIdentity> securityIdentityEvent;
private final CurrentIdentityAssociation currentIdentityAssociation;
private final CurrentVertxRequest currentVertxRequest;
Expand Down Expand Up @@ -69,6 +70,7 @@ public void handle(RoutingContext context) {
ContextState state = context.get(REQUEST_CONTEXT_STATE);
// Activate the context, i.e. set the thread locals, state can be null
requestContext.activate(state);
RoutingUtils.assumeCdiRequestContext(context, REACTIVE_ROUTES_KEY);
currentVertxRequest.setCurrent(context);
if (currentIdentityAssociation != null) {
if (user != null) {
Expand All @@ -90,14 +92,18 @@ public void handle(RoutingContext context) {
context.addEndHandler(new Handler<AsyncResult<Void>>() {
@Override
public void handle(AsyncResult<Void> result) {
requestContext.destroy(endState);
if (RoutingUtils.isCdiRequestContextOwner(context, REACTIVE_ROUTES_KEY)) {
requestContext.destroy(endState);
}
}
});
}
invoke(context);
} finally {
// Deactivate the context, i.e. cleanup the thread locals
requestContext.deactivate();
if (RoutingUtils.isCdiRequestContextOwner(context, REACTIVE_ROUTES_KEY)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this check unnecessary? I mean, at this point the owner must be quarkus-reactive-routes (unless something replaced the CURRENT_CDI_REQUEST_CTX_OWNER)...

Copy link
Member Author

@michalvavrik michalvavrik Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this check unnecessary? I mean, at this point the owner must be quarkus-reactive-routes (unless something replaced the CURRENT_CDI_REQUEST_CTX_OWNER)...

That is not how I read the situation. When routingContext.next() happens, other route handlers are called synchronously until some code is scheduled (like run blocking), at which point this can be invoked. So it can easily be Quarkus REST.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I meant "synchronously".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this check unnecessary? I mean, at this point the owner must be quarkus-reactive-routes (unless something replaced the CURRENT_CDI_REQUEST_CTX_OWNER)...

That is not how I read the situation. When routingContext.next() happens, other route handlers are called synchronously until some code is scheduled (like run blocking), at which point this can be invoked. So it can easily be Quarkus REST.

Yes, but all those handlers share the same RoutingContext...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is that finally is run when Quarkus REST is processing because routingContext.next() is run before.

Yes, but all those handlers share the same RoutingContext...

I don't know how to answer that :-) You will need to elaborate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is that finally is run when Quarkus REST is processing because routingContext.next() is run before.

Ok, so we don't want to deactivate the context either. Small note - if Vertx is used then requestContext.deactivate() is a NOOP if duplicated context is used: https://github.com/quarkusio/quarkus/blob/main/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/VertxCurrentContextFactory.java#L99

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is that finally is run when Quarkus REST is processing because routingContext.next() is run before.

Ok, so we don't want to deactivate the context either. Small note - if Vertx is used then requestContext.deactivate() is a NOOP if duplicated context is used: https://github.com/quarkusio/quarkus/blob/main/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/VertxCurrentContextFactory.java#L99

I didn't know, thanks. I thought that the bug is about the endHandler where is destroy invoked, but during the debugging I saw my breakpoint on deactivate called after Quarkus REST started doing stuff, so I'd keep it here just in case you change how deactivate works in the future.

// Deactivate the context, i.e. cleanup the thread locals
requestContext.deactivate();
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import io.quarkus.resteasy.reactive.server.runtime.ResteasyReactiveSecurityContext;
import io.quarkus.runtime.BlockingOperationControl;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.RoutingUtils;
import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.ResponseCommitListener;
Expand All @@ -60,6 +61,7 @@
public class ServletRequestContext extends ResteasyReactiveRequestContext
implements ServerHttpRequest, ServerHttpResponse, ResponseCommitListener {

private static final String QUARKUS_REST_SERVLET_KEY = "quarkus-rest-servlet";
private static final LazyValue<Event<SecurityIdentity>> SECURITY_IDENTITY_EVENT = new LazyValue<>(
ServletRequestContext::createEvent);
final RoutingContext context;
Expand Down Expand Up @@ -138,6 +140,11 @@ protected void handleRequestScopeActivation() {
}
}

@Override
protected void onPreRequestScopeActivation() {
RoutingUtils.assumeCdiRequestContext(context, QUARKUS_REST_SERVLET_KEY);
}

static void fireSecurityIdentity(SecurityIdentity identity) {
SECURITY_IDENTITY_EVENT.get().fire(identity);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
import io.quarkus.security.identity.CurrentIdentityAssociation;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.core.runtime.context.VertxContextSafetyToggle;
import io.quarkus.vertx.http.runtime.RoutingUtils;
import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
import io.smallrye.common.vertx.VertxContext;
import io.vertx.core.http.impl.HttpServerRequestInternal;
import io.vertx.ext.web.RoutingContext;

public class QuarkusResteasyReactiveRequestContext extends VertxResteasyReactiveRequestContext {

private static final String QUARKUS_REST_KEY = "quarkus-rest";
final CurrentIdentityAssociation association;
boolean userSetup = false;

Expand Down Expand Up @@ -51,6 +53,11 @@ protected void handleRequestScopeActivation() {
}
}

@Override
protected void onPreRequestScopeActivation() {
RoutingUtils.assumeCdiRequestContext(context, QUARKUS_REST_KEY);
}

@Override
protected void requestScopeDeactivated() {
// we intentionally don't call 'CurrentRequestManager.set(null)'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.quarkus.vertx.http.runtime;

import java.util.Objects;
import java.util.Set;

import org.jboss.logging.Logger;

import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.impl.MimeMapping;
import io.vertx.core.net.impl.URIDecoder;
Expand All @@ -10,10 +13,39 @@

public final class RoutingUtils {

private static final String CURRENT_CDI_REQUEST_CTX_OWNER = "io.quarkus.vertx.http.runtime#current-cdi-req-ctx-owner";
private static final Logger LOG = Logger.getLogger(RoutingUtils.class);

private RoutingUtils() throws IllegalAccessException {
throw new IllegalAccessException("Avoid direct instantiation");
}

/**
* Assumes ownership of the currently active CDI request context.
* Thus, code invoked (even asynchronously) from previous route handlers shouldn't deactivate it.
*
* @param ctx RoutingContext
* @param newOwner typically a route handler that needs CDI request context active
*/
public static void assumeCdiRequestContext(RoutingContext ctx, String newOwner) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michalvavrik is there any guarantee that the last handler to claim the context as their own is always going to be the last to actually attempt to use it?

I am not familiar enough with the code to be able to tell so maybe I am missing something obvious.
However, my thinking was that I would capture a collection of all handlers when they attempt to activate it. Later on, when a handler attempts to deactivate context, remove the handler from the collection and only the last to be removed actually performs the cleanup.
Then again, I am probably just missing something, so take it with a pinch of salt please :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michalvavrik is there any guarantee that the last handler to claim the context as their own is always going to be the last to actually attempt to use it?

It is for the scenario I am trying to solve, but if we treat this method as SPI that theoretically anyone could reuse, then probably no. IMO there should be always only one route handler actively handling the incoming request, because if there were multiple handlers using the same request, we would be in for whole lot of issues like this one #49468.

So what I meant to solve is:

  • avoid code executed after user callback runs routingContext.next(), such as
    • the finally block running requestContext.deactivate()
    • the endHandler code destroying CDI context state

And if in the future (or in quarkiverse) someone does things like these, we cannot guarantee we will be the last ones.

However, my thinking was that I would capture a collection of all handlers when they attempt to activate it. Later on, when a handler attempts to deactivate context, remove the handler from the collection and only the last to be removed actually performs the cleanup.

I think that would be a solution covering all eventualities, but the only way to keep it safe (be sure it is cleaned) would be synchronization (or other locking) which could have performance impact if it was hot path? I think there is a reason why RoutingContext data are not kept in the synchronized map and not in volatile field, and that is that we don't expect things to happen concurrently. But I can be wrong, not an expert on this matter.

IMO current code in Quarkus REST doesn't expect that someone else is handling active CDI request context and I have seen the same thing in gRPC. I'd keep it simple (as is, or just rename method / adjust javadoc if you have some suggestion) @manovotn , but I can do what you want if you deem it important.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First off, I don't think this should be just a local discussion, all relevant people need to be involved.

I think we've established that there may be multiple users (as in, Quarkus components) that want to access the same request context. We've also established that we want the request context created on demand, not eagerly. (Thanks for those remarks! It also feels like déjà vu to me. And I'm sure it will happen again.)

I'm gonna claim a simple int (or AtomicInteger) stored on a shared location (the Vert.x context probably? What about non-Vert.x threads though?) would be enough. If someone wants to create the request context, two things may happen.

  1. If they don't see the int on the shared location, they are the first to activate the request context. They do so and set the value to 1.
  2. If they do see the int on the shared location, the request context already exist and they just increment the value.

Conversely, if someone wants to destroy the request context, two (or three, depending how thorough we are) things may happen:

  1. They see the int on the shared location, they decrement the value and the result is > 0. They don't do anything more.
  2. They see the int on the shared location, they decrement the value and the result is 0. They destroy the request context.
  3. They don't see the int on the shared location. The request context wasn't activated, so there's no point in destroying it.

I think the above should be enough to ensure that the request context can be activated on-demand and destroyed only after all possible users are done with it.

I think either this could be put into a utility class, probably in ArC, or perhaps this could be done in the ArC implementation of request context itself?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. They see the int on the shared location, they decrement the value and the result is > 0. They don't do anything more.

AFAIU this would not help with the use case @michalvavrik is trying to fix. This sentence from #51299 (comment) is important: "IMO current code in Quarkus REST doesn't expect that someone else is handling active CDI request context and I have seen the same thing in gRPC"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIU this would not help with the use case @michalvavrik is trying to fix. This sentence from #51299 (comment) is important: "IMO current code in Quarkus REST doesn't expect that someone else is handling active CDI request context and I have seen the same thing in gRPC"

Yeah, but that assumption is wrong, otherwise the issue and this PR wouldn't exist. We clearly have multiple components assuming they own the request context, which is wrong.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I am fine with this change and its local scope.

I know I'm not the one to decide here, but I'm personally not. This PR is a result of great investigation, but the solution presented is a band-aid applied on a wrong abstraction level.

If we actually want to support multiple "owners" of the same request context, I think something like I describe above is what we should do, and the more I think about it, the more I'm convinced the request context implementation itself should do it. (This is obviously performance sensitive...)

Now, there's a difference between deactivation and destruction and I didn't think through this distinction, so there would be more work to do. Still, it feels better to do it properly once, rather than doing it improperly (at least once, but possibly multiple times) and then having to do it properly and undoing the hacks.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frankly, ATM I don't even have time to carefully read all your comments, but I got a gist that @Ladicek have in mind solid solution and @manovotn agree in principle.

I'd be fine to close this PR if you will handle the linked issue @Ladicek ? You can reuse that test, I wrote a lot of comments about re-running it, but it actually reproduced the issue pretty well from my workstation. I just don't know how stable reproducing will be from your laptop.

Anyway, if noone else handles the issue, I can go back to it by the end of this week, read all your comments and try to come up with something.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just talked to @mkouba and @manovotn and we concluded that concurrency in Quarkus is pretty much a mess (in how DuplicatedContext is passed around and duplicated/triplicated/whatever, in how SmallRye Context Propagation works/is integrated, in how Vert.x and non-Vert.x thread are different). I certainly don't aim to fix that.

We also briefly discussed the difference between context deactivation and destruction and concluded that deactivation itself shouldn't "unregister" the owner, only destruction should. We didn't discuss what should constitute the "registration" of the request context owner, but it seems logical to me that it's the call to activate() with no initial state (that is, zero-param activate() or one-param activate() with null argument).

Looking at the implementation of CurrentManagedContext, it seems relatively straightforward to support multiple owners. It should only affect these places:

  • method ContextState activate(ContextState initialState)
  • method void destroy(ContextState state)
  • member class CurrentContextState (we need to track the number of current owners, but that should always be small, so it should fit into the state field -- it seems safe to me to assume that there will never be more than 32 owners, in fact I think we can assume there will never more than 16 owners to leave 1 bit reserved for future use)

I'll try to figure something out.

In the meantime, I'm certainly not blocking the merge of this PR, because both @mkouba and @manovotn are fine with it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all your comments and expertise everyone.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't discuss what should constitute the "registration" of the request context owner, but it seems logical to me that it's the call to activate() with no initial state (that is, zero-param activate() or one-param activate() with null argument).

OK, so that is clearly wrong. (I knew there was a hurdle!) Activating a request context with no initial state is creating a new request context. That only happens once. Registration of an additional owner may only happen when activating with non-null initial state, and at that point, there's no way to know if that means a newly registered owner, or an already registered owner. So my nice and simple idea makes no sense :-)

var previousOwner = ctx.data().put(CURRENT_CDI_REQUEST_CTX_OWNER, Objects.requireNonNull(newOwner));
if (previousOwner != null && LOG.isDebugEnabled()) {
LOG.debugf("CDI request context owner has changed from '%s' to '%s'", previousOwner, newOwner);
}
}

/**
* Enables route handlers to determine if they can deactivate/destroy CDI request context without impacting
* any other extension.
*
* @param ctx RoutingContext
* @param owner typically a route handler that needs CDI request context active
* @return true if the CDI request context is owned by the {@code owner}
*/
public static boolean isCdiRequestContextOwner(RoutingContext ctx, String owner) {
return owner.equals(ctx.get(CURRENT_CDI_REQUEST_CTX_OWNER));
}

/**
* Get the normalized and decoded path:
* - normalize based on RFC3986
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ public void requireCDIRequestScope() {
if (requestScopeActivated) {
return;
}
onPreRequestScopeActivation();
if (isRequestScopeManagementRequired()) {
if (requestContext.isRequestContextActive()) {
// req. context is already active, just reuse existing one
Expand Down Expand Up @@ -274,6 +275,10 @@ public ThreadSetupAction.ThreadState captureCDIRequestScope() {

protected abstract void handleRequestScopeActivation();

protected void onPreRequestScopeActivation() {
// by default do nothing
}

/**
* Restarts handler chain processing on a chain that does not target a specific resource
* <p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.quarkus.it.openapi.security;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.logging.Log;

@ApplicationScoped
public class FailureStorage {

private volatile Throwable throwable = null;

public Throwable getThrowable() {
return throwable;
}

public void setThrowable(Throwable throwable) {
Log.info("Setting throwable value to " + throwable);
this.throwable = throwable;
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,91 @@
package io.quarkus.it.openapi.security;

import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.SecurityContext;

import io.quarkus.vertx.web.RouteFilter;
import io.vertx.core.Handler;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;

@Path("/security")
public class TestSecurityResource {

public static final String TEST_HEADER_NAME = "test-security-header";
public static final String TEST_HEADER_VALUE = "hush-hush";
public static int REQUEST_TIMEOUT = 3;

@Inject
FailureStorage failureStorage;

@Context
HttpHeaders httpHeaders;

@RolesAllowed("admin")
@GET
@Path("reactive-routes")
public String reactiveRoutes(@Context SecurityContext securityContext) {
return securityContext.getUserPrincipal().getName();
}

@RolesAllowed("admin")
@GET
@Path("reactive-routes-with-delayed-response")
public String reactiveRoutesWithDelayedResponse(@Context SecurityContext securityContext) throws InterruptedException {
Thread.sleep(REQUEST_TIMEOUT);
try {
// ATM this code is not invoked every single time, it is timing issue
// but on my laptop it is invoked 7 times out of 10, therefore, if re-run multiple times,
// it sufficiently reproduces our original issue with accessing CDI request context
var headerValue = httpHeaders.getHeaderString(TEST_HEADER_NAME);

// just to do something - check the expected value
if (!TEST_HEADER_VALUE.equals(headerValue)) {
throw new IllegalStateException(
"Invalid header value, got '%s',but expected '%s' ".formatted(headerValue, TEST_HEADER_VALUE));
}
} catch (Throwable t) {
failureStorage.setThrowable(t);
}
return securityContext.getUserPrincipal().getName();
}

@Path("throwable")
@GET
public String getThrowable() {
return String.valueOf(failureStorage.getThrowable());
}

@Path("empty-failure-storage")
@DELETE
public void emptyFailureStorage() {
failureStorage.setThrowable(null);
}

@RouteFilter(401)
public void doNothing(RoutingContext routingContext) {
// here so that the Reactive Routes extension activates CDI request context
routingContext.response().putHeader("reactive-routes-filter", "true");
routingContext.next();
}

void addFailureHandler(@Observes Router router) {
// this is necessary, because before the fix, the ContextNotActiveException was sometimes
// thrown in the CDI interceptors and handled by the QuarkusErrorHandler
router.route().order(Integer.MAX_VALUE - 100).failureHandler(new Handler<RoutingContext>() {
@Override
public void handle(RoutingContext routingContext) {
failureStorage.setThrowable(routingContext.failure());
routingContext.end();
}
});
}

}
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package io.quarkus.it.openapi.security;

import static io.quarkus.it.openapi.security.TestSecurityResource.TEST_HEADER_NAME;
import static io.quarkus.it.openapi.security.TestSecurityResource.TEST_HEADER_VALUE;
import static org.apache.http.params.CoreConnectionPNames.SO_TIMEOUT;
import static org.hamcrest.Matchers.is;

import java.net.SocketTimeoutException;
import java.time.Duration;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.RestAssured;
import io.restassured.config.HttpClientConfig;

@QuarkusTest
@TestHTTPEndpoint(TestSecurityResource.class)
Expand All @@ -23,4 +31,47 @@ public void testSecurityWithReactiveRoutesAndQuarkusRest() {
.body(is("Martin"));
}

/**
* This verifies that CDI request context activated by Reactive Routes is not deactivated/destroyed while
* Quarkus REST needs it. Before the fix, there was a racy behavior. Sometimes during the CDI interceptors processing,
* sometimes after the socket timeout when resource method was executed, CDI request context was not active.
* Depending on the speed of a test executor, you may need to execute this test couple of times in order to reproduce
* the original issue.
*/
@TestSecurity(user = "Martin", roles = "admin")
@Test
public void testCdiRequestActiveAfterTimeout() throws InterruptedException {
RestAssured.delete("empty-failure-storage").then().statusCode(204);

int valueLesserThanDelay = TestSecurityResource.REQUEST_TIMEOUT - 2;
// using deprecated constant due to https://github.com/rest-assured/rest-assured/issues/497
var config = RestAssured.config()
.httpClient(HttpClientConfig.httpClientConfig().setParam(SO_TIMEOUT, valueLesserThanDelay));
try {
RestAssured.given()
.config(config)
.header(TEST_HEADER_NAME, TEST_HEADER_VALUE)
.get("reactive-routes-with-delayed-response")
.then()
.statusCode(200)
.header("reactive-routes-filter", is("true"))
.body(is("Martin"));
Assertions.fail("HTTP request didn't result in a socket timeout exception");
} catch (Exception socketTimeoutException) {
// yes, this checked exception is thrown even though no method signature declares it
if (!(socketTimeoutException instanceof SocketTimeoutException)) {
// socket timeout exception is required to verify what happens with the CDI request context after the timeout
Assertions.fail("Expected a SocketTimeoutException but got " + socketTimeoutException);
}
}
int timeoutRemainder = TestSecurityResource.REQUEST_TIMEOUT - valueLesserThanDelay + 1;
Thread.sleep(Duration.ofSeconds(timeoutRemainder).toMillis());
RestAssured.given()
.get("throwable")
.then()
.statusCode(200)
.header("reactive-routes-filter", is("true"))
.body(is("null"));
}

}
Loading