Skip to content
Open
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
6 changes: 6 additions & 0 deletions containers/glassfish/cdi/pom.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--

Copyright (c) 2026 Contributors to the Eclipse Foundation
Copyright (c) 2013, 2025 Oracle and/or its affiliates. All rights reserved.

This program and the accompanying materials are made available under the
Expand Down Expand Up @@ -51,6 +52,11 @@
<artifactId>jakarta.inject-api</artifactId>
<version>${inject.api.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/*
* Copyright (c) 2026 Contributors to the Eclipse Foundation
* Copyright (c) 2013, 2025 Oracle and/or its affiliates. All rights reserved.
*
* This program and the accompanying materials are made available under the
Expand All @@ -23,9 +24,11 @@

import jakarta.enterprise.context.spi.CreationalContext;
import jakarta.enterprise.inject.spi.AnnotatedType;
import jakarta.enterprise.inject.spi.Bean;
import jakarta.enterprise.inject.spi.BeanManager;
import jakarta.enterprise.inject.spi.InjectionTarget;
import jakarta.enterprise.inject.spi.InjectionTargetFactory;
import jakarta.inject.Singleton;
import javax.naming.InitialContext;
import javax.naming.NamingException;

Expand Down Expand Up @@ -67,6 +70,16 @@ public CdiComponentProvider() throws NamingException {
}
}

/**
* Constructor used for testing, which avoids the JNDI lookup of the {@link BeanManager}.
*
* @param beanManager the bean manager to use, or {@code null} to simulate an environment without CDI.
*/
CdiComponentProvider(BeanManager beanManager) {
this.beanManager = beanManager;
this.managerRetrieved = (beanManager != null);
}

@Override
public boolean isApplicable(Class<?> c) {
Annotation[] annotations = c.getAnnotations();
Expand All @@ -86,21 +99,63 @@ public boolean isApplicable(Class<?> c) {
@SuppressWarnings("unchecked")
@Override
public <T> Object create(Class<T> c) {
if (managerRetrieved) {
T managedObject;
AnnotatedType annotatedType = beanManager.createAnnotatedType(c);
InjectionTargetFactory<T> injectionTargetFactory = beanManager.getInjectionTargetFactory(annotatedType);
InjectionTarget<T> it = injectionTargetFactory.createInjectionTarget(null);
CreationalContext cc = beanManager.createCreationalContext(null);
managedObject = (T) it.produce(cc);
it.inject(managedObject, cc);
it.postConstruct(managedObject);
cdiBeanToContext.put(managedObject, new CdiInjectionContext(it, cc));

return managedObject;
} else {
if (!managerRetrieved) {
return null;
}

// If the endpoint is a managed bean with a normal scope (e.g.
// @ApplicationScoped, @SessionScoped) or @Singleton, return the shared
// contextual reference so that WebSocket lifecycle callbacks, injected
// collaborators and CDI event observers all operate on the same bean
// instance. Producing a fresh instance per connection (as done below for
// dependent beans) would otherwise desynchronize the endpoint state from
// the contextual bean seen by observers and other injection points.
// See https://github.com/eclipse-ee4j/tyrus/issues/961
Bean<?> bean = resolveBean(c);
if (bean != null && isShared(bean)) {
// Not registered in cdiBeanToContext on purpose: a shared bean must
// not be destroyed when a single connection is closed - its lifecycle
// is managed by the CDI container.
CreationalContext<?> cc = beanManager.createCreationalContext(bean);
return beanManager.getReference(bean, c, cc);
}

// Dependent-scoped (or non-bean) endpoints keep the per-connection
// instance and are cleaned up by destroy() when the connection closes.
T managedObject;
AnnotatedType annotatedType = beanManager.createAnnotatedType(c);
InjectionTargetFactory<T> injectionTargetFactory = beanManager.getInjectionTargetFactory(annotatedType);
InjectionTarget<T> it = injectionTargetFactory.createInjectionTarget(null);
CreationalContext cc = beanManager.createCreationalContext(null);
managedObject = (T) it.produce(cc);
it.inject(managedObject, cc);
it.postConstruct(managedObject);
cdiBeanToContext.put(managedObject, new CdiInjectionContext(it, cc));

return managedObject;
}

/**
* Resolves the unique managed {@link Bean} for the given endpoint class, or {@code null} when the class is not a
* managed bean or cannot be unambiguously resolved (in which case a per-connection instance is created instead).
*/
private Bean<?> resolveBean(Class<?> c) {
try {
return beanManager.resolve(beanManager.getBeans(c));
} catch (Exception e) {
// e.g. AmbiguousResolutionException - fall back to a per-connection instance
LOGGER.fine(e.getMessage());
return null;
}
}

/**
* @return {@code true} if instances of the given bean are shared, i.e. the bean has a normal scope (such as
* {@code @ApplicationScoped} or {@code @SessionScoped}) or is a {@code @Singleton}.
*/
private boolean isShared(Bean<?> bean) {
Class<? extends Annotation> scope = bean.getScope();
return beanManager.isNormalScope(scope) || Singleton.class.equals(scope);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0, which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the
* Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
* version 2 with the GNU Classpath Exception, which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
*/

package org.glassfish.tyrus.gf.cdi;

import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collections;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.spi.AnnotatedType;
import jakarta.enterprise.inject.spi.Bean;
import jakarta.enterprise.inject.spi.BeanManager;
import jakarta.inject.Singleton;

import org.junit.Test;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;

/**
* Tests that {@link CdiComponentProvider#create(Class)} honours the CDI scope of the endpoint bean: shared scopes
* ({@code @ApplicationScoped}, {@code @Singleton}, ...) must reuse a single contextual instance across connections,
* while {@code @Dependent} endpoints keep the per-connection instance.
*
* @see <a href="https://github.com/eclipse-ee4j/tyrus/issues/961">Tyrus issue 961</a>
*/
public class CdiComponentProviderTest {

@ApplicationScoped
public static class ApplicationScopedEndpoint {
}

@Singleton
public static class SingletonEndpoint {
}

@Dependent
public static class DependentEndpoint {
}

@Test
public void applicationScopedEndpointReturnsSharedContextualReference() {
Object contextualReference = new ApplicationScopedEndpoint();
CdiComponentProvider provider =
new CdiComponentProvider(fakeBeanManager(ApplicationScoped.class, true, contextualReference));

// Every connection must observe the same (shared) bean instance.
assertSame(contextualReference, provider.create(ApplicationScopedEndpoint.class));
assertSame(contextualReference, provider.create(ApplicationScopedEndpoint.class));
}

@Test
public void singletonEndpointReturnsSharedContextualReference() {
Object contextualReference = new SingletonEndpoint();
// @Singleton is a pseudo-scope, so isNormalScope() returns false; it must still be treated as shared.
CdiComponentProvider provider =
new CdiComponentProvider(fakeBeanManager(Singleton.class, false, contextualReference));

assertSame(contextualReference, provider.create(SingletonEndpoint.class));
assertSame(contextualReference, provider.create(SingletonEndpoint.class));
}

@Test
public void dependentEndpointReturnsNewInstancePerConnection() {
CdiComponentProvider provider = new CdiComponentProvider(fakeBeanManager(Dependent.class, false, null));

Object first = provider.create(DependentEndpoint.class);
Object second = provider.create(DependentEndpoint.class);

assertNotNull(first);
assertNotNull(second);
assertNotSame("@Dependent endpoints must get a fresh instance per connection", first, second);
}

/**
* Builds a minimal {@link BeanManager} that exercises only the methods invoked by
* {@link CdiComponentProvider#create(Class)}. The shared path returns {@code contextualReference} from
* {@code getReference()}; the dependent path produces a fresh instance of the requested class on every call.
*/
private static BeanManager fakeBeanManager(Class<? extends Annotation> scope, boolean normalScope,
Object contextualReference) {
return (BeanManager) newProxy(BeanManager.class, new FakeHandler(scope, normalScope, contextualReference));
}

private static Object newProxy(Class<?> iface, InvocationHandler handler) {
return Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[] {iface}, handler);
}

/**
* Single {@link InvocationHandler} backing the fake {@link BeanManager} and the nested CDI SPI objects it returns
* ({@link Bean}, {@link AnnotatedType}, the injection-target factory and target, and the creational context). The
* method names used by {@code create()} do not collide across those interfaces, so one handler can serve them all.
*/
private static final class FakeHandler implements InvocationHandler {

private final Class<? extends Annotation> scope;
private final boolean normalScope;
private final Object contextualReference;
private Class<?> producedType;

private FakeHandler(Class<? extends Annotation> scope, boolean normalScope, Object contextualReference) {
this.scope = scope;
this.normalScope = normalScope;
this.contextualReference = contextualReference;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
switch (method.getName()) {
// --- shared-scope resolution path ---
case "getBeans":
return Collections.singleton(newProxy(Bean.class, this));
case "resolve":
return newProxy(Bean.class, this);
case "getScope":
return scope;
case "isNormalScope":
return normalScope;
case "getReference":
return contextualReference;
// --- per-connection (dependent) injection-target path ---
case "createAnnotatedType":
producedType = (Class<?>) args[0];
return newProxy(AnnotatedType.class, this);
case "getInjectionTargetFactory":
return newProxy(jakarta.enterprise.inject.spi.InjectionTargetFactory.class, this);
case "createInjectionTarget":
return newProxy(jakarta.enterprise.inject.spi.InjectionTarget.class, this);
case "createCreationalContext":
return newProxy(jakarta.enterprise.context.spi.CreationalContext.class, this);
case "produce":
return producedType.getDeclaredConstructor().newInstance();
case "inject":
case "postConstruct":
case "preDestroy":
case "dispose":
return null;
// --- Object methods ---
case "toString":
return "FakeBeanManager";
case "hashCode":
return System.identityHashCode(proxy);
case "equals":
return proxy == args[0];
default:
return method.getReturnType() == boolean.class ? Boolean.FALSE : null;
}
}
}
}