From 682066afa4ce26f05ddd741a9b10cd38665d2302 Mon Sep 17 00:00:00 2001 From: GedMarc Date: Mon, 14 Sep 2026 17:17:30 +0200 Subject: [PATCH] Prevent implicit Uni bootstrap and separate shutdown priority --- pom.xml | 1 + .../com/guicedee/client/IGuiceContext.java | 6 ++ .../mutiny/CallScopeUniInterceptor.java | 26 ++++--- .../services/lifecycle/IGuicePreDestroy.java | 7 +- .../test/UniBootstrapIsolationTest.java | 77 +++++++++++++++++++ 5 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/guicedee/client/test/UniBootstrapIsolationTest.java diff --git a/pom.xml b/pom.xml index b6672ae..7656f87 100644 --- a/pom.xml +++ b/pom.xml @@ -8,6 +8,7 @@ com.guicedee client + 2.2.3-SNAPSHOT GuicedEE Client https://guicedee.com diff --git a/src/main/java/com/guicedee/client/IGuiceContext.java b/src/main/java/com/guicedee/client/IGuiceContext.java index 69effef..48a8a29 100644 --- a/src/main/java/com/guicedee/client/IGuiceContext.java +++ b/src/main/java/com/guicedee/client/IGuiceContext.java @@ -123,6 +123,12 @@ static IGuiceContext instance() { */ Injector inject(); + /** Non-starting lookup for infrastructure that must not bootstrap the application. */ + default java.util.Optional existingInjector() { + return java.util.Optional.empty(); + } + + /** * Returns the Guice configuration backing this context. * diff --git a/src/main/java/com/guicedee/client/scopes/mutiny/CallScopeUniInterceptor.java b/src/main/java/com/guicedee/client/scopes/mutiny/CallScopeUniInterceptor.java index 4ffc5fc..801ce44 100644 --- a/src/main/java/com/guicedee/client/scopes/mutiny/CallScopeUniInterceptor.java +++ b/src/main/java/com/guicedee/client/scopes/mutiny/CallScopeUniInterceptor.java @@ -45,17 +45,8 @@ public Uni onUniCreation(Uni uni) { } INTERCEPTING.set(true); try { - CallScoper callScoper; - try { - callScoper = IGuiceContext.get(CallScoper.class); - } catch (RuntimeException noContextYet) { - // Mutiny invokes interceptors very early (e.g. during - // UniCreate.) and on any thread. When no Guice - // context is registered yet — during bootstrap, or in - // environments without com.guicedee:inject (such as unit - // tests) — skip call-scope propagation instead of failing. - return uni; - } + CallScoper callScoper = existingScoper(); + if (callScoper == null) return uni; if (callScoper.isStartedScope()) { recordTouch(callScoper, "uni-creation", captureLocation()); } @@ -66,6 +57,13 @@ public Uni onUniCreation(Uni uni) { } } + private static CallScoper existingScoper() { + var context = IGuiceContext.contexts.get("default"); + if (context == null) return null; + // An existing injector may resolve its scope; Uni creation must never invoke inject(). + return context.existingInjector().map(injector -> injector.getInstance(CallScoper.class)).orElse(null); + } + private static final ThreadLocal INTERCEPTING = ThreadLocal.withInitial(() -> false); /** @@ -117,7 +115,11 @@ public void subscribe(UniSubscriber subscriber) { return; } - CallScoper callScoper = IGuiceContext.get(CallScoper.class); + CallScoper callScoper = existingScoper(); + if (callScoper == null) { + AbstractUni.subscribe(upstream, subscriber); + return; + } boolean startedHere = false; if (!callScoper.isStartedScope()) { diff --git a/src/main/java/com/guicedee/client/services/lifecycle/IGuicePreDestroy.java b/src/main/java/com/guicedee/client/services/lifecycle/IGuicePreDestroy.java index b94caf6..272f3f8 100644 --- a/src/main/java/com/guicedee/client/services/lifecycle/IGuicePreDestroy.java +++ b/src/main/java/com/guicedee/client/services/lifecycle/IGuicePreDestroy.java @@ -23,7 +23,7 @@ *

* Purpose: release resources and stop background work before the injector is torn down. * Trigger: invoked during {@link com.guicedee.client.IGuiceContext#destroy()}. - * Order: ascending {@link #sortOrder()}, default 100. + * Order: ascending {@link #shutdownSortOrder()}, defaulting to sortOrder(). * Idempotency: implementations should be safe to invoke once and tolerate repeated calls. * * @author GedMarc @@ -36,4 +36,9 @@ public interface IGuicePreDestroy> extends IDefaul * Executes the pre-destroy logic. */ void onDestroy(); + + /** Allows infrastructure to start early and stop late without changing existing hooks. */ + default Integer shutdownSortOrder() { + return sortOrder(); + } } diff --git a/src/test/java/com/guicedee/client/test/UniBootstrapIsolationTest.java b/src/test/java/com/guicedee/client/test/UniBootstrapIsolationTest.java new file mode 100644 index 0000000..8c4b065 --- /dev/null +++ b/src/test/java/com/guicedee/client/test/UniBootstrapIsolationTest.java @@ -0,0 +1,77 @@ +package com.guicedee.client.test; + +import com.google.inject.*; +import com.guicedee.client.IGuiceContext; +import com.guicedee.client.scopes.CallScoper; +import com.guicedee.client.scopes.mutiny.CallScopeUniInterceptor; +import io.smallrye.mutiny.Uni; +import org.junit.jupiter.api.*; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +class UniBootstrapIsolationTest { + Map previous; + @BeforeEach void save() {previous=new HashMap<>(IGuiceContext.contexts);IGuiceContext.contexts.clear();} + @AfterEach void restore() {IGuiceContext.contexts.clear();IGuiceContext.contexts.putAll(previous);} + IGuiceContext context(Optional injector,AtomicInteger boots) { + return (IGuiceContext)Proxy.newProxyInstance(IGuiceContext.class.getClassLoader(),new Class[]{IGuiceContext.class},(proxy,method,args) -> { + if(method.getName().equals("existingInjector"))return injector; + if(method.getName().equals("getConfig"))return Proxy.newProxyInstance(com.guicedee.client.services.IGuiceConfig.class.getClassLoader(), + new Class[]{com.guicedee.client.services.IGuiceConfig.class},(p,m,a) -> { + if(m.getName().equals("isServiceLoadWithClassPath"))return false; + throw new AssertionError("Unexpected configuration operation: "+m.getName()); + }); + if(method.getName().equals("inject")) {boots.incrementAndGet();throw new IllegalStateException("fixture bootstrap must not run");} + throw new AssertionError("Unexpected context operation: "+method.getName()); + }); + } + @Test void creatingUniWithoutContextDoesNotDiscoverOrBootstrapProviders() { + assertEquals("value",Uni.createFrom().item("value").await().indefinitely()); + assertTrue(IGuiceContext.contexts.isEmpty()); + } + @Test void registeredButUninitializedContextIsNeverBootstrappedByInterceptor() { + var boots=new AtomicInteger();IGuiceContext.contexts.put("default",context(Optional.empty(),boots)); + var source=Uni.createFrom().item("value"); + assertSame(source,new CallScopeUniInterceptor().onUniCreation(source)); + assertEquals("value",source.await().indefinitely());assertEquals(0,boots.get()); + } + @Test void failureFromExistingScopeProviderIsNotSwallowedAsMissingBootstrap() { + var source=Uni.createFrom().item("value");var boots=new AtomicInteger(); + var injector=Guice.createInjector(new AbstractModule(){protected void configure(){ + bind(CallScoper.class).toProvider(() -> {throw new IllegalStateException("fixture scope failure");}); + }}); + IGuiceContext.contexts.put("default",context(Optional.of(injector),boots)); + assertThrows(ProvisionException.class,() -> new CallScopeUniInterceptor().onUniCreation(source));assertEquals(0,boots.get()); + } + @Test void initializedScopeIsResolvedWithoutInvokingBootstrap() { + var boots=new AtomicInteger();var resolutions=new AtomicInteger();var scoper=new CallScoper(); + var injector=Guice.createInjector(new AbstractModule(){protected void configure(){bind(CallScoper.class).toProvider(() -> {resolutions.incrementAndGet();return scoper;});}}); + IGuiceContext.contexts.put("default",context(Optional.of(injector),boots)); + assertEquals("ready",Uni.createFrom().item("ready").await().indefinitely()); + assertTrue(resolutions.get()>0);assertEquals(0,boots.get()); + } + @Test void activeScopeSnapshotCrossesContextsAndIsRemovedAfterCompletion() throws Exception { + var scoper=new CallScoper();var boots=new AtomicInteger(); + var injector=Guice.createInjector(new AbstractModule(){protected void configure(){bind(CallScoper.class).toInstance(scoper);}}); + IGuiceContext.contexts.put("default",context(Optional.of(injector),boots)); + var vertx=io.vertx.core.Vertx.vertx(); + try { + var captured=new java.util.concurrent.CompletableFuture>(); + vertx.getOrCreateContext().runOnContext(ignored -> { + try { + scoper.enterQuietly();scoper.seed(String.class,"original-actor"); + var uni=Uni.createFrom().item("read").map(value -> (String)scoper.getValues().get(Key.get(String.class))); + scoper.exitQuietly();captured.complete(uni); + }catch(Throwable failed){captured.completeExceptionally(failed);} + }); + var uni=captured.get(5,java.util.concurrent.TimeUnit.SECONDS);var result=new java.util.concurrent.CompletableFuture(); + var target=vertx.getOrCreateContext();target.runOnContext(ignored -> uni.subscribe().with(result::complete,result::completeExceptionally)); + assertEquals("original-actor",result.get(5,java.util.concurrent.TimeUnit.SECONDS)); + var empty=new java.util.concurrent.CompletableFuture();target.runOnContext(ignored -> empty.complete(!scoper.isStartedScope())); + assertTrue(empty.get(5,java.util.concurrent.TimeUnit.SECONDS));assertEquals(0,boots.get()); + } finally {vertx.close().toCompletionStage().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);} + } + +}