From fa5da9ffce03ed2c68cfaa9674d8a5634d53e068 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 13 Oct 2026 22:29:14 +0200 Subject: [PATCH 1/2] fix(agent): stop the agent and close redis on shutdown, document the single bean --- CHANGELOG.md | 1 + README.md | 6 +- .../com/guardcore/spring/GuardMiddleware.kt | 22 +++++- .../guardcore/spring/GuardAgentWiringTest.kt | 77 +++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c65406..e07fc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Telemetry agent support: an `AgentHandlerProtocol` bean (for example guard-agent-jvm's `GuardAgentTelemetry`) is now injected into `GuardMiddleware` by the auto-configuration, adopted into the engine composite on initialization, and stopped through the new `GuardMiddleware.shutdown()` when the context closes. Before this, `guard.enable-agent=true` always degraded with a "W7 artifact" message, because the middleware had no way to receive a handler. The degrade path now only applies when no handler bean is registered. The pinned guard-core-jvm engine ref moves to 9067b7b, whose agent telemetry wiring fix (Guard-Core/guard-core-jvm#48) the handler needs to receive anything. +- `GuardMiddleware.shutdown()` now also closes the middleware's own redis handler, and stops the injected agent handler directly when initialization never ran (a lazily initialized app that saw no traffic), so an agent created with `cfg.redis` no longer leaks its persistence connection and daemon thread. It runs under the initialization mutex and is safe to call twice. The README documents that exactly one `AgentHandlerProtocol` bean is supported and adds the guard-agent-jvm dependency line. - Dependencies: netty is pinned to 4.1.139.Final through `platform(io.netty:netty-bom)`. lettuce-core 6.8.2 declares netty 4.1.125, which carries twelve advisories fixed by 4.1.137 at the latest. The pin is declared here as well as in the guard-core-jvm facade, so this lockfile stays the same whichever engine revision the build resolves. The four netty osv-scanner suppressions are removed; the Spring Boot test-scope suppressions stay. ## [1.0.0] - 2026-10-13 diff --git a/README.md b/README.md index 8b8de2a..bf16f3b 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,11 @@ service container); redis-backed tests skip when no server answers. ## Telemetry agent -The adapter never builds the agent itself. Register a guard-agent-jvm handler as an `AgentHandlerProtocol` bean and set `guard.enable-agent=true` (with `guard.agent-api-key`, which the engine config requires when the agent is enabled). The auto-configuration hands the bean to `GuardMiddleware`, which starts it on initialization and stops it, flushing its buffer, when the application context closes: +The adapter never builds the agent itself. Register a guard-agent-jvm handler as an `AgentHandlerProtocol` bean and set `guard.enable-agent=true` (with `guard.agent-api-key`, which the engine config requires when the agent is enabled). The auto-configuration hands the bean to `GuardMiddleware`, which starts it on initialization and stops it, flushing its buffer, when the application context closes. Exactly one `AgentHandlerProtocol` bean is used: two beans fail startup with `NoUniqueBeanDefinitionException` unless one is `@Primary`. Add the agent to your own build: + +```kotlin +implementation("com.guard-core:guard-agent-jvm:3.2.3") +``` ```kotlin import com.guardcore.agent.GuardAgent diff --git a/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt b/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt index 4c0d98b..2f1ea24 100644 --- a/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt +++ b/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt @@ -164,6 +164,8 @@ public class GuardMiddleware( private var initialized: Boolean = false + private var shutDown: Boolean = false + init { // Host-side seam registration (the facade's documented host contract, CoreTestSupport.kt:132 / // GuardCore.kt:15): wires the detection-engine seams (user-agent matcher G-5, straddle overlap) @@ -439,10 +441,26 @@ public class GuardMiddleware( /** * Python `shutdown_agent_integrations` on application shutdown: stops the composite, which stops * the injected agent and flushes its buffer. Spring calls it as the bean's inferred destroy - * method. + * method. Runs under [initMutex] so it cannot interleave with a first-request [initialize]. When + * initialization never ran the composite does not exist, so the injected handler is stopped + * directly (its stop is idempotent). It also closes the middleware's own [redisHandler], which + * Spring constructs and therefore owns. Safe to call twice. */ public fun shutdown() { - kotlinx.coroutines.runBlocking { handlerInitializer.shutdownAgentIntegrations() } + kotlinx.coroutines.runBlocking { + initMutex.withLock { + if (shutDown) { + return@withLock + } + shutDown = true + if (initialized) { + handlerInitializer.shutdownAgentIntegrations() + } else { + injectedAgentHandler?.stop() + } + redisHandler?.close() + } + } } /** diff --git a/src/test/kotlin/com/guardcore/spring/GuardAgentWiringTest.kt b/src/test/kotlin/com/guardcore/spring/GuardAgentWiringTest.kt index c9c7ee9..5ca5039 100644 --- a/src/test/kotlin/com/guardcore/spring/GuardAgentWiringTest.kt +++ b/src/test/kotlin/com/guardcore/spring/GuardAgentWiringTest.kt @@ -3,12 +3,17 @@ package com.guardcore.spring import com.guardcore.config.SecurityConfig import com.guardcore.config.SecurityConfigBuilder import com.guardcore.core.events.AgentTelemetryMember +import com.guardcore.errors.GuardRedisError import com.guardcore.protocols.AgentHandlerProtocol import com.guardcore.protocols.RedisHandlerProtocol import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.WebApplicationType +import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration @@ -98,6 +103,78 @@ public class GuardAgentWiringTest { middleware.shutdown() assertThat(telemetry.stops).isEqualTo(1) } + + @Test + public fun `shutdown without initialize stops the injected handler once`() { + val telemetry = RecordingTelemetry() + val middleware = GuardMiddleware(agentConfig(), injectedAgentHandler = telemetry) + middleware.shutdown() + assertThat(telemetry.stops).isEqualTo(1) + assertThat(telemetry.starts).isZero() + } + + @Test + public fun `shutdown twice stops the handler once`() { + val telemetry = RecordingTelemetry() + val middleware = GuardMiddleware(agentConfig(), injectedAgentHandler = telemetry) + runBlocking { middleware.initialize() } + middleware.shutdown() + middleware.shutdown() + assertThat(telemetry.stops).isEqualTo(1) + + val never = RecordingTelemetry() + val fresh = GuardMiddleware(agentConfig(), injectedAgentHandler = never) + fresh.shutdown() + fresh.shutdown() + assertThat(never.stops).isEqualTo(1) + } + + @Test + public fun `shutdown closes the middleware redis handler and is safe twice`() { + assumeTrue( + RedisRateLimitIntegrationTest.redisAvailable(), + "live redis on localhost:6379 required", + ) + val config = + SecurityConfigBuilder() + .apply { + enableRedis = true + redisUrl = "redis://localhost:6379" + redisPrefix = "guard-sb-shutdown-it" + } + .build() + val middleware = GuardMiddleware(config) + val redis = middleware.redisHandler!! + runBlocking { redis.withConnection { it } } + + middleware.shutdown() + middleware.shutdown() + + assertThatThrownBy { runBlocking { redis.withConnection { it } } } + .isInstanceOf(GuardRedisError::class.java) + .hasMessageContaining("closed") + } + + @Test + public fun `closing the Spring context stops the agent bean`() { + val context = + SpringApplicationBuilder(GuardTestApp::class.java, AgentBeanConfig::class.java) + .web(WebApplicationType.SERVLET) + .properties( + "server.port=0", + "guard.enable-redis=false", + "guard.enable-agent=true", + "guard.agent-api-key=test-key", + ) + .run() + val telemetry = context.getBean(RecordingTelemetry::class.java) + runBlocking { context.getBean(GuardMiddleware::class.java).initialize() } + assertThat(telemetry.starts).isEqualTo(1) + assertThat(telemetry.stops).isZero() + + context.close() + assertThat(telemetry.stops).isEqualTo(1) + } } @TestConfiguration From 232f510ebd3f187b3c09cd19c53db53f75adc601 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 13 Oct 2026 22:35:45 +0200 Subject: [PATCH 2/2] fix(agent): decide the shutdown path by whether a composite was built --- src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt b/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt index 2f1ea24..e19553a 100644 --- a/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt +++ b/src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt @@ -442,9 +442,9 @@ public class GuardMiddleware( * Python `shutdown_agent_integrations` on application shutdown: stops the composite, which stops * the injected agent and flushes its buffer. Spring calls it as the bean's inferred destroy * method. Runs under [initMutex] so it cannot interleave with a first-request [initialize]. When - * initialization never ran the composite does not exist, so the injected handler is stopped - * directly (its stop is idempotent). It also closes the middleware's own [redisHandler], which - * Spring constructs and therefore owns. Safe to call twice. + * no composite was built (initialization never ran or stopped early), the injected handler is + * stopped directly (its stop is idempotent). It also closes the middleware's own [redisHandler], + * which Spring constructs and therefore owns. Safe to call twice. */ public fun shutdown() { kotlinx.coroutines.runBlocking { @@ -453,7 +453,7 @@ public class GuardMiddleware( return@withLock } shutDown = true - if (initialized) { + if (handlerInitializer.compositeHandler != null) { handlerInitializer.shutdownAgentIntegrations() } else { injectedAgentHandler?.stop()