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..e19553a 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 + * 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 { handlerInitializer.shutdownAgentIntegrations() } + kotlinx.coroutines.runBlocking { + initMutex.withLock { + if (shutDown) { + return@withLock + } + shutDown = true + if (handlerInitializer.compositeHandler != null) { + 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