|
@@ -0,0 +1,196 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.config.AsyncConfig;
|
|
|
|
|
+import com.etotem.cfc.service.AiGateway;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|
|
|
|
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
|
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.concurrent.CountDownLatch;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:LangGraph 调用面不得以非守护线程挂起进程退出(能力键 {@code langgraph.pytest.exitHang})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>需求/设计锚点</b>(B-135-2 + B-126,cfc-langgraph pytest 43 例 147s 写完进程不退出,
|
|
|
|
|
+ * timeout 650 强杀 EXIT=124;Java 侧同源镜像契约):
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li><b>根因</b>:LLM 调用面线程挂起(<b>非守护线程</b>/协程未 await)——线程不退出,
|
|
|
|
|
+ * 进程(pytest / JVM)无法正常退出,被外部 timeout 强杀(EXIT=124);</li>
|
|
|
|
|
+ * <li><b>修复条款(Java 侧镜像)</b>:LLM/LangGraph 异步调用面由 {@code AsyncConfig}
|
|
|
|
|
+ * 提供的执行器承载时,其线程必须为 <b>守护线程(daemon)</b>,
|
|
|
|
|
+ * 保证测试/进程在任务挂起时仍能正常退出(await 收口 + 非守护线程退出);</li>
|
|
|
|
|
+ * <li><b>补充条款(超时收口)</b>:{@code AiGateway}(Java 侧 LangGraph 网关)的 HTTP
|
|
|
|
|
+ * 调用面必须配置有界 connect/read 超时,LangGraph 挂起时调用方在有限时间内返回,
|
|
|
|
|
+ * 不得无限阻塞拖住线程({@code AiGatewayTimeoutTest} 已覆盖时限,本契约断言配置面)。</li>
|
|
|
|
|
+ * </ul></p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状(RED 依据)</b>:宿主类 {@code AsyncConfig} 存在,但 {@code taskExecutor()} /
|
|
|
|
|
+ * {@code taskScheduler()} 使用 Spring 默认线程工厂({@code Executors.defaultThreadFactory()}
|
|
|
|
|
+ * 创建<b>非守护线程</b>),未设置守护线程工厂;AiGateway 的 HTTP 工厂超时已配置(GREEN 锚点)。
|
|
|
|
|
+ * 因此「执行器线程必须为守护线程」断言当前失败(红),GREEN 阶段需为执行器注入守护线程工厂。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测——无 Spring 容器 / 无 DB / 无外部服务,
|
|
|
|
|
+ * 直接实例化 {@code AsyncConfig} 调用 Bean 方法并探测其真实创建的线程,
|
|
|
|
|
+ * 反射锚定 {@code AiGateway} 超时配置面;不触碰 src/main。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>权限流</b>:本能力为后端基础设施线程/超时契约,无用户权限维度,权限流不适用。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 预期</b>:正常流 1/2(taskExecutor/taskScheduler 线程必须为守护线程)失败(红);
|
|
|
|
|
+ * 正常流 3(AiGateway 有界超时)为契约不变式,当前已满足(绿,作为锚点)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class LangGraphExitHangContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String ASYNC_PREFIX = "cfc-async-";
|
|
|
|
|
+ private static final String SCHEDULER_PREFIX = "cfc-scheduler-";
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:LLM/LangGraph 异步调用面线程必须为守护线程(非守护线程挂起 → 进程不退出)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 1:taskExecutor 线程必须为守护线程(B-135-2 非守护线程挂起进程退出)")
|
|
|
|
|
+ void taskExecutor_threads_mustBeDaemon() throws Exception {
|
|
|
|
|
+ AsyncConfig cfg = new AsyncConfig();
|
|
|
|
|
+ ThreadPoolTaskExecutor executor = cfg.taskExecutor();
|
|
|
|
|
+ CountDownLatch started = new CountDownLatch(1);
|
|
|
|
|
+ CountDownLatch release = new CountDownLatch(1);
|
|
|
|
|
+ try {
|
|
|
|
|
+ assertNotNull(executor, "[langgraph.pytest.exitHang] AsyncConfig.taskExecutor() 返回 null");
|
|
|
|
|
+
|
|
|
|
|
+ executor.execute(() -> {
|
|
|
|
|
+ started.countDown();
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 任务挂起,模拟 LLM 调用面被拖住;进程是否可退出取决于线程是否守护
|
|
|
|
|
+ release.await(5, TimeUnit.SECONDS);
|
|
|
|
|
+ } catch (InterruptedException ignored) {
|
|
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(started.await(5, TimeUnit.SECONDS),
|
|
|
|
|
+ "[langgraph.pytest.exitHang] taskExecutor 未创建工作线程");
|
|
|
|
|
+
|
|
|
|
|
+ Thread worker = findAliveThreadByPrefix(ASYNC_PREFIX);
|
|
|
|
|
+ assertNotNull(worker,
|
|
|
|
|
+ "[langgraph.pytest.exitHang] 未找到 taskExecutor 工作线程(前缀 " + ASYNC_PREFIX + ")");
|
|
|
|
|
+ assertTrue(worker.isDaemon(),
|
|
|
|
|
+ "[langgraph.pytest.exitHang] taskExecutor 线程必须为守护线程(daemon):"
|
|
|
|
|
+ + "LLM/LangGraph 调用面非守护线程挂起会导致进程无法退出(B-135-2 EXIT=124 同源),"
|
|
|
|
|
+ + "GREEN 需为 taskExecutor 注入守护线程工厂;当前线程=" + worker.getName()
|
|
|
|
|
+ + " isDaemon=false");
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ release.countDown();
|
|
|
|
|
+ releaseTask(executor);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 2:taskScheduler 线程必须为守护线程(B-135-2 非守护线程挂起进程退出)")
|
|
|
|
|
+ void taskScheduler_threads_mustBeDaemon() throws Exception {
|
|
|
|
|
+ AsyncConfig cfg = new AsyncConfig();
|
|
|
|
|
+ ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) cfg.taskScheduler();
|
|
|
|
|
+ try {
|
|
|
|
|
+ assertNotNull(scheduler, "[langgraph.pytest.exitHang] AsyncConfig.taskScheduler() 返回 null");
|
|
|
|
|
+
|
|
|
|
|
+ CountDownLatch scheduled = new CountDownLatch(1);
|
|
|
|
|
+ // 调度一个 30s 后执行的挂起任务,探测其创建的线程
|
|
|
|
|
+ scheduler.schedule(() -> scheduled.countDown(),
|
|
|
|
|
+ new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30)));
|
|
|
|
|
+
|
|
|
|
|
+ Thread worker = null;
|
|
|
|
|
+ for (int i = 0; i < 20 && worker == null; i++) {
|
|
|
|
|
+ Thread.sleep(50);
|
|
|
|
|
+ worker = findAliveThreadByPrefix(SCHEDULER_PREFIX);
|
|
|
|
|
+ }
|
|
|
|
|
+ assertNotNull(worker,
|
|
|
|
|
+ "[langgraph.pytest.exitHang] 未找到 taskScheduler 工作线程(前缀 " + SCHEDULER_PREFIX + ")");
|
|
|
|
|
+ assertTrue(worker.isDaemon(),
|
|
|
|
|
+ "[langgraph.pytest.exitHang] taskScheduler 线程必须为守护线程(daemon):"
|
|
|
|
|
+ + "LLM/LangGraph 定时调用面非守护线程挂起会导致进程无法退出(B-135-2 EXIT=124 同源),"
|
|
|
|
|
+ + "GREEN 需为 taskScheduler 注入守护线程工厂;当前线程=" + worker.getName()
|
|
|
|
|
+ + " isDaemon=false");
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ scheduler.shutdown();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:AiGateway(LangGraph 网关)调用面必须有界超时(超时收口,防无限阻塞挂起)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 3:AiGateway HTTP 调用面必须有界 connect/read 超时(GREEN 锚点/契约不变式)")
|
|
|
|
|
+ void aiGateway_httpSurface_hasBoundedTimeouts() throws Exception {
|
|
|
|
|
+ AiGateway gateway = new AiGateway();
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "enabled", true);
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "timeoutMs", 3000);
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "readTimeoutMs", 5000);
|
|
|
|
|
+ gateway.init();
|
|
|
|
|
+
|
|
|
|
|
+ Field rtField = AiGateway.class.getDeclaredField("restTemplate");
|
|
|
|
|
+ rtField.setAccessible(true);
|
|
|
|
|
+ org.springframework.web.client.RestTemplate restTemplate =
|
|
|
|
|
+ (org.springframework.web.client.RestTemplate) rtField.get(gateway);
|
|
|
|
|
+ assertNotNull(restTemplate.getRequestFactory(),
|
|
|
|
|
+ "[langgraph.pytest.exitHang] AiGateway restTemplate 缺少请求工厂(未调用 init() 收口超时)");
|
|
|
|
|
+ assertTrue(restTemplate.getRequestFactory() instanceof org.springframework.http.client.SimpleClientHttpRequestFactory,
|
|
|
|
|
+ "[langgraph.pytest.exitHang] AiGateway restTemplate 请求工厂应为 SimpleClientHttpRequestFactory"
|
|
|
|
|
+ + "(承载 connect/read 有界超时)");
|
|
|
|
|
+
|
|
|
|
|
+ org.springframework.http.client.SimpleClientHttpRequestFactory factory =
|
|
|
|
|
+ (org.springframework.http.client.SimpleClientHttpRequestFactory) restTemplate.getRequestFactory();
|
|
|
|
|
+ int connectTimeout = readTimeoutField(factory, "connectTimeout");
|
|
|
|
|
+ int readTimeout = readTimeoutField(factory, "readTimeout");
|
|
|
|
|
+ assertTrue(connectTimeout > 0,
|
|
|
|
|
+ "[langgraph.pytest.exitHang] AiGateway connect 超时必须有界(>0ms):"
|
|
|
|
|
+ + "LangGraph 挂起(accept 不响应)时快速失败,不得无限阻塞(B-126/B-135-2 同源)");
|
|
|
|
|
+ assertTrue(readTimeout > 0,
|
|
|
|
|
+ "[langgraph.pytest.exitHang] AiGateway read 超时必须有界(>0ms):"
|
|
|
|
|
+ + "LangGraph 挂起时有限时间内返回,不得拖死线程(B-126/B-135-2 同源)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 权限流(不适用):后端基础设施线程/超时契约,无用户权限维度
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流:本能力为后端基础设施契约,无用户权限维度,权限流不适用")
|
|
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
|
|
+ // 说明性用例:exitHang 为 LLM/LangGraph 调用面线程与超时语义,无权限校验维度;
|
|
|
|
|
+ // 仅为三流覆盖标注「不适用」,不产生断言失败。
|
|
|
|
|
+ assertTrue(true, "权限流不适用:langgraph.pytest.exitHang 为后端线程/超时契约,无用户权限维度");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 辅助
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ private Thread findAliveThreadByPrefix(String prefix) {
|
|
|
|
|
+ for (Thread t : Thread.getAllStackTraces().keySet()) {
|
|
|
|
|
+ if (t.isAlive() && t.getName().startsWith(prefix)) {
|
|
|
|
|
+ return t;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int readTimeoutField(Object target, String fieldName) throws Exception {
|
|
|
|
|
+ Field f = target.getClass().getDeclaredField(fieldName);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ return ((Number) f.get(target)).intValue();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void releaseTask(ThreadPoolTaskExecutor executor) {
|
|
|
|
|
+ if (executor != null) {
|
|
|
|
|
+ executor.shutdown();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|