mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(webchat): close SSE streams on done or error
Close WebChat subscriber SSE connections when the logical stream reaches done/error, make the emitter timeout configurable, and ensure stale-run eviction closes subscribers instead of leaving clients waiting.
This commit is contained in:
parent
ddb6a837ea
commit
e9c08edb3a
@ -1623,6 +1623,11 @@ public class ChatStreamTracker {
|
|||||||
}
|
}
|
||||||
// 先清理资源再移除
|
// 先清理资源再移除
|
||||||
stopHeartbeat(entry.getKey());
|
stopHeartbeat(entry.getKey());
|
||||||
|
// Close subscriber SSE connections so an evicted run does not
|
||||||
|
// leave clients hanging in silence until their own emitter
|
||||||
|
// timeout (issue #586). Aligns the eviction path with the
|
||||||
|
// close-out sequence forceRecycle() uses.
|
||||||
|
closeSubscribers(entry.getKey());
|
||||||
Disposable d = state.disposable;
|
Disposable d = state.disposable;
|
||||||
if (d != null && !d.isDisposed()) {
|
if (d != null && !d.isDisposed()) {
|
||||||
d.dispose();
|
d.dispose();
|
||||||
@ -1781,6 +1786,42 @@ public class ChatStreamTracker {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close every live subscriber's SSE connection for this run.
|
||||||
|
* <p>
|
||||||
|
* For the WebChat channel (issue #586), {@code done}/{@code error} is the
|
||||||
|
* logical end of the stream and downstream integrators reading the SSE
|
||||||
|
* stream by standard semantics ("read until the server closes") must see
|
||||||
|
* the connection actually close — otherwise a 5-second answer holds a
|
||||||
|
* backend connection pool slot for the full 10-minute SseEmitter timeout.
|
||||||
|
* The in-house web channel does NOT call this (it keeps the emitter open
|
||||||
|
* for reconnect + buffer replay of late {@code async_task_*} events); the
|
||||||
|
* close-on-done policy is channel-scoped, not global.
|
||||||
|
* <p>
|
||||||
|
* Also the shared closing sequence invoked by {@link #cleanupStaleRuns()}
|
||||||
|
* on eviction so a forcibly-reclaimed run does not leave subscribers
|
||||||
|
* hanging in silence until their own timeout fires.
|
||||||
|
* <p>
|
||||||
|
* Idempotent: safe to call when no run exists or subscribers are already
|
||||||
|
* empty. Each {@code em.complete()} is wrapped so one dead subscriber
|
||||||
|
* cannot abort the loop before later subscribers are closed.
|
||||||
|
*/
|
||||||
|
public void closeSubscribers(String conversationId) {
|
||||||
|
RunState state = runs.get(conversationId);
|
||||||
|
if (state == null) return;
|
||||||
|
synchronized (state.lock) {
|
||||||
|
for (SseEmitter em : state.subscribers) {
|
||||||
|
try {
|
||||||
|
em.complete();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// A subscriber that is already closed/errored must not
|
||||||
|
// prevent the rest from being closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.subscribers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force a wedged run to terminate. Used by the admin Live view's
|
* Force a wedged run to terminate. Used by the admin Live view's
|
||||||
* "End it" action when the friendly stop has been observed not to take
|
* "End it" action when the friendly stop has been observed not to take
|
||||||
|
|||||||
@ -103,6 +103,20 @@ public class WebChatController {
|
|||||||
@Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}")
|
@Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}")
|
||||||
private String visitorTokenSecret;
|
private String visitorTokenSecret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SseEmitter timeout (minutes) for WebChat SSE streams. Previously
|
||||||
|
* hardcoded to 10 minutes across three {@code new Utf8SseEmitter(...)} call
|
||||||
|
* sites; downstream integrators were forced to reason about a constant
|
||||||
|
* living in someone else's repo (issue #586). Configurable so operators
|
||||||
|
* have a documented knob, defaulting to the historical 10 minutes.
|
||||||
|
*/
|
||||||
|
@Value("${mateclaw.webchat.sse-timeout-minutes:10}")
|
||||||
|
private int webchatSseTimeoutMinutes;
|
||||||
|
|
||||||
|
private long sseTimeoutMillis() {
|
||||||
|
return (long) webchatSseTimeoutMinutes * 60_000L;
|
||||||
|
}
|
||||||
|
|
||||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -115,7 +129,7 @@ public class WebChatController {
|
|||||||
@RequestBody WebChatRequest request) {
|
@RequestBody WebChatRequest request) {
|
||||||
|
|
||||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
SseEmitter emitter = new Utf8SseEmitter(sseTimeoutMillis());
|
||||||
|
|
||||||
// 验证 API Key 并获取关联的 Channel 配置
|
// 验证 API Key 并获取关联的 Channel 配置
|
||||||
ChannelEntity channel = resolveChannel(apiKey);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
@ -282,12 +296,21 @@ public class WebChatController {
|
|||||||
persistErr.getMessage());
|
persistErr.getMessage());
|
||||||
}
|
}
|
||||||
streamTracker.broadcast(conversationId, "done", "{\"status\":\"completed\"}");
|
streamTracker.broadcast(conversationId, "done", "{\"status\":\"completed\"}");
|
||||||
|
// WebChat is a pure-backend SSE channel with no re-attach
|
||||||
|
// endpoint: for third-party integrators reading until the
|
||||||
|
// server closes, `done` IS the end of the stream. Close the
|
||||||
|
// subscriber connections so a 5-second answer doesn't hold
|
||||||
|
// a downstream connection-pool slot for the full SseEmitter
|
||||||
|
// timeout (issue #586). The in-house web channel does NOT
|
||||||
|
// do this — it keeps emitters open for reconnect + replay.
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
streamTracker.complete(conversationId);
|
streamTracker.complete(conversationId);
|
||||||
})
|
})
|
||||||
.doOnError(e -> {
|
.doOnError(e -> {
|
||||||
log.error("[WebChat] Stream error: {}", e.getMessage());
|
log.error("[WebChat] Stream error: {}", e.getMessage());
|
||||||
streamTracker.broadcast(conversationId, "error",
|
streamTracker.broadcast(conversationId, "error",
|
||||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
streamTracker.complete(conversationId);
|
streamTracker.complete(conversationId);
|
||||||
})
|
})
|
||||||
.subscribe();
|
.subscribe();
|
||||||
@ -1187,7 +1210,7 @@ public class WebChatController {
|
|||||||
@RequestParam String visitorId,
|
@RequestParam String visitorId,
|
||||||
@RequestParam(required = false) String sessionId,
|
@RequestParam(required = false) String sessionId,
|
||||||
@RequestParam String pendingId) {
|
@RequestParam String pendingId) {
|
||||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
SseEmitter emitter = new Utf8SseEmitter(sseTimeoutMillis());
|
||||||
ChannelEntity channel = resolveChannel(apiKey);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
sendErrorAndComplete(emitter, "Invalid API Key");
|
sendErrorAndComplete(emitter, "Invalid API Key");
|
||||||
@ -1248,6 +1271,7 @@ public class WebChatController {
|
|||||||
broadcastApprovalResolved(conversationId, consumed);
|
broadcastApprovalResolved(conversationId, consumed);
|
||||||
streamTracker.broadcast(conversationId, "done",
|
streamTracker.broadcast(conversationId, "done",
|
||||||
"{\"status\":\"already_resolved\"}");
|
"{\"status\":\"already_resolved\"}");
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1263,6 +1287,7 @@ public class WebChatController {
|
|||||||
pendingId);
|
pendingId);
|
||||||
streamTracker.broadcast(conversationId, "done",
|
streamTracker.broadcast(conversationId, "done",
|
||||||
"{\"status\":\"error\",\"message\":\"No agent bound to approval\"}");
|
"{\"status\":\"error\",\"message\":\"No agent bound to approval\"}");
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1333,12 +1358,16 @@ public class WebChatController {
|
|||||||
}
|
}
|
||||||
streamTracker.broadcast(conversationId, "done",
|
streamTracker.broadcast(conversationId, "done",
|
||||||
"{\"status\":\"completed\"}");
|
"{\"status\":\"completed\"}");
|
||||||
|
// Close the WebChat SSE connection on the logical end of
|
||||||
|
// the replay stream — same rationale as /stream (issue #586).
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
streamTracker.complete(conversationId);
|
streamTracker.complete(conversationId);
|
||||||
})
|
})
|
||||||
.doOnError(e -> {
|
.doOnError(e -> {
|
||||||
log.error("[WebChat] approve replay stream error: {}", e.getMessage());
|
log.error("[WebChat] approve replay stream error: {}", e.getMessage());
|
||||||
streamTracker.broadcast(conversationId, "error",
|
streamTracker.broadcast(conversationId, "error",
|
||||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
streamTracker.complete(conversationId);
|
streamTracker.complete(conversationId);
|
||||||
})
|
})
|
||||||
.subscribe();
|
.subscribe();
|
||||||
@ -1349,6 +1378,7 @@ public class WebChatController {
|
|||||||
streamTracker.broadcast(conversationId, "error",
|
streamTracker.broadcast(conversationId, "error",
|
||||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
|
streamTracker.closeSubscribers(conversationId);
|
||||||
streamTracker.complete(conversationId);
|
streamTracker.complete(conversationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1404,7 +1434,7 @@ public class WebChatController {
|
|||||||
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
||||||
@RequestParam String visitorId,
|
@RequestParam String visitorId,
|
||||||
@RequestParam(required = false) String sessionId) {
|
@RequestParam(required = false) String sessionId) {
|
||||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
SseEmitter emitter = new Utf8SseEmitter(sseTimeoutMillis());
|
||||||
ChannelEntity channel = resolveChannel(apiKey);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
sendErrorAndComplete(emitter, "Invalid API Key");
|
sendErrorAndComplete(emitter, "Invalid API Key");
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
package vip.mate.channel.web;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins {@link ChatStreamTracker#closeSubscribers(String)} and its wiring into
|
||||||
|
* the eviction path — issue #586. WebChat's {@code done}/{@code error} is the
|
||||||
|
* logical end of the stream; subscriber SSE connections must actually close so
|
||||||
|
* backend integrators reading "until the server closes" are not held for the
|
||||||
|
* full 10-minute SseEmitter timeout. The eviction path must do the same so a
|
||||||
|
* forcibly-reclaimed run does not leave subscribers in silence.
|
||||||
|
*
|
||||||
|
* <p>Completion is observed by the post-complete send() throwing
|
||||||
|
* {@code IllegalStateException: ResponseBodyEmitter has already completed}
|
||||||
|
* (the servlet container's onCompletion callback does not fire in a unit test
|
||||||
|
* without an async request, so we assert on the emitter's own state instead).
|
||||||
|
*/
|
||||||
|
class ChatStreamTrackerCloseSubscribersTest {
|
||||||
|
|
||||||
|
private ChatStreamTracker newTracker() {
|
||||||
|
return new ChatStreamTracker(new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the emitter has been completed (a subsequent send() throws). */
|
||||||
|
private static boolean isCompleted(SseEmitter emitter) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().data("probe"));
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return e.getMessage() != null && e.getMessage().contains("already completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("closeSubscribers completes every attached emitter")
|
||||||
|
void closesAllSubscribers() {
|
||||||
|
ChatStreamTracker tracker = newTracker();
|
||||||
|
String cid = "close-all";
|
||||||
|
tracker.register(cid);
|
||||||
|
|
||||||
|
SseEmitter emA = new SseEmitter();
|
||||||
|
SseEmitter emB = new SseEmitter();
|
||||||
|
tracker.attach(cid, emA);
|
||||||
|
tracker.attach(cid, emB);
|
||||||
|
|
||||||
|
tracker.closeSubscribers(cid);
|
||||||
|
|
||||||
|
assertTrue(isCompleted(emA), "subscriber A must be completed");
|
||||||
|
assertTrue(isCompleted(emB), "subscriber B must be completed");
|
||||||
|
// subscribers list is cleared after close
|
||||||
|
assertEquals(0, tracker.getAllSnapshot().getFirst().subscriberCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("closeSubscribers is idempotent / safe when no run or no subscribers")
|
||||||
|
void closeSubscribersSafeWhenEmpty() {
|
||||||
|
ChatStreamTracker tracker = newTracker();
|
||||||
|
// No run at all — must not throw.
|
||||||
|
assertDoesNotThrow(() -> tracker.closeSubscribers("never-registered"));
|
||||||
|
|
||||||
|
tracker.register("no-subs");
|
||||||
|
tracker.closeSubscribers("no-subs"); // no subscribers — no-op, no throw
|
||||||
|
// run still alive (closeSubscribers does NOT mark done)
|
||||||
|
assertEquals(0, tracker.getAllSnapshot().getFirst().subscriberCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("closeSubscribers does not mark the run done (run stays until complete())")
|
||||||
|
void closeSubscribersDoesNotMarkDone() {
|
||||||
|
ChatStreamTracker tracker = newTracker();
|
||||||
|
String cid = "close-not-done";
|
||||||
|
tracker.register(cid);
|
||||||
|
tracker.incrementFlux(cid);
|
||||||
|
SseEmitter em = new SseEmitter();
|
||||||
|
tracker.attach(cid, em);
|
||||||
|
|
||||||
|
tracker.closeSubscribers(cid);
|
||||||
|
|
||||||
|
// The run is still registered — closeSubscribers only closes the SSE
|
||||||
|
// connections, it does not finalize the run lifecycle. That remains
|
||||||
|
// complete()'s job so the retention window for reconnect still applies.
|
||||||
|
assertEquals(1, tracker.getAllSnapshot().size());
|
||||||
|
assertTrue(tracker.streamExistsOnThisNode(cid));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Eviction closes subscriber emitters (not just disposes the Flux)")
|
||||||
|
void evictionClosesSubscribers() {
|
||||||
|
ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper());
|
||||||
|
tracker.setIdleTimeoutMinutesForTesting(5);
|
||||||
|
String cid = "evict-close";
|
||||||
|
tracker.register(cid);
|
||||||
|
|
||||||
|
SseEmitter em = new SseEmitter();
|
||||||
|
tracker.attach(cid, em);
|
||||||
|
|
||||||
|
// Backdate so the idle-eviction path fires.
|
||||||
|
tracker.backdateLastEventForTesting(cid, System.currentTimeMillis() - 6 * 60_000L);
|
||||||
|
tracker.cleanupStaleRuns();
|
||||||
|
|
||||||
|
assertTrue(isCompleted(em),
|
||||||
|
"eviction must close the subscriber emitter so the client is not " +
|
||||||
|
"left hanging in silence until its own timeout");
|
||||||
|
assertFalse(tracker.hasRunStateForTesting(cid));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("One already-dead subscriber does not block the rest from being closed")
|
||||||
|
void closeSubscribersResilientToDeadEmitter() {
|
||||||
|
ChatStreamTracker tracker = newTracker();
|
||||||
|
String cid = "resilient-close";
|
||||||
|
tracker.register(cid);
|
||||||
|
|
||||||
|
SseEmitter dead = new SseEmitter();
|
||||||
|
// Force the dead emitter into a completed state so the complete() call
|
||||||
|
// inside closeSubscribers() throws on it — proving the loop survives.
|
||||||
|
dead.complete();
|
||||||
|
SseEmitter live = new SseEmitter();
|
||||||
|
tracker.attach(cid, dead);
|
||||||
|
tracker.attach(cid, live);
|
||||||
|
|
||||||
|
tracker.closeSubscribers(cid);
|
||||||
|
|
||||||
|
assertTrue(isCompleted(live),
|
||||||
|
"the live subscriber must still be closed even though a dead " +
|
||||||
|
"subscriber threw on complete()");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user