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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user