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
+24
View File
@@ -0,0 +1,24 @@
$ GET /chat/stream?message=hello world (raw lines, first 14 and last 5)
event:token
data: [stub]
event:token
data: saw
event:token
data: 2
event:token
data: messages.
event:token
data: Earlier
...
data: world
<blank>
event:done
data:
<blank>
event names seen: [token, done]
joined token text as EventSource delivers it: [stub] saw 2 messages. Earlier user turns: none. You said: hello world
same text if the server did not pad: [stub]saw2messages.Earlieruserturns:none.Yousaid:helloworld
+4
View File
@@ -0,0 +1,4 @@
session A, turn 1: [stub] saw 2 messages. Earlier user turns: none. You said: My name is Asha and I like tea
session A, turn 2: [stub] saw 4 messages. Earlier user turns: My name is Asha and I like tea. You said: What did I just tell you?
session B, turn 1: [stub] saw 2 messages. Earlier user turns: none. You said: What did I just tell you?
session A, turn 3: [stub] saw 6 messages. Earlier user turns: My name is Asha and I like tea | What did I just tell you?. You said: And now?
+1
View File
@@ -0,0 +1 @@
data sent to the browser: Here is &lt;b&gt;bold&lt;/b&gt; and &lt;img src=x onerror=alert(1)&gt; &amp; done
@@ -0,0 +1,4 @@
client read 40000 bytes then closed the socket
Flux cancelled on the server: 1
stub saw the upstream connection close: 1 (within 50 ms)
stub chunks written for this request: 5 of 400 requested
@@ -0,0 +1,4 @@
plain OkHttp 4.12.0, 10 runs each, stub streaming 60 chunks at 20 ms, reader thread blocked in a read
response.close() threw: java.lang.IllegalStateException: Unbalanced enter/exit
response.close() from another thread: upstream connection closed in 0 of 10 runs
call.cancel() from another thread: upstream connection closed in 10 of 10 runs
+18
View File
@@ -0,0 +1,18 @@
== turn 1 (session A)
A1: '[stub] saw 2 messages. Earlier user turns: none. You said: My name is Asha and I like tea'
== turn 2 (session A, same cookie)
A2: '[stub] saw 4 messages. Earlier user turns: My name is Asha and I like tea. You said: What did I just tell you?'
cookies: ['JSESSIONID']
== turn 1 (session B, fresh browser context, no cookie)
B1: '[stub] saw 2 messages. Earlier user turns: none. You said: What did I just tell you?'
== New conversation button in session A, then ask again
A3: '[stub] saw 2 messages. Earlier user turns: none. You said: Do you remember me?'
== model output containing markup
rendered text: 'Here is <b>bold</b> and <img src=x onerror=alert(1)> & done'
<b> elements created inside the answer: 0
<img> elements created inside the answer: 0
alert dialogs fired: 0
== stop button
text at click : 'w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11'
text 1.5 s on : 'w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12'
grew after stop: False
+6
View File
@@ -0,0 +1,6 @@
-- app log (the turn started by the Stop-button test)
turn 6 start conversation=def8b65c-b58a-4677-b149-e71575426ebd message=count 200
turn 6 CANCELLED by client after 14 tokens
-- stub log
stub: request #6 model=stub-gpt stream=true messages=6 roles=system,user,assistant,user,assistant,user latest="count 200"
stub: stream #6 client closed the connection after 15 chunks
@@ -0,0 +1,21 @@
== chunk delay 20 ms, 8 runs, app args: --chat.cancel-fix=false
run 1: upstream closed
run 2: upstream LEFT OPEN
run 3: upstream LEFT OPEN
run 4: upstream LEFT OPEN
run 5: upstream LEFT OPEN
run 6: upstream LEFT OPEN
run 7: upstream LEFT OPEN
run 8: upstream LEFT OPEN
upstream closed: 1 of 8, left open: 7 of 8
== chunk delay 20 ms, 8 runs, app args: (none)
run 1: upstream closed
run 2: upstream closed
run 3: upstream closed
run 4: upstream closed
run 5: upstream closed
run 6: upstream closed
run 7: upstream closed
run 8: upstream closed
upstream closed: 8 of 8, left open: 0 of 8
+45
View File
@@ -0,0 +1,45 @@
== chat.backpressure=none (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 34669K
t= 2.8s client got 197 KiB stub: chunksWritten=452 finished=0 abortedByClient=0 app heap after GC: used 134096K
t= 5.5s client got 896 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 86306K
t= 8.9s client got 1832 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 109343K
t=13.3s client got 2967 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 116565K
-- app log (demand + outcome):
213 web-side demand request(1)
213 model-side demand request(1)
1 CANCELLED by client after 213 tokens
== chat.backpressure=limitRate (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 42630K
t= 2.8s client got 242 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 86598K
t= 5.2s client got 896 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 86745K
t= 8.5s client got 1768 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 109571K
t=12.9s client got 2967 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 116491K
-- app log (demand + outcome):
213 web-side demand request(1)
35 model-side demand request(6)
1 model-side demand request(8)
1 CANCELLED by client after 213 tokens
== chat.backpressure=slow (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 40942K
t= 2.6s client got 72 KiB stub: chunksWritten=218 finished=0 abortedByClient=0 app heap after GC: used 146705K
t= 5.3s client got 787 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 111009K
t= 8.6s client got 1659 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 109188K
t=13.0s client got 2858 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 119593K
-- app log (demand + outcome):
221 model-side demand request(1)
220 web-side demand request(1)
1 CANCELLED by client after 220 tokens
== chat.backpressure=bounded (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 61061K
t= 2.7s client got 133 KiB stub: chunksWritten=256 finished=0 abortedByClient=0 app heap after GC: used 58420K
t= 5.1s client got 787 KiB stub: chunksWritten=256 finished=0 abortedByClient=1 app heap after GC: used 58420K
t= 8.5s client got 1659 KiB stub: chunksWritten=256 finished=0 abortedByClient=1 app heap after GC: used 30251K
t=12.8s client got 2858 KiB stub: chunksWritten=256 finished=0 abortedByClient=1 app heap after GC: used 30381K
-- app log (demand + outcome):
144 web-side demand request(1)
1 model-side demand request(unbounded)
1 error reactor.core.Exceptions$OverflowException: The receiver is overrun by more signals than expected (bounded queue...)
+7
View File
@@ -0,0 +1,7 @@
$ curl -sN "localhost:38180/chat/stream?message=count 700" (700 tokens x 50 ms = 35 s)
<blank>
event:done
data:
<blank>
elapsed: 38 s
turn 1 complete, 700 tokens
@@ -0,0 +1 @@
stream failed: IllegalArgumentException: conversationId cannot be null
@@ -0,0 +1,2 @@
turn 1 'warm' completed, turn 2 'count 300' cancelled after a few tokens, then turn 3:
[stub] saw 5 messages. Earlier user turns: warm | count 300. You said: What do you remember?