Add chat-ui-htmx module: streaming chat page on Spring AI 2.0.2 with htmx and its SSE extension (vendored), MessageChatMemoryAdvisor keyed by the HTTP session, a Stop button, and the traps measured: EventSource strips one leading space per data line, model text must be HTML-escaped, a cancelled stream left the upstream connection open in 7 of 8 runs until an OkHttp interceptor calls Call.cancel (8 of 8 closed after), and the SDK push source ignores back-pressure (limitRate changes only the demand; a bounded buffer works). The model is a stub OpenAI-compatible SSE server written for the module; no real provider was run.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b6Kg9RWSTJzyz6KP9X
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
package com.ankurm.chatui;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.chatui.stub.OpenAiStubServer;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.CookieManager;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
/**
|
||||
* The whole chain on a random port: HTTP -> Spring MVC -> ChatClient + memory -> OpenAiChatModel ->
|
||||
* STUB server. Transcripts land in output/01-sse-transcript.txt, 02-memory.txt, 03-escaping.txt and
|
||||
* 04-cancel-with-fix.txt.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class ChatFlowTest {
|
||||
|
||||
@BeforeAll
|
||||
static void stub() {
|
||||
StubSupport.ensureStarted();
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void props(DynamicPropertyRegistry r) {
|
||||
r.add("spring.ai.openai.base-url", () -> "http://localhost:" + StubSupport.PORT + "/v1");
|
||||
}
|
||||
|
||||
@Value("${local.server.port}")
|
||||
int port;
|
||||
@Autowired
|
||||
ChatController controller;
|
||||
|
||||
/** One SSE event as a browser's EventSource would see it. */
|
||||
record Event(String name, String data) {
|
||||
}
|
||||
|
||||
HttpClient browser() {
|
||||
return HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
|
||||
}
|
||||
|
||||
/** Reads the raw stream and returns (raw lines, events parsed per the WHATWG rules). */
|
||||
List<String> raw = new ArrayList<>();
|
||||
|
||||
List<Event> stream(HttpClient client, String message) throws Exception {
|
||||
raw = new ArrayList<>();
|
||||
var req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/chat/stream?turn=1&message="
|
||||
+ URLEncoder.encode(message, StandardCharsets.UTF_8))).header("Accept", "text/event-stream").build();
|
||||
var res = client.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||
List<Event> events = new ArrayList<>();
|
||||
String name = "message";
|
||||
StringBuilder data = new StringBuilder();
|
||||
boolean has = false;
|
||||
try (var in = new BufferedReader(new InputStreamReader(res.body(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
raw.add(line);
|
||||
if (line.isEmpty()) {
|
||||
if (has) {
|
||||
events.add(new Event(name, data.toString()));
|
||||
}
|
||||
name = "message";
|
||||
data.setLength(0);
|
||||
has = false;
|
||||
} else if (line.startsWith("event:")) {
|
||||
name = strip(line.substring(6));
|
||||
} else if (line.startsWith("data:")) {
|
||||
if (has) {
|
||||
data.append('\n');
|
||||
}
|
||||
data.append(strip(line.substring(5)));
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** The SSE spec: remove ONE leading space from the field value. */
|
||||
static String strip(String v) {
|
||||
return v.startsWith(" ") ? v.substring(1) : v;
|
||||
}
|
||||
|
||||
static String text(List<Event> events) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
events.stream().filter(e -> e.name().equals("token")).forEach(e -> sb.append(e.data()));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sseFramesAndTheLeadingSpaceRule() throws Exception {
|
||||
var t = new Transcript("01-sse-transcript.txt");
|
||||
var events = stream(browser(), "hello world");
|
||||
t.line("$ GET /chat/stream?message=hello world (raw lines, first 14 and last 5)");
|
||||
raw.stream().limit(14).forEach(t::line);
|
||||
t.line("...");
|
||||
raw.subList(raw.size() - 5, raw.size()).forEach(l -> t.line(l.isEmpty() ? "<blank>" : l));
|
||||
String joined = text(events);
|
||||
t.line("event names seen: " + events.stream().map(Event::name).distinct().toList());
|
||||
t.line("joined token text as EventSource delivers it: " + joined);
|
||||
// What the browser would show if the server had NOT put a space in front of every data line:
|
||||
String naive = events.stream().filter(e -> e.name().equals("token")).map(e -> strip(e.data()))
|
||||
.reduce("", String::concat);
|
||||
t.line("same text if the server did not pad: " + naive);
|
||||
assertThat(joined).isEqualTo("[stub] saw 2 messages. Earlier user turns: none. You said: hello world");
|
||||
assertThat(naive).doesNotContain(" ");
|
||||
assertThat(events.getLast().name()).isEqualTo("done");
|
||||
}
|
||||
|
||||
@Test
|
||||
void memoryFollowsTheHttpSession() throws Exception {
|
||||
var t = new Transcript("02-memory.txt");
|
||||
var a = browser();
|
||||
t.line("session A, turn 1: " + text(stream(a, "My name is Asha and I like tea")));
|
||||
t.line("session A, turn 2: " + text(stream(a, "What did I just tell you?")));
|
||||
var b = browser();
|
||||
String other = text(stream(b, "What did I just tell you?"));
|
||||
t.line("session B, turn 1: " + other);
|
||||
String third = text(stream(a, "And now?"));
|
||||
t.line("session A, turn 3: " + third);
|
||||
assertThat(third).contains("saw 6 messages").contains("My name is Asha and I like tea | What did I just tell you?");
|
||||
assertThat(other).contains("saw 2 messages").contains("Earlier user turns: none");
|
||||
}
|
||||
|
||||
@Test
|
||||
void modelTextIsEscapedBeforeItReachesInnerHtml() throws Exception {
|
||||
var t = new Transcript("03-escaping.txt");
|
||||
var events = stream(browser(), "html please");
|
||||
String joined = text(events);
|
||||
t.line("data sent to the browser: " + joined);
|
||||
assertThat(joined).contains("<b>bold</b>").contains("<img src=x onerror=alert(1)>");
|
||||
assertThat(joined).doesNotContain("<b>").doesNotContain("<img");
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingTheConnectionCancelsTheFluxAndTheUpstreamStream() throws Exception {
|
||||
var t = new Transcript("04-cancel-with-fix.txt");
|
||||
OpenAiStubServer.setDelayMs(50);
|
||||
long[] before = OpenAiStubServer.counters();
|
||||
int cancelledBefore = controller.cancelled.get();
|
||||
try (var socket = new java.net.Socket("localhost", port)) {
|
||||
socket.getOutputStream().write(("GET /chat/stream?turn=9&message=big%20400 HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
||||
.getBytes(StandardCharsets.US_ASCII));
|
||||
socket.getInputStream().readNBytes(40_000);
|
||||
} // closing the socket is the "client abort"
|
||||
long t0 = System.currentTimeMillis();
|
||||
long[] after = before;
|
||||
for (int i = 0; i < 100 && after[3] == before[3]; i++) {
|
||||
Thread.sleep(50);
|
||||
after = OpenAiStubServer.counters();
|
||||
}
|
||||
long took = System.currentTimeMillis() - t0;
|
||||
OpenAiStubServer.setDelayMs(20);
|
||||
t.line("client read 40000 bytes then closed the socket");
|
||||
t.line("Flux cancelled on the server: " + (controller.cancelled.get() - cancelledBefore));
|
||||
t.line("stub saw the upstream connection close: " + (after[3] - before[3]) + " (within " + took + " ms)");
|
||||
t.line("stub chunks written for this request: " + (after[1] - before[1]) + " of 400 requested");
|
||||
assertThat(controller.cancelled.get() - cancelledBefore).isEqualTo(1);
|
||||
assertThat(after[3] - before[3]).isEqualTo(1);
|
||||
assertThat(after[1] - before[1]).isLessThan(400);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
org.springframework.ai.chat.client.ChatClient chatClient;
|
||||
|
||||
@Test
|
||||
void aMemoryAdvisorWithoutAConversationIdFails() {
|
||||
var t = new Transcript("11-missing-conversation-id.txt");
|
||||
try {
|
||||
chatClient.prompt().user("hi").stream().content().blockLast();
|
||||
t.line("no exception");
|
||||
} catch (RuntimeException e) {
|
||||
t.line("stream failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
assertThat(e.getMessage()).contains("conversationId cannot be null");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatMemoryKeepsWhenTheClientCancelsMidAnswer() throws Exception {
|
||||
var t = new Transcript("12-memory-after-cancel.txt");
|
||||
OpenAiStubServer.setDelayMs(30);
|
||||
var client = browser();
|
||||
// turn 1: ask for 300 tokens over a raw socket that shares the session cookie, read a little, hang up
|
||||
var warm = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/chat/stream?message=warm")).build();
|
||||
client.send(warm, HttpResponse.BodyHandlers.discarding()); // sets JSESSIONID in the client's cookie jar
|
||||
String cookie = client.cookieHandler().map(h -> {
|
||||
try {
|
||||
return h.get(URI.create("http://localhost:" + port + "/"), java.util.Map.of()).get("Cookie").getFirst();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}).orElseThrow();
|
||||
try (var socket = new java.net.Socket("localhost", port)) {
|
||||
socket.getOutputStream().write(("GET /chat/stream?message=count%20300 HTTP/1.1\r\nHost: localhost\r\nCookie: "
|
||||
+ cookie + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
socket.getInputStream().readNBytes(600);
|
||||
}
|
||||
Thread.sleep(1500);
|
||||
String next = text(stream(client, "What do you remember?"));
|
||||
OpenAiStubServer.setDelayMs(20);
|
||||
t.line("turn 1 'warm' completed, turn 2 'count 300' cancelled after a few tokens, then turn 3:");
|
||||
t.line(next);
|
||||
assertThat(next).contains("Earlier user turns: warm | count 300");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ankurm.chatui;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.chatui.stub.OpenAiStubServer;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* No Spring AI, no SDK: plain OkHttp 4.12 against the stub. A reader thread is blocked reading the
|
||||
* SSE body; another thread then closes the response, or cancels the call. Which one closes the socket?
|
||||
*/
|
||||
class OkHttpCloseReproTest {
|
||||
|
||||
@BeforeAll
|
||||
static void stub() {
|
||||
StubSupport.ensureStarted();
|
||||
}
|
||||
|
||||
int run(boolean cancelInsteadOfClose, Transcript t) throws Exception {
|
||||
var http = new OkHttpClient();
|
||||
int closed = 0;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
long[] before = OpenAiStubServer.counters();
|
||||
var req = new Request.Builder().url("http://localhost:" + StubSupport.PORT + "/v1/chat/completions")
|
||||
.post(RequestBody.create("{\"model\":\"m\",\"stream\":true,\"messages\":[{\"role\":\"user\","
|
||||
+ "\"content\":\"count 60\"}]}", MediaType.get("application/json"))).build();
|
||||
var call = http.newCall(req);
|
||||
var response = call.execute();
|
||||
var source = response.body().source();
|
||||
var reader = new Thread(() -> {
|
||||
try {
|
||||
while (source.readUtf8Line() != null) {
|
||||
// keep reading
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// closed under us
|
||||
}
|
||||
});
|
||||
reader.start();
|
||||
Thread.sleep(150);
|
||||
try {
|
||||
if (cancelInsteadOfClose) {
|
||||
call.cancel();
|
||||
} else {
|
||||
response.close();
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
if (i == 0) {
|
||||
t.line(" response.close() threw: " + e);
|
||||
}
|
||||
}
|
||||
Thread.sleep(500);
|
||||
if (OpenAiStubServer.counters()[3] - before[3] == 1) {
|
||||
closed++;
|
||||
}
|
||||
reader.join(2000);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeFromAnotherThreadLeaksCancelDoesNot() throws Exception {
|
||||
var t = new Transcript("05-okhttp-close-vs-cancel.txt");
|
||||
t.line("plain OkHttp 4.12.0, 10 runs each, stub streaming 60 chunks at 20 ms, reader thread blocked in a read");
|
||||
int viaClose = run(false, t);
|
||||
t.line("response.close() from another thread: upstream connection closed in " + viaClose + " of 10 runs");
|
||||
int viaCancel = run(true, t);
|
||||
t.line("call.cancel() from another thread: upstream connection closed in " + viaCancel + " of 10 runs");
|
||||
assertThat(viaCancel).isEqualTo(10);
|
||||
assertThat(viaClose).isLessThanOrEqualTo(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.chatui;
|
||||
|
||||
import com.ankurm.chatui.stub.OpenAiStubServer;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Starts the in-process stub once per JVM on port 38193. */
|
||||
final class StubSupport {
|
||||
static final int PORT = 38193;
|
||||
private static boolean started;
|
||||
|
||||
static synchronized void ensureStarted() {
|
||||
if (!started) {
|
||||
try {
|
||||
OpenAiStubServer.start(PORT, 20);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
private StubSupport() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.chatui;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
/** Appends lines to output/NN-name.txt (the file is truncated by the first call of a JVM run). */
|
||||
final class Transcript {
|
||||
private final Path file;
|
||||
|
||||
Transcript(String name) {
|
||||
this.file = Path.of("output", name);
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, "");
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
void line(String s) {
|
||||
System.out.println(s);
|
||||
try {
|
||||
Files.writeString(file, s + "\n", StandardOpenOption.APPEND);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user