fix(feishu): own WebSocket readiness and resource lifecycle (#659)

Contributed by RobinZhiBin <robinzhi@gmail.com> (@RobinZhiBin). Wait for handshake readiness, release transport resources, and validate liveness using protocol pings. Includes both original commits; verified by local socket and channel health regression tests.
This commit is contained in:
RobinZhiBin 2026-10-09 15:53:46 +08:00 committed by GitHub
parent 6365f0af8a
commit 61b7f56bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 713 additions and 147 deletions

View File

@ -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.

View File

@ -159,6 +159,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
* 重连成功回调 * 重连成功回调
*/ */
protected void onReconnectSuccess() { protected void onReconnectSuccess() {
if (!running.get()) return;
backoff.reset(); backoff.reset();
connectionState.set(ConnectionState.CONNECTED); connectionState.set(ConnectionState.CONNECTED);
lastEventTimeMs.set(System.currentTimeMillis()); lastEventTimeMs.set(System.currentTimeMillis());
@ -171,6 +172,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
* 重连失败回调 * 重连失败回调
*/ */
protected void onReconnectFailed(Exception e) { protected void onReconnectFailed(Exception e) {
if (!running.get()) return;
lastError = e.getMessage(); lastError = e.getMessage();
log.warn("[{}] Reconnect failed for {}: {} (attempt #{})", log.warn("[{}] Reconnect failed for {}: {} (attempt #{})",
getChannelType(), channelEntity.getName(), e.getMessage(), backoff.getAttempts()); getChannelType(), channelEntity.getName(), e.getMessage(), backoff.getAttempts());
@ -182,11 +184,14 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
if (running.compareAndSet(false, true)) { if (running.compareAndSet(false, true)) {
log.info("[{}] Starting channel: {}", getChannelType(), channelEntity.getName()); log.info("[{}] Starting channel: {}", getChannelType(), channelEntity.getName());
try { try {
connectionState.set(ConnectionState.RECONNECTING);
doStart(); doStart();
if (!connectsAsynchronously()) {
connectionState.set(ConnectionState.CONNECTED); connectionState.set(ConnectionState.CONNECTED);
lastEventTimeMs.set(System.currentTimeMillis()); lastEventTimeMs.set(System.currentTimeMillis());
lastError = null; lastError = null;
backoff.reset(); backoff.reset();
}
log.info("[{}] Channel started successfully: {}", getChannelType(), channelEntity.getName()); log.info("[{}] Channel started successfully: {}", getChannelType(), channelEntity.getName());
} catch (Exception e) { } catch (Exception e) {
running.set(false); 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 @Override
public void stop() { public void stop() {
if (running.compareAndSet(true, false)) { if (running.compareAndSet(true, false)) {

View File

@ -248,6 +248,8 @@ public interface ChannelAdapter {
/** /**
* RFC-024 Change 2:本 adapter 认为"多久没活动就视作 stale 需要重启"的阈值。 * RFC-024 Change 2:本 adapter 认为"多久没活动就视作 stale 需要重启"的阈值。
* *
* <p>返回 Duration.ZERO 可禁用消息静默检查(无状态 HTTP 或自带传输心跳的渠道)。
* ERROR 状态的恢复不受影响。
* <p>通用默认 60 分钟;长轮询类渠道(如 iLink 微信)应覆盖为 5 分钟, * <p>通用默认 60 分钟;长轮询类渠道(如 iLink 微信)应覆盖为 5 分钟,
* 这样代理/NAT 侧 2–5 分钟 idle-close 把连接切断后, * 这样代理/NAT 侧 2–5 分钟 idle-close 把连接切断后,
* {@code ChannelHealthMonitor} 能在几分钟内触发自动重启,而非静默等整小时。</p> * {@code ChannelHealthMonitor} 能在几分钟内触发自动重启,而非静默等整小时。</p>

View File

@ -82,12 +82,12 @@ public class ChannelHealthMonitor {
Duration staleThreshold; Duration staleThreshold;
try { try {
Duration d = adapter.stalenessThreshold(); 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) { } catch (Exception ex) {
staleThreshold = DEFAULT_STALE_THRESHOLD; staleThreshold = DEFAULT_STALE_THRESHOLD;
} }
if (reason == null && state == AbstractChannelAdapter.ConnectionState.CONNECTED 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)", reason = String.format("stale connection, no events for %ds (threshold=%ds)",
sinceLastEvent / 1000, staleThreshold.toSeconds()); sinceLastEvent / 1000, staleThreshold.toSeconds());
} }

View File

@ -63,7 +63,7 @@ import java.util.concurrent.TimeUnit;
* - enable_nickname_cache: 是否通过 Contact API 获取用户昵称(默认 true) * - enable_nickname_cache: 是否通过 Contact API 获取用户昵称(默认 true)
* - media_download_enabled: 是否下载消息中的媒体文件(默认 false) * - media_download_enabled: 是否下载消息中的媒体文件(默认 false)
* - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt(默认 true) * - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt(默认 true)
* - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值(默认 1800,0 禁用) * - silent_disconnect_threshold_seconds: 已弃用;健康检查使用协议心跳,不依赖消息频率
* - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用) * - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用)
* - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never" * - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never"
* 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; private static final int NICKNAME_CACHE_MAX = 500;
/** WebSocket 客户端(websocket 模式) */ /** WebSocket 客户端(websocket 模式) */
private volatile com.lark.oapi.ws.Client wsClient; private volatile FeishuWebSocketClient wsClient;
private final Object wsLifecycleLock = new Object();
/** WebSocket 连接线程 */ /** WebSocket 连接线程 */
private volatile Thread wsThread; private volatile Thread wsThread;
/** WebSocket 静默断连看门狗:定期检查最近事件时间,超过阈值就强制重连 */ /** Poll transport failure; user-message silence is never a disconnect signal. */
private ScheduledFuture<?> silentDisconnectWatchdog; private ScheduledFuture<?> transportWatchdog;
private static final long WATCHDOG_INTERVAL_SECONDS = 15L;
/** 是否已收到至少一个事件(用于避免新连接立即触发静默超时) */
private volatile boolean hasReceivedFirstEvent = false;
/** 看门狗检查间隔(秒) */
private static final long WATCHDOG_INTERVAL_SECONDS = 60L;
/** 静默断连默认阈值(秒):30 分钟无事件就视为可疑 */
private static final long DEFAULT_SILENT_THRESHOLD_SECONDS = 1800L;
/** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */ /** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */
private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L; 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 @Override
protected void doStart() { 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 appId = getConfigString("app_id");
String appSecret = getConfigString("app_secret"); String appSecret = getConfigString("app_secret");
@ -439,7 +463,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
// 关闭 WebSocket // 关闭 WebSocket
stopWebSocket(); stopWebSocket();
HttpClient previousHttpClient = this.httpClient;
this.httpClient = null; this.httpClient = null;
if (previousHttpClient != null) previousHttpClient.shutdownNow();
this.tenantAccessToken = null; this.tenantAccessToken = null;
// Reset bot-open-id state under the same lock getBotOpenId uses, so a // Reset bot-open-id state under the same lock getBotOpenId uses, so a
// dispatch thread mid-fetch sees a consistent (cleared) view rather than // 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 appSecret = getConfigString("app_secret");
String connectionMode = getConfigString("connection_mode", "websocket"); String connectionMode = getConfigString("connection_mode", "websocket");
// 重新建立 HTTP 客户端 // Token refresh has its own schedule; a WebSocket reconnect does not rotate HTTP state.
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();
if ("websocket".equals(connectionMode)) { if ("websocket".equals(connectionMode)) {
log.info("[feishu] Reconnecting WebSocket..."); log.info("[feishu] Reconnecting WebSocket...");
stopWebSocket(); stopWebSocket();
// 同步连接:在当前重连线程中直接阻塞调用 start() // Wait for the handshake; failures propagate to the adapter's single backoff loop.
// 连接成功 start() 会一直阻塞(不会返回到这里)
// 连接失败 start() 抛异常,由 AbstractChannelAdapter.scheduleReconnect 捕获并触发 onReconnectFailed
startWebSocketSync(appId, appSecret); startWebSocketSync(appId, appSecret);
} }
@ -494,7 +504,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
/** /**
* 创建 WebSocket 客户端实例(不启动连接) * 创建 WebSocket 客户端实例(不启动连接)
*/ */
private com.lark.oapi.ws.Client createWsClient(String appId, String appSecret) { FeishuWebSocketClient createWsClient(String appId, String appSecret) {
EventDispatcher eventDispatcher = EventDispatcher.newBuilder("", "") EventDispatcher eventDispatcher = EventDispatcher.newBuilder("", "")
.onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() { .onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() {
@Override @Override
@ -588,130 +598,94 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
}) })
.build(); .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) .eventHandler(eventDispatcher)
.autoReconnect(false) // 由我们的 ExponentialBackoff 控制重连,不用 SDK 内置重连 .autoReconnect(false) // 由我们的 ExponentialBackoff 控制重连,不用 SDK 内置重连
.domain("lark".equals(getConfigString("domain", "feishu")) .domain("lark".equals(getConfigString("domain", "feishu"))
? "https://open.larksuite.com" ? "https://open.larksuite.com"
: "https://open.feishu.cn") : "https://open.feishu.cn")
.build(); .build());
} }
/** /** First connection is asynchronous; start() returning means handshake initiated. */
* 启动 WebSocket 长连接(异步,用于 doStart 首次启动) void startWebSocket(String appId, String appSecret) {
* 在守护线程中运行,避免阻塞主线程。连接失败时触发 onDisconnected → 退避重连 synchronized (wsLifecycleLock) {
*/ if (!running.get()) return;
private void startWebSocket(String appId, String appSecret) { FeishuWebSocketClient client = createWsClient(appId, appSecret);
wsClient = createWsClient(appId, appSecret); wsClient = client;
wsThread = new Thread(() -> { wsThread = new Thread(() -> {
try { try {
log.info("[feishu] WebSocket connecting (long connection)..."); client.start();
wsClient.start(); synchronized (wsLifecycleLock) {
// start() blocks until disconnect; if it returns normally, it means disconnected if (running.get() && wsClient == client) onReconnectSuccess();
if (running.get()) {
onDisconnected("WebSocket connection ended");
} }
} catch (Exception e) { } catch (Exception e) {
log.error("[feishu] WebSocket error: {}", e.getMessage(), e); synchronized (wsLifecycleLock) {
if (running.get()) { if (running.get() && wsClient == client) {
onDisconnected("WebSocket error: " + e.getMessage()); onDisconnected("WebSocket startup failed: " + e.getClass().getSimpleName());
}
} }
} }
}, "feishu-ws-" + channelEntity.getId()); }, "feishu-ws-" + channelEntity.getId());
wsThread.setDaemon(true); wsThread.setDaemon(true);
wsThread.start(); wsThread.start();
startTransportWatchdog();
startSilentDisconnectWatchdog(); }
} }
/** private void startTransportWatchdog() {
* 启动静默断连看门狗。 if (transportWatchdog != null) transportWatchdog.cancel(false);
* <p> transportWatchdog = ensureReconnectScheduler().scheduleWithFixedDelay(
* SDK 内部有 ping/pong 心跳,正常情况下连接断开会触发 start() 返回 → onDisconnected。 this::checkWebSocketTransport, WATCHDOG_INTERVAL_SECONDS,
* 但少数场景 TCP 层认为连接还在但事件不再流入(NAT 超时、半开连接、对端 hang 死), WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS);
* SDK 也察觉不到。看门狗每 60s 检查一次「最近事件时间」,超过阈值就强制重连。 }
* <p>
* 静默判定有两个前置条件: void checkWebSocketTransport() {
* 1. 已经收到过至少一个事件(避免新连接立即触发误报) synchronized (wsLifecycleLock) {
* 2. silent_disconnect_threshold_seconds &gt; 0(设为 0 可禁用看门狗) FeishuWebSocketClient client = wsClient;
* <p> if (running.get() && client != null
* 默认阈值 30 分钟。安静的渠道(一天没几条消息)建议调大到 1-2 小时; && connectionState.get() == ConnectionState.CONNECTED && !client.isConnected()) {
* 高频渠道可以调小到 5-10 分钟以更快发现问题。 // onDisconnected sets RECONNECTING before another probe can schedule a retry.
*/ onDisconnected("WebSocket transport closed");
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;
} }
} }
/**
* 启动 WebSocket 长连接(同步,用于 doReconnect 重连线程)
* 在当前线程中阻塞调用 start():
* - 连接成功后 start() 会一直阻塞(收消息),不会返回
* - 连接失败 start() 抛异常,由 scheduleReconnect 的 catch 捕获 → onReconnectFailed → 退避递增
*/
private void startWebSocketSync(String appId, String appSecret) { private void startWebSocketSync(String appId, String appSecret) {
wsClient = createWsClient(appId, appSecret); FeishuWebSocketClient client;
log.info("[feishu] WebSocket connecting (long connection)..."); synchronized (wsLifecycleLock) {
// 看门狗必须在 start() 之前启动,否则 start() 阻塞后这一行永远到不了, if (!running.get()) throw new IllegalStateException("Channel stopped");
// 一旦断连后重连这条路径,watchdog 就再也不会被恢复 client = createWsClient(appId, appSecret);
startSilentDisconnectWatchdog(); wsClient = client;
wsClient.start(); // 阻塞:成功则永驻,失败则抛异常 }
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);
}
} }
/** /** Permanently release the old SDK pool before a new client can be created. */
* 关闭 WebSocket 连接。
* <p>
* 必须主动关闭底层 WebSocket 连接并触发 SDK 的内部清理(停止 pingLoop、
* 释放 ExecutorService)。仅置空引用会导致旧连接的 pingLoop 和线程池
* 持续运行,造成文件描述符和线程泄漏,最终使新连接无法建立。
* <p>
* SDK 2.7.0 起暴露了 public {@code close()} 入口,内部调用 protected
* {@code disconnect()} 完成 {@code conn.close(1000) → executor.shutdown() →
* 字段清零} 的全套清理。直接调用即可,无需反射。
*/
private void stopWebSocket() { private void stopWebSocket() {
cancelSilentDisconnectWatchdog(); FeishuWebSocketClient previous;
hasReceivedFirstEvent = false; Thread thread;
if (wsThread != null) { synchronized (wsLifecycleLock) {
wsThread.interrupt(); if (transportWatchdog != null) {
transportWatchdog.cancel(false);
transportWatchdog = null;
}
previous = wsClient;
wsClient = null;
thread = wsThread;
wsThread = null; wsThread = null;
} }
if (wsClient != null) { if (thread != null) thread.interrupt();
try { if (previous != null) previous.close();
wsClient.close();
} catch (Exception e) {
log.warn("[feishu] WebSocket close failed: {}", e.getMessage());
}
}
wsClient = null;
} }
/** /**
@ -726,9 +700,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
var message = eventBody.getMessage(); var message = eventBody.getMessage();
var sender = eventBody.getSender(); var sender = eventBody.getSender();
// 任何事件到达都更新活跃时间戳,看门狗用它判断是否静默断连 // Record business activity without using message frequency as transport health.
touchActivity(); touchActivity();
hasReceivedFirstEvent = true;
// 旧事件过滤:SDK 在重连后可能回放历史事件,按 message.create_time 过滤 // 旧事件过滤:SDK 在重连后可能回放历史事件,按 message.create_time 过滤
// 远超 stale 阈值的消息(典型场景:连接断了 5 分钟后恢复,5 分钟前的消息再处理一次没意义) // 远超 stale 阈值的消息(典型场景:连接断了 5 分钟后恢复,5 分钟前的消息再处理一次没意义)

View File

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

View File

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

View File

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

View File

@ -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()<end) Thread.sleep(10);
assertTrue(predicate.getAsBoolean(),"condition did not become true");
}
static class TestAdapter extends FeishuChannelAdapter {
final LocalFeishuSocketServer server;
final List<Client> 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);
}
}
}

View File

@ -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<Socket> sockets = ConcurrentHashMap.newKeySet();
final BlockingQueue<Socket> 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<payload.length && mask.length==4;i++) payload[i]^=mask[i%4];
int opcode=first&15;
if(opcode==8) { frame(socket,8,payload);return; }
if(opcode==9 && replyToPing) frame(socket,10,payload);
}
} catch (Exception ignored) {
// Tests assert observed handshake/readiness/failure. Peer close is expected.
} finally { sockets.remove(socket); }
}
Socket awaitConnection() throws InterruptedException {
Socket socket=connections.poll(3,TimeUnit.SECONDS);
if(socket==null) throw new AssertionError("No WebSocket handshake");
return socket;
}
static void frame(Socket socket,int opcode,byte[] payload) throws IOException {
synchronized(socket) {
OutputStream out=socket.getOutputStream();out.write(128|opcode);out.write(payload.length);out.write(payload);out.flush();
}
}
private static void write(Socket socket,String text) throws IOException {
socket.getOutputStream().write(text.getBytes(StandardCharsets.UTF_8));socket.getOutputStream().flush();
}
@Override public void close() throws Exception {
listener.close();for(Socket s:sockets) s.close();threads.shutdownNow();
if(!threads.awaitTermination(3,TimeUnit.SECONDS)) throw new AssertionError("Fixture threads did not stop");
}
}