Просмотр исходного кода

test(langgraph): S-cfc-135 契约测试 langgraph.pytest.exitHang - LLM 调用面线程必须 daemon(RED 2 失败)

Sisyphus Agent 2 дней назад
Родитель
Сommit
c8d6b0d44e
1 измененных файлов с 196 добавлено и 0 удалено
  1. 196 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/LangGraphExitHangContractTest.java

+ 196 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/LangGraphExitHangContractTest.java

@@ -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();
+        }
+    }
+}