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
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# A slow client against a fast model: curl reads 300 KB/s of a 600 x 32 KB stream (19.6 MB) while the
# stub writes with no delay. Samples how far the stub got and how much heap the app holds.
# Usage: backpressure.sh <none|limitRate|slow> [extra app args]
source "$(dirname "$0")/lib.sh"; set +e
MODE=${1:-none}; shift
stop_all; start_stub 0; start_app --chat.backpressure="$MODE" --chat.slow-millis=5 "$@"
echo "== chat.backpressure=$MODE (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)"
echo 0 > "$RUN/recv.bytes"
python3 scripts/slow_client.py "http://localhost:$APP_PORT/chat/stream?message=big%20600&turn=1" "$RUN/recv.bytes" &
CURL=$!
T0=$(date +%s.%N)
for pause in 1 1 2 3 4; do
sleep "$pause"
st=$(curl -s "localhost:$STUB_PORT/stub/stats" | sed 's/requests=[0-9]* //')
recv=$(cat "$RUN/recv.bytes")
now=$(date +%s.%N)
jcmd "$(cat $RUN/app.pid)" GC.run >/dev/null 2>&1
heap=$(jcmd "$(cat $RUN/app.pid)" GC.heap_info 2>/dev/null | grep -o "used [0-9]*K" | head -1)
printf "t=%4.1fs client got %5d KiB stub: %s app heap after GC: %s\n" "$(echo "$now - $T0" | bc)" $((recv/1024)) "$st" "$heap"
done
wait $CURL 2>/dev/null
echo "-- app log (demand + outcome):"
grep "demand\|CANCELLED\|complete\|error" $RUN/app.log | sed "s/turn 1 //" | sort | uniq -c | sort -rn | head -6
stop_all
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Drives the real page in headless Chromium (Playwright for Python) against the running app + stub.
Prints a transcript to stdout. Needs: pip install playwright && playwright install chromium."""
import sys, time, urllib.request
from playwright.sync_api import sync_playwright
APP = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:38180"
STUB = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:38181"
def stub(path):
return urllib.request.urlopen(STUB + path, data=b"" if "config" in path else None).read().decode().strip()
def ask(page, text):
n = page.locator(".msg.bot").count()
page.fill("input[name=message]", text)
page.click("form button")
page.wait_for_selector(f".msg.bot >> nth={n}")
return int(page.locator(".msg.bot span[id^=out-]").last.get_attribute("id")[4:])
def wait_done(page, n, timeout=20000):
page.wait_for_function(f"!document.querySelector('#stop-{n}')", timeout=timeout)
def bot_text(page, n):
return page.locator(f"#out-{n}").inner_text()
with sync_playwright() as p:
b = p.chromium.launch()
ctx = b.new_context()
page = ctx.new_page()
dialogs = []
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
page.goto(APP + "/")
print("== turn 1 (session A)")
n = ask(page, "My name is Asha and I like tea"); wait_done(page, n)
print("A1:", repr(bot_text(page, n)))
print("== turn 2 (session A, same cookie)")
n = ask(page, "What did I just tell you?"); wait_done(page, n)
print("A2:", repr(bot_text(page, n)))
print("cookies:", [c["name"] for c in ctx.cookies()])
print("== turn 1 (session B, fresh browser context, no cookie)")
ctx2 = b.new_context(); page2 = ctx2.new_page(); page2.goto(APP + "/")
n = ask(page2, "What did I just tell you?"); wait_done(page2, n)
print("B1:", repr(bot_text(page2, n)))
print("== New conversation button in session A, then ask again")
page.click("text=New conversation"); time.sleep(0.5)
n = ask(page, "Do you remember me?"); wait_done(page, n)
print("A3:", repr(bot_text(page, n)))
print("== model output containing markup")
n = ask(page, "html please"); wait_done(page, n)
print("rendered text:", repr(bot_text(page, n)))
print("<b> elements created inside the answer:", page.locator(f"#out-{n} b").count())
print("<img> elements created inside the answer:", page.locator(f"#out-{n} img").count())
print("alert dialogs fired:", len(dialogs))
print("== stop button")
stub("/stub/config?delayMs=50")
n = ask(page, "count 200")
page.wait_for_function(f"document.querySelector('#out-{n}').innerText.length > 30")
before = bot_text(page, n)
t0 = time.time(); page.click(f"#stop-{n}")
page.wait_for_function(f"!document.querySelector('#stop-{n}')")
time.sleep(1.5)
after = bot_text(page, n)
print("text at click :", repr(before))
print("text 1.5 s on :", repr(after))
print("grew after stop:", len(after.replace(' [stopped]','')) > len(before.replace(' [stopped]','')) + 6)
stub("/stub/config?delayMs=20")
b.close()
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Aborts a streaming request from the client side N times and asks the stub what happened to the
# upstream HTTP stream: closed (abortedByClient) or left open. Usage: cancel-upstream.sh <chunk-delay-ms> <runs> [app args]
source "$(dirname "$0")/lib.sh"; set +e
DELAY=${1:-20}; RUNS=${2:-10}; shift 2
stop_all; start_stub "$DELAY"; start_app "$@"
echo "== chunk delay ${DELAY} ms, ${RUNS} runs, app args: ${*:-(none)}"
closed=0; open=0
for r in $(seq 1 "$RUNS"); do
b=$(curl -s "localhost:$STUB_PORT/stub/stats" | sed 's/.*abortedByClient=//')
curl -sN --max-time 1 "localhost:$APP_PORT/chat/stream?message=big%20400&turn=$r" >/dev/null
sleep 2
a=$(curl -s "localhost:$STUB_PORT/stub/stats" | sed 's/.*abortedByClient=//')
if [ "$a" -gt "$b" ]; then closed=$((closed+1)); res=closed; else open=$((open+1)); res="LEFT OPEN"; fi
echo "run $r: upstream $res"
done
echo "upstream closed: $closed of $RUNS, left open: $open of $RUNS"
stop_all
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Shared helpers: JDK 25, ports, start/stop of the stub and the app. Stop is by recorded PID only.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
export JAVA_HOME="${CHATUI_JAVA_HOME:-/tmp/tools/jdk-25.0.5+8}"
export PATH="$JAVA_HOME/bin:$PATH"
APP_PORT=${APP_PORT:-38180}
STUB_PORT=${STUB_PORT:-38181}
RUN=target/run; mkdir -p "$RUN" output
build() { [ -f target/cp.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt >/dev/null
mvn -B -q -DskipTests package >/dev/null; }
start_stub() { # $1 = delay ms
setsid nohup sh -c 'echo $$ > "$1"; shift; exec java "$@"' sh "$RUN/stub.pid" -cp "target/classes:$(cat target/cp.txt)" \
com.ankurm.chatui.stub.OpenAiStubServer "$STUB_PORT" "${1:-20}" > "$RUN/stub.log" 2>&1 < /dev/null &
for i in $(seq 1 30); do curl -s -o /dev/null "localhost:$STUB_PORT/stub/stats" && return; sleep 0.5; done; }
start_app() { # extra args are passed to the app (e.g. --chat.backpressure=limitRate)
setsid nohup sh -c 'echo $$ > "$1"; shift; exec java "$@"' sh "$RUN/app.pid" -jar target/chat-ui-htmx-1.0.0.jar \
--server.port="$APP_PORT" "$@" > "$RUN/app.log" 2>&1 < /dev/null &
for i in $(seq 1 60); do curl -s -o /dev/null "localhost:$APP_PORT/" && return; sleep 1; done; }
stop_pid() { [ -f "$RUN/$1.pid" ] && kill "$(cat "$RUN/$1.pid")" 2>/dev/null || true; rm -f "$RUN/$1.pid"; sleep 1; }
stop_all() { stop_pid app; stop_pid stub; }
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Regenerates every file in output/. Needs JDK 25, Maven, curl, python3 and (for step 2) Playwright for
# Python with Chromium. Timing-dependent figures (back-pressure samples, cancel counts) differ run to run.
source "$(dirname "$0")/lib.sh"; set +e
echo "[1/6] unit + integration tests (write output/01..05)"
mvn -B -q test 2>&1 | grep "Tests run:\|FAIL\|ERROR" | tail -3
build
echo "[2/6] real browser (headless Chromium): memory, escaping, Stop button -> output/06-browser.txt, 07-stop-logs.txt"
stop_all; start_stub 20; start_app
python3 scripts/browser-test.py "http://localhost:$APP_PORT" "http://localhost:$STUB_PORT" > output/06-browser.txt 2>&1
sleep 12 # let the stub finish or abort the 200-token stream
{ echo "-- app log (the turn started by the Stop-button test)"; grep -E "CANCELLED|count 200" "$RUN/app.log"
echo "-- stub log"; grep -E "count 200|stream #.* (finished|client closed)" "$RUN/stub.log" | tail -2; } > output/07-stop-logs.txt
stop_all
echo "[3/6] client abort, upstream closed or left open? 8 runs each -> output/08-cancel-upstream.txt"
{ scripts/cancel-upstream.sh 20 8 --chat.cancel-fix=false; echo; scripts/cancel-upstream.sh 20 8; } 2>&1 | grep -v "^Picked\|Done" > output/08-cancel-upstream.txt
echo "[4/6] slow client, four ways to shape the Flux -> output/09-backpressure.txt"
for m in none limitRate slow bounded; do scripts/backpressure.sh "$m"; echo; done 2>&1 | grep -v "^Picked\|Done" > output/09-backpressure.txt
echo "[5/6] a 35 s answer with no async timeout configured -> output/10-long-stream.txt"
stop_all; start_stub 50; start_app
{ echo '$ curl -sN "localhost:'$APP_PORT'/chat/stream?message=count 700" (700 tokens x 50 ms = 35 s)'
s=$(date +%s); curl -sN "localhost:$APP_PORT/chat/stream?message=count%20700&turn=1" | tail -n 4 | sed 's/^$/<blank>/'
echo "elapsed: $(( $(date +%s) - s )) s"; grep -E "complete|error" "$RUN/app.log"; } > output/10-long-stream.txt 2>&1
stop_all
echo "[6/6] done"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""A deliberately slow SSE reader on a raw socket: recv() up to CHUNK bytes every INTERVAL seconds
(default 64 KiB / 0.2 s, about 320 KB/s at most) and keep the running byte count in a file so another
process can sample it. Usage: slow_client.py URL COUNTER_FILE [CHUNK] [INTERVAL] [SECONDS]"""
import socket, sys, time, urllib.parse
url, counter = sys.argv[1], sys.argv[2]
chunk = int(sys.argv[3]) if len(sys.argv) > 3 else 64 * 1024
interval = float(sys.argv[4]) if len(sys.argv) > 4 else 0.2
limit = float(sys.argv[5]) if len(sys.argv) > 5 else 14.0
u = urllib.parse.urlparse(url)
s = socket.create_connection((u.hostname, u.port))
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 * 1024)
s.sendall(f"GET {u.path}?{u.query} HTTP/1.1\r\nHost: {u.hostname}\r\nAccept: text/event-stream\r\n\r\n".encode())
total, t0 = 0, time.time()
while time.time() - t0 < limit:
data = s.recv(chunk)
if not data:
break
total += len(data)
open(counter, "w").write(str(total))
time.sleep(interval)
s.close()