mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
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:
parent
6365f0af8a
commit
61b7f56bca
52
docs/compatibility/feishu-websocket-lifecycle.md
Normal file
52
docs/compatibility/feishu-websocket-lifecycle.md
Normal 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.
|
||||||
@ -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();
|
||||||
connectionState.set(ConnectionState.CONNECTED);
|
if (!connectsAsynchronously()) {
|
||||||
lastEventTimeMs.set(System.currentTimeMillis());
|
connectionState.set(ConnectionState.CONNECTED);
|
||||||
lastError = null;
|
lastEventTimeMs.set(System.currentTimeMillis());
|
||||||
backoff.reset();
|
lastError = null;
|
||||||
|
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)) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
client.start();
|
||||||
log.info("[feishu] WebSocket connecting (long connection)...");
|
synchronized (wsLifecycleLock) {
|
||||||
wsClient.start();
|
if (running.get() && wsClient == client) onReconnectSuccess();
|
||||||
// start() blocks until disconnect; if it returns normally, it means disconnected
|
}
|
||||||
if (running.get()) {
|
} catch (Exception e) {
|
||||||
onDisconnected("WebSocket connection ended");
|
synchronized (wsLifecycleLock) {
|
||||||
|
if (running.get() && wsClient == client) {
|
||||||
|
onDisconnected("WebSocket startup failed: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
}, "feishu-ws-" + channelEntity.getId());
|
||||||
log.error("[feishu] WebSocket error: {}", e.getMessage(), e);
|
wsThread.setDaemon(true);
|
||||||
if (running.get()) {
|
wsThread.start();
|
||||||
onDisconnected("WebSocket error: " + e.getMessage());
|
startTransportWatchdog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, "feishu-ws-" + channelEntity.getId());
|
|
||||||
wsThread.setDaemon(true);
|
private void startTransportWatchdog() {
|
||||||
wsThread.start();
|
if (transportWatchdog != null) transportWatchdog.cancel(false);
|
||||||
|
transportWatchdog = ensureReconnectScheduler().scheduleWithFixedDelay(
|
||||||
startSilentDisconnectWatchdog();
|
this::checkWebSocketTransport, WATCHDOG_INTERVAL_SECONDS,
|
||||||
}
|
WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
/**
|
|
||||||
* 启动静默断连看门狗。
|
void checkWebSocketTransport() {
|
||||||
* <p>
|
synchronized (wsLifecycleLock) {
|
||||||
* SDK 内部有 ping/pong 心跳,正常情况下连接断开会触发 start() 返回 → onDisconnected。
|
FeishuWebSocketClient client = wsClient;
|
||||||
* 但少数场景 TCP 层认为连接还在但事件不再流入(NAT 超时、半开连接、对端 hang 死),
|
if (running.get() && client != null
|
||||||
* SDK 也察觉不到。看门狗每 60s 检查一次「最近事件时间」,超过阈值就强制重连。
|
&& connectionState.get() == ConnectionState.CONNECTED && !client.isConnected()) {
|
||||||
* <p>
|
// onDisconnected sets RECONNECTING before another probe can schedule a retry.
|
||||||
* 静默判定有两个前置条件:
|
onDisconnected("WebSocket transport closed");
|
||||||
* 1. 已经收到过至少一个事件(避免新连接立即触发误报)
|
}
|
||||||
* 2. silent_disconnect_threshold_seconds > 0(设为 0 可禁用看门狗)
|
|
||||||
* <p>
|
|
||||||
* 默认阈值 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动 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 分钟前的消息再处理一次没意义)
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user