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:
Executable
+25
@@ -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
|
||||
Executable
+67
@@ -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()
|
||||
Executable
+18
@@ -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
|
||||
Executable
+25
@@ -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; }
|
||||
Executable
+25
@@ -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"
|
||||
Executable
+22
@@ -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()
|
||||
Reference in New Issue
Block a user