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:
2026-10-31 04:02:56 +00:00
co-authored by Claude Sonnet 5.5
parent c97bcf9adf
commit 68da642f9a
35 changed files with 1571 additions and 0 deletions
@@ -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("&lt;b&gt;bold&lt;/b&gt;").contains("&lt;img src=x onerror=alert(1)&gt;");
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);
}
}
}