diff --git a/docs/compatibility/feishu-websocket-lifecycle.md b/docs/compatibility/feishu-websocket-lifecycle.md new file mode 100644 index 00000000..f1bd9321 --- /dev/null +++ b/docs/compatibility/feishu-websocket-lifecycle.md @@ -0,0 +1,52 @@ +# Feishu WebSocket lifecycle (oapi-sdk 2.7.1) + +The adapter owns reconnect scheduling. SDK `autoReconnect` stays false. SDK +`start()` initiates a handshake and returns; only `awaitReady` success makes the +adapter CONNECTED. Startup failures and actual socket failures enter the existing +single exponential-backoff loop. A 15-second probe observes the SDK socket; +OkHttp protocol ping/pong (20 seconds) detects half-open transports without +business messages. A quiet channel does not imply a broken connection. + +`FeishuWebSocketClient` is a deliberately narrow, version-bound compatibility +bridge. SDK 2.7.1 has a private constructor and no public resource-disposal or +transport-status APIs. Its `close()` does not shut down the executor. The bridge +validates `executor`, `httpClient`, and `conn` field types. Reads of the non-volatile +`conn` field acquire the SDK monitor used by `disconnect()`. It configures the dedicated +HTTP client before use, and releases SDK/HTTP executors and idle connections on +permanent close. It cancels the underlying socket after SDK close (which only +enqueues a graceful close), outside the SDK monitor to allow failure callbacks. +It cancels HTTP calls and interrupts the starter before taking +the lifecycle lock, so a pending handshake cannot prevent shutdown. Do not remove +these contract tests or upgrade the SDK without rechecking its lifecycle. + +Feishu opts out of message-age staleness with Duration.ZERO. Other adapters retain +their existing thresholds. +ERROR recovery remains active; long-poll adapters retain their existing positive +thresholds. The legacy Feishu `silent_disconnect_threshold_seconds` setting no +longer triggers reconnects. A transport reconnect reuses the HTTP token client; +token refresh already has its own schedule. Permanent stop shuts down that client. + +Regression tests use a loopback-only HTTP/WebSocket peer and synthetic credentials, +with no model calls or live Feishu messages. They cover delayed/rejected handshakes, +TCP resets, peer close, missing pongs, stop during startup, close-before-start, +close between handshake and success, and 25 real SDK start/stop cycles. Each old +SDK and HTTP executor must terminate; quiet channels, ERROR cooldown, and Weixin +five-minute stale recovery are verified separately. + +## Validation + +Run the lifecycle and existing channel regressions on Java 21: + +```sh +JAVA_TOOL_OPTIONS=-Djdk.httpclient.allowRestrictedHeaders=connection \ + mvn -pl mateclaw-server -am \ + -Dtest='Feishu*Test,ChannelHealthMonitor*Test,ChannelAdapterStalenessTest' \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +The JVM option matches the application startup HTTP setting; the broader existing +Feishu HTTP tests also run outside the application main method. The lifecycle +tests do not need credentials or Internet access. The native SDK +resource bridge is scoped to the pinned oapi-sdk 2.7.1; review the reflected field +contracts before changing that dependency. Local socket tests do not establish +real-user delivery or multi-day reliability. diff --git a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java index a40f9a82..948667bf 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java @@ -159,6 +159,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { * 重连成功回调 */ protected void onReconnectSuccess() { + if (!running.get()) return; backoff.reset(); connectionState.set(ConnectionState.CONNECTED); lastEventTimeMs.set(System.currentTimeMillis()); @@ -171,6 +172,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { * 重连失败回调 */ protected void onReconnectFailed(Exception e) { + if (!running.get()) return; lastError = e.getMessage(); log.warn("[{}] Reconnect failed for {}: {} (attempt #{})", getChannelType(), channelEntity.getName(), e.getMessage(), backoff.getAttempts()); @@ -182,11 +184,14 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { if (running.compareAndSet(false, true)) { log.info("[{}] Starting channel: {}", getChannelType(), channelEntity.getName()); try { + connectionState.set(ConnectionState.RECONNECTING); doStart(); - connectionState.set(ConnectionState.CONNECTED); - lastEventTimeMs.set(System.currentTimeMillis()); - lastError = null; - backoff.reset(); + if (!connectsAsynchronously()) { + connectionState.set(ConnectionState.CONNECTED); + lastEventTimeMs.set(System.currentTimeMillis()); + lastError = null; + backoff.reset(); + } log.info("[{}] Channel started successfully: {}", getChannelType(), channelEntity.getName()); } catch (Exception e) { running.set(false); @@ -198,6 +203,11 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { } } + /** Async transports report CONNECTED only after their handshake completes. */ + protected boolean connectsAsynchronously() { + return false; + } + @Override public void stop() { if (running.compareAndSet(true, false)) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java index a831c5bc..e6216faa 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java @@ -248,6 +248,8 @@ public interface ChannelAdapter { /** * RFC-024 Change 2:本 adapter 认为"多久没活动就视作 stale 需要重启"的阈值。 * + *

返回 Duration.ZERO 可禁用消息静默检查(无状态 HTTP 或自带传输心跳的渠道)。 + * ERROR 状态的恢复不受影响。 *

通用默认 60 分钟;长轮询类渠道(如 iLink 微信)应覆盖为 5 分钟, * 这样代理/NAT 侧 2–5 分钟 idle-close 把连接切断后, * {@code ChannelHealthMonitor} 能在几分钟内触发自动重启,而非静默等整小时。

diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelHealthMonitor.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelHealthMonitor.java index c0613793..52497b94 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelHealthMonitor.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelHealthMonitor.java @@ -82,12 +82,12 @@ public class ChannelHealthMonitor { Duration staleThreshold; try { Duration d = adapter.stalenessThreshold(); - staleThreshold = (d == null || d.isNegative() || d.isZero()) ? DEFAULT_STALE_THRESHOLD : d; + staleThreshold = (d == null || d.isNegative()) ? DEFAULT_STALE_THRESHOLD : d; } catch (Exception ex) { staleThreshold = DEFAULT_STALE_THRESHOLD; } if (reason == null && state == AbstractChannelAdapter.ConnectionState.CONNECTED - && sinceLastEvent > staleThreshold.toMillis()) { + && !staleThreshold.isZero() && sinceLastEvent > staleThreshold.toMillis()) { reason = String.format("stale connection, no events for %ds (threshold=%ds)", sinceLastEvent / 1000, staleThreshold.toSeconds()); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 045ea059..2f021790 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -63,7 +63,7 @@ import java.util.concurrent.TimeUnit; * - enable_nickname_cache: 是否通过 Contact API 获取用户昵称(默认 true) * - media_download_enabled: 是否下载消息中的媒体文件(默认 false) * - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt(默认 true) - * - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值(默认 1800,0 禁用) + * - silent_disconnect_threshold_seconds: 已弃用;健康检查使用协议心跳,不依赖消息频率 * - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用) * - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never" * auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用) @@ -126,22 +126,15 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre private static final int NICKNAME_CACHE_MAX = 500; /** WebSocket 客户端(websocket 模式) */ - private volatile com.lark.oapi.ws.Client wsClient; + private volatile FeishuWebSocketClient wsClient; + private final Object wsLifecycleLock = new Object(); /** WebSocket 连接线程 */ private volatile Thread wsThread; - /** WebSocket 静默断连看门狗:定期检查最近事件时间,超过阈值就强制重连 */ - private ScheduledFuture silentDisconnectWatchdog; - - /** 是否已收到至少一个事件(用于避免新连接立即触发静默超时) */ - private volatile boolean hasReceivedFirstEvent = false; - - /** 看门狗检查间隔(秒) */ - private static final long WATCHDOG_INTERVAL_SECONDS = 60L; - - /** 静默断连默认阈值(秒):30 分钟无事件就视为可疑 */ - private static final long DEFAULT_SILENT_THRESHOLD_SECONDS = 1800L; + /** Poll transport failure; user-message silence is never a disconnect signal. */ + private ScheduledFuture transportWatchdog; + private static final long WATCHDOG_INTERVAL_SECONDS = 15L; /** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */ private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L; @@ -381,8 +374,39 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // ==================== 生命周期 ==================== + @Override + public Duration stalenessThreshold() { + return Duration.ZERO; // Protocol ping/pong detects dead transports, even on quiet channels. + } + + @Override + protected void onReconnectSuccess() { + synchronized (wsLifecycleLock) { + if (!running.get()) return; + if (connectsAsynchronously() && (wsClient == null || !wsClient.isConnected())) { + throw new IllegalStateException("WebSocket closed before connection became ready"); + } + super.onReconnectSuccess(); + } + } + + @Override + protected boolean connectsAsynchronously() { + return "websocket".equals(getConfigString("connection_mode", "websocket")); + } + @Override protected void doStart() { + try { + initializeChannel(); + } catch (RuntimeException e) { + doStop(); + if (reconnectScheduler != null) reconnectScheduler.shutdownNow(); + throw e; + } + } + + private void initializeChannel() { String appId = getConfigString("app_id"); String appSecret = getConfigString("app_secret"); @@ -439,7 +463,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // 关闭 WebSocket stopWebSocket(); + HttpClient previousHttpClient = this.httpClient; this.httpClient = null; + if (previousHttpClient != null) previousHttpClient.shutdownNow(); this.tenantAccessToken = null; // Reset bot-open-id state under the same lock getBotOpenId uses, so a // dispatch thread mid-fetch sees a consistent (cleared) view rather than @@ -462,27 +488,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre String appSecret = getConfigString("app_secret"); String connectionMode = getConfigString("connection_mode", "websocket"); - // 重新建立 HTTP 客户端 - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - - try { - refreshTenantAccessToken(); - } catch (Exception e) { - log.warn("[feishu] Token refresh during reconnect failed: {}", e.getMessage()); - } - - // Re-prefetch bot open_id after reconnect: same dispatch-thread latency - // concern as doStart, plus picks up a rotated app identity if any. - getBotOpenId(); - + // Token refresh has its own schedule; a WebSocket reconnect does not rotate HTTP state. if ("websocket".equals(connectionMode)) { log.info("[feishu] Reconnecting WebSocket..."); stopWebSocket(); - // 同步连接:在当前重连线程中直接阻塞调用 start() - // 连接成功 start() 会一直阻塞(不会返回到这里) - // 连接失败 start() 抛异常,由 AbstractChannelAdapter.scheduleReconnect 捕获并触发 onReconnectFailed + // Wait for the handshake; failures propagate to the adapter's single backoff loop. startWebSocketSync(appId, appSecret); } @@ -494,7 +504,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** * 创建 WebSocket 客户端实例(不启动连接) */ - private com.lark.oapi.ws.Client createWsClient(String appId, String appSecret) { + FeishuWebSocketClient createWsClient(String appId, String appSecret) { EventDispatcher eventDispatcher = EventDispatcher.newBuilder("", "") .onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() { @Override @@ -588,130 +598,94 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre }) .build(); - return new com.lark.oapi.ws.Client.Builder(appId, appSecret) + return new FeishuWebSocketClient(new com.lark.oapi.ws.Client.Builder(appId, appSecret) .eventHandler(eventDispatcher) .autoReconnect(false) // 由我们的 ExponentialBackoff 控制重连,不用 SDK 内置重连 .domain("lark".equals(getConfigString("domain", "feishu")) ? "https://open.larksuite.com" : "https://open.feishu.cn") - .build(); + .build()); } - /** - * 启动 WebSocket 长连接(异步,用于 doStart 首次启动) - * 在守护线程中运行,避免阻塞主线程。连接失败时触发 onDisconnected → 退避重连 - */ - private void startWebSocket(String appId, String appSecret) { - wsClient = createWsClient(appId, appSecret); - - wsThread = new Thread(() -> { - try { - log.info("[feishu] WebSocket connecting (long connection)..."); - wsClient.start(); - // start() blocks until disconnect; if it returns normally, it means disconnected - if (running.get()) { - onDisconnected("WebSocket connection ended"); + /** First connection is asynchronous; start() returning means handshake initiated. */ + void startWebSocket(String appId, String appSecret) { + synchronized (wsLifecycleLock) { + if (!running.get()) return; + FeishuWebSocketClient client = createWsClient(appId, appSecret); + wsClient = client; + wsThread = new Thread(() -> { + try { + client.start(); + synchronized (wsLifecycleLock) { + if (running.get() && wsClient == client) onReconnectSuccess(); + } + } catch (Exception e) { + synchronized (wsLifecycleLock) { + if (running.get() && wsClient == client) { + onDisconnected("WebSocket startup failed: " + e.getClass().getSimpleName()); + } + } } - } catch (Exception e) { - log.error("[feishu] WebSocket error: {}", e.getMessage(), e); - if (running.get()) { - onDisconnected("WebSocket error: " + e.getMessage()); - } - } - }, "feishu-ws-" + channelEntity.getId()); - wsThread.setDaemon(true); - wsThread.start(); - - startSilentDisconnectWatchdog(); - } - - /** - * 启动静默断连看门狗。 - *

- * SDK 内部有 ping/pong 心跳,正常情况下连接断开会触发 start() 返回 → onDisconnected。 - * 但少数场景 TCP 层认为连接还在但事件不再流入(NAT 超时、半开连接、对端 hang 死), - * SDK 也察觉不到。看门狗每 60s 检查一次「最近事件时间」,超过阈值就强制重连。 - *

- * 静默判定有两个前置条件: - * 1. 已经收到过至少一个事件(避免新连接立即触发误报) - * 2. silent_disconnect_threshold_seconds > 0(设为 0 可禁用看门狗) - *

- * 默认阈值 30 分钟。安静的渠道(一天没几条消息)建议调大到 1-2 小时; - * 高频渠道可以调小到 5-10 分钟以更快发现问题。 - */ - private void startSilentDisconnectWatchdog() { - long thresholdSec = getConfigLong("silent_disconnect_threshold_seconds", - DEFAULT_SILENT_THRESHOLD_SECONDS); - if (thresholdSec <= 0) { - log.debug("[feishu] Silent disconnect watchdog disabled (threshold=0)"); - return; - } - long thresholdMs = thresholdSec * 1000L; - - cancelSilentDisconnectWatchdog(); - silentDisconnectWatchdog = ensureReconnectScheduler().scheduleAtFixedRate(() -> { - if (!running.get() || wsClient == null) return; - if (!hasReceivedFirstEvent) return; // 没收到首个事件前不算静默 - - long silentMs = System.currentTimeMillis() - lastEventTimeMs.get(); - if (silentMs > thresholdMs) { - log.warn("[feishu] Silent WebSocket detected: no events for {}s (threshold {}s), forcing reconnect", - silentMs / 1000, thresholdSec); - onDisconnected("silent disconnect: no events for " + (silentMs / 1000) + "s"); - } - }, WATCHDOG_INTERVAL_SECONDS, WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS); - log.info("[feishu] Silent disconnect watchdog started (threshold {}s, check every {}s)", - thresholdSec, WATCHDOG_INTERVAL_SECONDS); - } - - private void cancelSilentDisconnectWatchdog() { - if (silentDisconnectWatchdog != null) { - silentDisconnectWatchdog.cancel(false); - silentDisconnectWatchdog = null; + }, "feishu-ws-" + channelEntity.getId()); + wsThread.setDaemon(true); + wsThread.start(); + startTransportWatchdog(); + } + } + + private void startTransportWatchdog() { + if (transportWatchdog != null) transportWatchdog.cancel(false); + transportWatchdog = ensureReconnectScheduler().scheduleWithFixedDelay( + this::checkWebSocketTransport, WATCHDOG_INTERVAL_SECONDS, + WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + void checkWebSocketTransport() { + synchronized (wsLifecycleLock) { + FeishuWebSocketClient client = wsClient; + if (running.get() && client != null + && connectionState.get() == ConnectionState.CONNECTED && !client.isConnected()) { + // onDisconnected sets RECONNECTING before another probe can schedule a retry. + onDisconnected("WebSocket transport closed"); + } } } - /** - * 启动 WebSocket 长连接(同步,用于 doReconnect 重连线程) - * 在当前线程中阻塞调用 start(): - * - 连接成功后 start() 会一直阻塞(收消息),不会返回 - * - 连接失败 start() 抛异常,由 scheduleReconnect 的 catch 捕获 → onReconnectFailed → 退避递增 - */ private void startWebSocketSync(String appId, String appSecret) { - wsClient = createWsClient(appId, appSecret); - log.info("[feishu] WebSocket connecting (long connection)..."); - // 看门狗必须在 start() 之前启动,否则 start() 阻塞后这一行永远到不了, - // 一旦断连后重连这条路径,watchdog 就再也不会被恢复 - startSilentDisconnectWatchdog(); - wsClient.start(); // 阻塞:成功则永驻,失败则抛异常 + FeishuWebSocketClient client; + synchronized (wsLifecycleLock) { + if (!running.get()) throw new IllegalStateException("Channel stopped"); + client = createWsClient(appId, appSecret); + wsClient = client; + } + try { + client.start(); + synchronized (wsLifecycleLock) { + if (!running.get() || wsClient != client) throw new IllegalStateException("Channel stopped"); + startTransportWatchdog(); + } + } catch (Exception e) { + client.close(); + throw new IllegalStateException("WebSocket handshake failed", e); + } } - /** - * 关闭 WebSocket 连接。 - *

- * 必须主动关闭底层 WebSocket 连接并触发 SDK 的内部清理(停止 pingLoop、 - * 释放 ExecutorService)。仅置空引用会导致旧连接的 pingLoop 和线程池 - * 持续运行,造成文件描述符和线程泄漏,最终使新连接无法建立。 - *

- * SDK 2.7.0 起暴露了 public {@code close()} 入口,内部调用 protected - * {@code disconnect()} 完成 {@code conn.close(1000) → executor.shutdown() → - * 字段清零} 的全套清理。直接调用即可,无需反射。 - */ + /** Permanently release the old SDK pool before a new client can be created. */ private void stopWebSocket() { - cancelSilentDisconnectWatchdog(); - hasReceivedFirstEvent = false; - if (wsThread != null) { - wsThread.interrupt(); + FeishuWebSocketClient previous; + Thread thread; + synchronized (wsLifecycleLock) { + if (transportWatchdog != null) { + transportWatchdog.cancel(false); + transportWatchdog = null; + } + previous = wsClient; + wsClient = null; + thread = wsThread; wsThread = null; } - if (wsClient != null) { - try { - wsClient.close(); - } catch (Exception e) { - log.warn("[feishu] WebSocket close failed: {}", e.getMessage()); - } - } - wsClient = null; + if (thread != null) thread.interrupt(); + if (previous != null) previous.close(); } /** @@ -726,9 +700,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre var message = eventBody.getMessage(); var sender = eventBody.getSender(); - // 任何事件到达都更新活跃时间戳,看门狗用它判断是否静默断连 + // Record business activity without using message frequency as transport health. touchActivity(); - hasReceivedFirstEvent = true; // 旧事件过滤:SDK 在重连后可能回放历史事件,按 message.create_time 过滤 // 远超 stale 阈值的消息(典型场景:连接断了 5 分钟后恢复,5 分钟前的消息再处理一次没意义) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuWebSocketClient.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuWebSocketClient.java new file mode 100644 index 00000000..8db58210 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuWebSocketClient.java @@ -0,0 +1,128 @@ +package vip.mate.channel.feishu; + +import com.lark.oapi.okhttp.OkHttpClient; +import com.lark.oapi.okhttp.WebSocket; +import com.lark.oapi.ws.Client; + +import java.lang.reflect.Field; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Owns one oapi-sdk 2.7.1 WebSocket client for its entire lifetime. + * The SDK's close() does not release its executors and exposes neither its + * transport nor an HTTP-client builder. Keep this version-bound compatibility + * bridge here; its field contract and real socket lifecycle are regression tested. + * Only the adapter reconnects (SDK autoReconnect must be false). + */ +final class FeishuWebSocketClient implements AutoCloseable { + private static final Field EXECUTOR = field("executor", ExecutorService.class); + private static final Field HTTP_CLIENT = field("httpClient", OkHttpClient.class); + private static final Field CONNECTION = field("conn", WebSocket.class); + + private final Client client; + private final ExecutorService executor; + private final OkHttpClient httpClient; + private final AtomicBoolean closed = new AtomicBoolean(); + private final Object lifecycleLock = new Object(); + private volatile Thread starter; + private volatile boolean ready; + + FeishuWebSocketClient(Client client) { + this(client, 20_000); + } + + // Short protocol ping interval is used only by local socket tests. + FeishuWebSocketClient(Client client, long pingIntervalMs) { + this.client = client; + this.executor = (ExecutorService) read(EXECUTOR, client); + OkHttpClient original = (OkHttpClient) read(HTTP_CLIENT, client); + this.httpClient = original.newBuilder() + .pingInterval(pingIntervalMs, TimeUnit.MILLISECONDS) + .callTimeout(10, TimeUnit.SECONDS) + .build(); + try { + HTTP_CLIENT.set(client, httpClient); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unsupported Feishu SDK HTTP client contract", e); + } + } + + void start() throws Exception { + synchronized (lifecycleLock) { + if (closed.get()) throw new IllegalStateException("WebSocket client already closed"); + starter = Thread.currentThread(); + try { + // start() initiates an asynchronous handshake; returning is NOT a disconnect. + client.start(); + client.awaitReady(10_000); + if (closed.get()) throw new IllegalStateException("WebSocket client closed during startup"); + ready = true; + } catch (Exception e) { + close(); + throw e; + } finally { + starter = null; + } + } + } + + boolean isConnected() { + // SDK 2.7.1 conn is non-volatile; disconnect() clears it under this monitor. + synchronized (client) { + return ready && !closed.get() && read(CONNECTION, client) != null; + } + } + + @Override + public void close() { + closed.set(true); + ready = false; + // Unblock endpoint requests/handshake before acquiring the lifecycle lock. + httpClient.dispatcher().cancelAll(); + Thread thread = starter; + if (thread != null && thread != Thread.currentThread()) thread.interrupt(); + synchronized (lifecycleLock) { + WebSocket connection = null; + try { + synchronized (client) { + connection = (WebSocket) read(CONNECTION, client); + client.close(); + } + } finally { + try { + // SDK close() only enqueues a graceful close. Permanent disposal must + // abort the socket even when the peer never completes the handshake. + // Cancel outside the SDK monitor so failure callbacks can acquire it. + if (connection != null) connection.cancel(); + } finally { + executor.shutdownNow(); + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + } + + private static Field field(String name, Class expectedType) { + try { + Field field = Client.class.getDeclaredField(name); + if (!expectedType.isAssignableFrom(field.getType())) { + throw new IllegalStateException("Unsupported Feishu SDK field: " + name); + } + field.setAccessible(true); + return field; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unsupported Feishu SDK lifecycle contract: " + name, e); + } + } + + private static Object read(Field field, Client client) { + try { + return field.get(client); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Cannot access Feishu SDK lifecycle resource", e); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelHealthMonitorQuietTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelHealthMonitorQuietTest.java new file mode 100644 index 00000000..61970fba --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelHealthMonitorQuietTest.java @@ -0,0 +1,50 @@ +package vip.mate.channel; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.channel.feishu.FeishuChannelAdapter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.weixin.WeixinChannelAdapter; +import java.time.Duration; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ChannelHealthMonitorQuietTest { + private final ChannelManager manager=mock(ChannelManager.class); + private final ChannelHealthMonitor monitor=new ChannelHealthMonitor(manager); + private final ChannelMessageRouter router=mock(ChannelMessageRouter.class); + private final ObjectMapper json=new ObjectMapper(); + private ChannelEntity entity() { + ChannelEntity e=new ChannelEntity();e.setId(42L);e.setName("health test");e.setConfigJson("{}");return e; + } + private void simulateActivityAge(AbstractChannelAdapter adapter,Duration age,AbstractChannelAdapter.ConnectionState state) { + adapter.getConnectionState().set(state); + adapter.getLastEventTimeMs().set(System.currentTimeMillis()-age.toMillis()); + when(manager.getActiveAdapters()).thenReturn(List.of(adapter)); + } + @Test void quietFeishuDoesNotTriggerHourlyRestart() { + var adapter=new FeishuChannelAdapter(entity(),router,json); + simulateActivityAge(adapter,Duration.ofHours(3),AbstractChannelAdapter.ConnectionState.CONNECTED); + monitor.checkHealth();verify(manager,never()).restartChannel(anyLong()); + assertEquals(Duration.ZERO,adapter.stalenessThreshold()); + } + @Test void idleExemptionStillRecoversErrorAndHonorsCooldown() { + var adapter=new FeishuChannelAdapter(entity(),router,json); + simulateActivityAge(adapter,Duration.ofMinutes(6),AbstractChannelAdapter.ConnectionState.ERROR); + monitor.checkHealth();monitor.checkHealth(); + verify(manager,times(1)).restartChannel(42L); + } + @Test void idleExemptionDoesNotRestartRecentErrors() { + var adapter=new FeishuChannelAdapter(entity(),router,json); + simulateActivityAge(adapter,Duration.ofMinutes(2),AbstractChannelAdapter.ConnectionState.ERROR); + monitor.checkHealth();verify(manager,never()).restartChannel(anyLong()); + } + @Test void longPollStalenessStillRestartsAfterFiveMinutes() { + // Real Weixin override, with no network startup or token configuration. + var adapter=mock(WeixinChannelAdapter.class,withSettings().useConstructor(entity(),router,json).defaultAnswer(CALLS_REAL_METHODS)); + assertEquals(Duration.ofMinutes(5),adapter.stalenessThreshold()); + simulateActivityAge(adapter,Duration.ofMinutes(6),AbstractChannelAdapter.ConnectionState.CONNECTED); + monitor.checkHealth();verify(manager).restartChannel(42L); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuWebSocketLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuWebSocketLifecycleTest.java new file mode 100644 index 00000000..301c6238 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuWebSocketLifecycleTest.java @@ -0,0 +1,84 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lark.oapi.ws.Client; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class FeishuWebSocketLifecycleTest { + @Test void permanentlyClosingAdapterTerminatesSdkThreads() throws Exception { + ChannelEntity entity = new ChannelEntity(); entity.setId(42L); entity.setConfigJson("{}"); + FeishuChannelAdapter adapter = new FeishuChannelAdapter(entity, mock(ChannelMessageRouter.class), new ObjectMapper()); + Client sdk = new Client.Builder("local-test", "local-test").autoReconnect(false).build(); + var f = Client.class.getDeclaredField("executor"); f.setAccessible(true); + ExecutorService executor = (ExecutorService) f.get(sdk); + executor.submit(() -> {}).get(2, TimeUnit.SECONDS); + var c = FeishuChannelAdapter.class.getDeclaredField("wsClient"); c.setAccessible(true); c.set(adapter, new FeishuWebSocketClient(sdk)); + var stop = FeishuChannelAdapter.class.getDeclaredMethod("stopWebSocket"); stop.setAccessible(true); + try { + stop.invoke(adapter); + assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS), "old SDK executor must terminate on permanent stop"); + } finally { sdk.close(); executor.shutdownNow(); } + } + @Test + void transportStatusReadsUnderSdkMonitor() throws Exception { + try (var server = new LocalFeishuSocketServer()) { + Client sdk = new Client.Builder("local", "local") + .domain(server.domain()).autoReconnect(false).build(); + try (var client = new FeishuWebSocketClient(sdk)) { + client.start(); + var result = new java.util.concurrent.atomic.AtomicReference(); + Thread observer = new Thread(() -> result.set(client.isConnected()), "transport-status-reader"); + try { + synchronized (sdk) { + observer.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (observer.getState() != Thread.State.BLOCKED + && observer.getState() != Thread.State.TERMINATED + && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertEquals(Thread.State.BLOCKED, observer.getState(), + "conn is non-volatile: reads must acquire the monitor used by SDK disconnect()"); + assertNull(result.get(), "status read must not bypass the SDK monitor"); + } + } finally { + observer.join(3_000); + } + assertFalse(observer.isAlive()); + assertEquals(Boolean.TRUE, result.get()); + } + } + } + @Test + void permanentCloseCancelsTransportWithoutWaitingForPeerClose() throws Exception { + Client sdk = new Client.Builder("local", "local").autoReconnect(false).build(); + var socket = mock(com.lark.oapi.okhttp.WebSocket.class); + var connection = Client.class.getDeclaredField("conn"); + connection.setAccessible(true); + connection.set(sdk, socket); + try (var client = new FeishuWebSocketClient(sdk)) { + client.close(); + org.mockito.Mockito.verify(socket).cancel(); + } + } + @Test + void sdkCloseFailureStillCancelsSocketAndReleasesExecutors() throws Exception { + Client sdk = new Client.Builder("local", "local").autoReconnect(false).build(); + var socket = mock(com.lark.oapi.okhttp.WebSocket.class); + org.mockito.Mockito.when(socket.close(org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyString())).thenThrow(new IllegalStateException("close failed")); + var connection = Client.class.getDeclaredField("conn"); + connection.setAccessible(true); + connection.set(sdk, socket); + try (var client = new FeishuWebSocketClient(sdk)) { + assertThrows(IllegalStateException.class, client::close); + org.mockito.Mockito.verify(socket).cancel(); + FeishuWebSocketTransportTest.assertReleased(sdk); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuWebSocketTransportTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuWebSocketTransportTest.java new file mode 100644 index 00000000..0d827e44 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuWebSocketTransportTest.java @@ -0,0 +1,171 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lark.oapi.okhttp.OkHttpClient; +import com.lark.oapi.ws.Client; +import org.junit.jupiter.api.Test; +import vip.mate.channel.AbstractChannelAdapter.ConnectionState; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.ExponentialBackoff; +import vip.mate.channel.model.ChannelEntity; + +import java.net.Socket; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class FeishuWebSocketTransportTest { + static Object field(Object target, String name) throws Exception { + var f=Client.class.getDeclaredField(name);f.setAccessible(true);return f.get(target); + } + static void await(BooleanSupplier predicate) throws Exception { + long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(4); + while(!predicate.getAsBoolean() && System.nanoTime() sdks=new CopyOnWriteArrayList<>(); + TestAdapter(LocalFeishuSocketServer server) { + super(entity(),mock(ChannelMessageRouter.class),new ObjectMapper());this.server=server; + backoff=new ExponentialBackoff(50,100,2,-1); + } + static ChannelEntity entity() { + ChannelEntity e=new ChannelEntity();e.setId(73L);e.setName("local transport"); + e.setConfigJson("{\"app_id\":\"local\",\"app_secret\":\"local\"}");return e; + } + @Override protected void doStart() { startWebSocket("local","local"); } + @Override FeishuWebSocketClient createWsClient(String id,String secret) { + Client sdk=new Client.Builder(id,secret).domain(server.domain()).autoReconnect(false).build(); + sdks.add(sdk);return new FeishuWebSocketClient(sdk,100); + } + } + static void assertReleased(Client sdk) throws Exception { + ExecutorService pool=(ExecutorService)field(sdk,"executor"); + OkHttpClient http=(OkHttpClient)field(sdk,"httpClient"); + assertTrue(pool.awaitTermination(3,TimeUnit.SECONDS)); + assertTrue(http.dispatcher().executorService().awaitTermination(3,TimeUnit.SECONDS)); + assertEquals(0,http.connectionPool().connectionCount()); + } + @Test void delayedHandshakeStaysReconnectingThenUsesOnlyOneClient() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + server.handshakeDelayMs=250; + var adapter=new TestAdapter(server); + try { + adapter.start();assertEquals(ConnectionState.RECONNECTING,adapter.getConnectionState().get()); + await(()->adapter.getConnectionState().get()==ConnectionState.CONNECTED); + adapter.getLastEventTimeMs().set(System.currentTimeMillis()-Duration.ofHours(3).toMillis()); + for(int i=0;i<10;i++) adapter.checkWebSocketTransport(); + assertEquals(1,adapter.sdks.size());assertEquals(1,server.handshakes.get()); + } finally { adapter.stop(); } + assertReleased(adapter.sdks.getFirst()); + } + } + @Test void closeBetweenHandshakeAndSuccessDoesNotGetStuckReconnecting() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + var successReached=new CountDownLatch(1); + var allowSuccess=new CountDownLatch(1); + var adapter=new TestAdapter(server) { + @Override protected void onReconnectSuccess() { + successReached.countDown(); + try { assertTrue(allowSuccess.await(3,TimeUnit.SECONDS)); } + catch(InterruptedException e) {Thread.currentThread().interrupt();throw new IllegalStateException(e);} + super.onReconnectSuccess(); + } + }; + try { + adapter.start();assertTrue(successReached.await(3,TimeUnit.SECONDS)); + server.awaitConnection().close(); + await(()->{try{return field(adapter.sdks.getFirst(),"conn")==null;}catch(Exception e){throw new RuntimeException(e);}}); + allowSuccess.countDown(); + await(()->adapter.sdks.size()==2 && adapter.getConnectionState().get()==ConnectionState.CONNECTED); + assertReleased(adapter.sdks.getFirst()); + } finally { allowSuccess.countDown();adapter.stop(); } + } + } + @Test void tcpAbortReconnectsOnceAndReleasesOldClient() throws Exception { reconnect(false); } + @Test void peerCloseReconnectsOnceAndReleasesOldClient() throws Exception { reconnect(true); } + private void reconnect(boolean graceful) throws Exception { + try(var server=new LocalFeishuSocketServer()) { + var adapter=new TestAdapter(server); + try { + adapter.start();await(()->adapter.getConnectionState().get()==ConnectionState.CONNECTED); + Socket old=server.awaitConnection(); + if(graceful) LocalFeishuSocketServer.frame(old,8,new byte[]{3,(byte)232}); + else old.setSoLinger(true,0); + old.close(); + await(()->{adapter.checkWebSocketTransport();return adapter.sdks.size()==2 && adapter.getConnectionState().get()==ConnectionState.CONNECTED;}); + assertEquals(2,server.handshakes.get());assertReleased(adapter.sdks.getFirst()); + } finally { adapter.stop(); } + for(Client sdk:adapter.sdks) assertReleased(sdk); + } + } + @Test void protocolPingDetectsHalfOpenSocketWithoutBusinessMessages() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + var adapter=new TestAdapter(server); + try { + adapter.start();await(()->adapter.getConnectionState().get()==ConnectionState.CONNECTED); + server.replyToPing=false; + await(()->{adapter.checkWebSocketTransport();return adapter.sdks.size()>=2;}); + server.replyToPing=true; + await(()->adapter.getConnectionState().get()==ConnectionState.CONNECTED); + assertReleased(adapter.sdks.getFirst()); + } finally { adapter.stop(); } + } + } + @Test void rejectedHandshakeIsRetriedWithoutLeakingTheFailedClient() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + server.rejectHandshake=true; + var adapter=new TestAdapter(server); + try { + adapter.start();await(()->adapter.sdks.size()>=2); + server.rejectHandshake=false; + await(()->adapter.getConnectionState().get()==ConnectionState.CONNECTED); + assertReleased(adapter.sdks.getFirst()); + } finally { adapter.stop(); } + for(Client sdk:adapter.sdks) assertReleased(sdk); + } + } + @Test void repeatedRealStartStopDoesNotAccumulateExecutorThreads() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + for(int i=0;i<25;i++) { + var adapter=new TestAdapter(server); + try { adapter.start();await(()->adapter.getConnectionState().get()==ConnectionState.CONNECTED); } + finally { adapter.stop(); } + assertEquals(1,adapter.sdks.size());assertReleased(adapter.sdks.getFirst()); + } + assertEquals(25,server.handshakes.get()); + } + } + @Test void stopDuringDelayedHandshakeReturnsWithinManagerDeadline() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + server.handshakeDelayMs=2_000; + var adapter=new TestAdapter(server);adapter.start(); + assertTrue(server.endpointRequested.await(2,TimeUnit.SECONDS)); + assertTimeoutPreemptively(Duration.ofSeconds(3),adapter::stop); + Thread.sleep(100); + assertFalse(adapter.isRunning());assertEquals(ConnectionState.DISCONNECTED,adapter.getConnectionState().get()); + for(Client sdk:adapter.sdks) assertReleased(sdk); + } + } + @Test void stopCancelsStalledEndpointRequest() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + server.stallEndpoint=true; + var adapter=new TestAdapter(server);adapter.start(); + assertTrue(server.endpointRequested.await(2,TimeUnit.SECONDS)); + assertTimeoutPreemptively(Duration.ofSeconds(3),adapter::stop); + assertFalse(adapter.isRunning());for(Client sdk:adapter.sdks) assertReleased(sdk); + } + } + @Test void closeBeforeStartCannotReopenSdkOrCreateThreads() throws Exception { + try(var server=new LocalFeishuSocketServer()) { + Client sdk=new Client.Builder("local","local").domain(server.domain()).autoReconnect(false).build(); + var client=new FeishuWebSocketClient(sdk);client.close();client.close(); + assertThrows(IllegalStateException.class,client::start); + assertEquals(0,server.handshakes.get());assertReleased(sdk); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/LocalFeishuSocketServer.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/LocalFeishuSocketServer.java new file mode 100644 index 00000000..26795f49 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/LocalFeishuSocketServer.java @@ -0,0 +1,96 @@ +package vip.mate.channel.feishu; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** Loopback-only HTTP endpoint + RFC6455 peer; never contacts Feishu or a model. */ +final class LocalFeishuSocketServer implements AutoCloseable { + final ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + final Set sockets = ConcurrentHashMap.newKeySet(); + final BlockingQueue connections = new LinkedBlockingQueue<>(); + final AtomicInteger handshakes = new AtomicInteger(); + final ExecutorService threads = Executors.newCachedThreadPool(); + volatile long handshakeDelayMs; + volatile boolean rejectHandshake; + volatile boolean replyToPing = true; + volatile boolean stallEndpoint; + final CountDownLatch endpointRequested = new CountDownLatch(1); + + LocalFeishuSocketServer() throws IOException { + threads.submit(() -> { + try { + while (!listener.isClosed()) { + Socket socket = listener.accept(); sockets.add(socket); + threads.submit(() -> serve(socket)); + } + } catch (IOException ignored) { } + }); + } + String domain() { return "http://127.0.0.1:" + listener.getLocalPort(); } + + private void serve(Socket socket) { + try (socket) { + InputStream in = socket.getInputStream(); + ByteArrayOutputStream header = new ByteArrayOutputStream(); + while (!header.toString(StandardCharsets.US_ASCII).endsWith("\r\n\r\n")) { + int next = in.read(); if (next < 0) return; header.write(next); + if (header.size() > 16_384) throw new IOException("oversize request"); + } + String headers = header.toString(StandardCharsets.US_ASCII); + if (headers.startsWith("POST ")) { + endpointRequested.countDown(); + if (stallEndpoint) { while (in.read() >= 0) {} return; } + String body = "{\"code\":0,\"data\":{\"URL\":\"ws://127.0.0.1:" + listener.getLocalPort() + + "/socket?device_id=local&service_id=1\",\"ClientConfig\":{\"ReconnectCount\":0,\"ReconnectInterval\":0,\"ReconnectNonce\":0,\"PingInterval\":30}}}"; + write(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + body.getBytes(StandardCharsets.UTF_8).length + "\r\nConnection: close\r\n\r\n" + body); + return; + } + if (handshakeDelayMs > 0) Thread.sleep(handshakeDelayMs); + if (rejectHandshake) { write(socket, "HTTP/1.1 503 Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; } + String key = Arrays.stream(headers.split("\r\n")).filter(s -> s.toLowerCase(Locale.ROOT).startsWith("sec-websocket-key:")) + .findFirst().orElseThrow().split(":",2)[1].trim(); + String accept = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1") + .digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII))); + write(socket,"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"); + handshakes.incrementAndGet(); connections.add(socket); + while (true) { + int first=in.read(); if(first<0) return; + int second=in.read(); if(second<0) return; + int length=second&127; + if(length==126) length=(in.read()<<8)|in.read(); + if(length==127) throw new IOException("unexpected test frame length"); + byte[] mask=(second&128)!=0 ? in.readNBytes(4) : new byte[0]; + byte[] payload=in.readNBytes(length); + for(int i=0;i