]> mop.ddnsfree.com - git repositories - brisk.git/commitdiff
test/load: the test bench used for the checks
authorMatteo Nastasi <nastasi@alternativeoutput.it>
Sun, 13 Sep 2026 10:41:31 +0000 (12:41 +0200)
committerMatteo Nastasi <nastasi@alternativeoutput.it>
Sun, 13 Sep 2026 10:41:31 +0000 (12:41 +0200)
The tools the port was verified with: load generator, resource sampler, driver
for a five player game, and the probes for the corner cases of the transport
(duplicate session, expired session, full queue, socket backlog). The README
says what is needed first - the test users in the database, with the note
about the guar_code constraint that makes the obvious insert fail - and what
each tool is for.

They are not part of the application: they are laboratory stuff, and the
README says so.

The generator is a single asyncio process because it has to be cheap: it runs
on the same machine as the daemon, and a heavy generator would distort the
very measurement it is taking.

Every script that starts background loops carries a trap that kills its own
process group, and there is a stop.sh to run at the end of a session. This is
not pedantry: without it the loops outlive their parent, and in this session
eighty processes stayed alive for two days, with the machine load at 19 and
the disk full of capture files. stop.sh also avoids "pkill -f", which kills
the shell that runs it when the pattern shows up in its own command line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014M1jiEq9cHdE5SE5j6vFuE

15 files changed:
test/load/README [new file with mode: 0644]
test/load/brisk_load.py [new file with mode: 0755]
test/load/brisk_sample.sh [new file with mode: 0755]
test/load/brisk_step.sh [new file with mode: 0755]
test/load/burst.py [new file with mode: 0755]
test/load/game.sh [new file with mode: 0755]
test/load/ghostswap.py [new file with mode: 0755]
test/load/hand.sh [new file with mode: 0755]
test/load/play_human.sh [new file with mode: 0755]
test/load/ramp_probe.sh [new file with mode: 0755]
test/load/sit4.sh [new file with mode: 0755]
test/load/slowprobe.py [new file with mode: 0755]
test/load/stop.sh [new file with mode: 0755]
test/load/takeover.py [new file with mode: 0755]
test/load/wsprobe.py [new file with mode: 0755]

diff --git a/test/load/README b/test/load/README
new file mode 100644 (file)
index 0000000..5072bed
--- /dev/null
@@ -0,0 +1,101 @@
+Load test bench
+===============
+
+Tools used to check the port to php 8.4 on debian 13. They exercise the daemon
+with many players connected and reproduce the corner cases of the transport
+(duplicate sessions, expired sessions, full queues).
+
+They are not part of the application: they are laboratory tools, meant to run
+on a test machine, never in production.
+
+
+What is needed first
+--------------------
+
+Test users in the database. The authenticated scripts use names of the form
+loadNNN with the password equal to the name:
+
+  insert into bsk_users (code, login, pass, email, type, tos_vers, guar_code)
+  select 20000+i, 'load'||to_char(i,'FM000'), md5('load'||to_char(i,'FM000')),
+         'load'||to_char(i,'FM000')||'@example.invalid', 65536, '1.4', <guarantor>
+  from generate_series(1,400) i;
+
+where <guarantor> is the code of a user that already exists: the guar_code
+column has a foreign key constraint and the default value (-1) violates it.
+
+tos_vers has to be set to the current version, otherwise every login opens the
+dialog to accept the terms of service and pollutes the measurements.
+
+game.sh and hand.sh instead use the five users uno/due/tre/qua/cin with the
+passwords one/two/thr/for/fiv.
+
+The paths and ports are those of the test container (/home/brisk, 8444 for
+https): they have to be adapted elsewhere.
+
+
+The load
+--------
+
+brisk_load.py    generator: N players that come in, keep the comet stream open
+                 and write in the chat. A single process, asyncio, because the
+                 generator has to be cheap: it runs on the same machine as the
+                 daemon. It measures the write latency (p50/p95/p99), the
+                 messages broadcast and how well the streams hold.
+                   --clients N --base N --auth --silent --period S --port P
+
+brisk_step.sh    one complete step: restarts the daemon so as to start from an
+                 empty room, launches the generator and samples the resources.
+
+brisk_sample.sh  sampler: cpu and memory of the daemon and of the frontend,
+                 open descriptors, connections towards the sockets of the pool.
+
+ramp_probe.sh    measures what a client receives while N others come into the
+                 room: it weighs the cost of the protocol (every entry sends
+                 everybody the complete list of the people present).
+
+
+A real game
+-----------
+
+game.sh [table] [port]   five players come in, sit down, the table forms; they
+                         stay connected.
+hand.sh [table] [port]   plays the hand: auction, call and forty cards.
+
+sit4.sh                  four automatic players waiting for a fifth, human one
+play_human.sh            drives the four while the fifth plays from the browser
+
+
+The corner cases of the transport
+---------------------------------
+
+wsprobe.py      opens a real websocket and counts the frames: it tells a server
+                defect apart from the state of one browser tab.
+takeover.py     two streams on the same session: the first one must be
+                dismissed, not closed in silence.
+ghostswap.py    second access by the same user from another session: the first
+                one must go back to the login, where ghost_sess explains why.
+slowprobe.py    slow client: it connects and stops reading while a burst
+                arrives, to see whether the message queue overflows.
+burst.py        burst of simultaneous connections on the unix sockets: it
+                measures how many the backlog queues before refusing.
+
+
+At the end of a session
+-----------------------
+
+  ./stop.sh
+
+It shuts everything down and prints the state: running scripts, running curls,
+descriptors of the daemon, disk space, load. To be run ALWAYS.
+
+The scripts that start background loops carry a trap that kills their own
+process group. Without it the loops outlive their parent and are left spinning
+for nothing: that happened for two days, with eighty processes alive and the
+machine load at 19.
+
+stop.sh does not use "pkill -f": the pattern would end up in the command line
+of the script itself, which would kill itself and leave the targets alive.
+
+The working files (auth.txt, tok.txt, mani.txt, *.stream) are created in the
+working directory of the scripts and can grow large: the read streams grow all
+the time. stop.sh deletes them.
diff --git a/test/load/brisk_load.py b/test/load/brisk_load.py
new file mode 100755 (executable)
index 0000000..6a73bc4
--- /dev/null
@@ -0,0 +1,261 @@
+#!/usr/bin/env python3
+"""Load generator for brisk.
+
+Simulates N players in the room: each one comes in as a guest, keeps the comet
+stream of index_rd.php open and now and then writes in the chat with
+index_wr.php.
+
+It measures the latency of the writes (which is what the player feels) and how
+well the read streams hold. A single process, asyncio: the generator has to be
+cheap, because it runs on the same machine as the daemon.
+"""
+
+import argparse
+import asyncio
+import hashlib
+import random
+import re
+import ssl
+import statistics
+import sys
+import time
+
+SESS_RE = re.compile(rb'sess = "([0-9a-f]+)"')
+
+
+class Stats:
+    def __init__(self):
+        self.write_lat = []      # write latencies, in ms
+        self.write_err = 0
+        self.login_err = 0
+        self.stream_bytes = 0
+        self.stream_deliv = 0    # chat messages delivered to the readers
+        self.stream_reconn = 0
+        self.stream_err = 0
+        self.http_err = {}       # status code -> count
+
+    def http(self, code):
+        self.http_err[code] = self.http_err.get(code, 0) + 1
+
+
+class Client:
+    def __init__(self, idx, host, port, use_tls, prefix, stats, auth=False):
+        self.idx = idx
+        if auth:
+            self.name = "load%03d" % idx
+            self.passwd = self.name
+        else:
+            self.name = "Ospite%04d" % idx
+            self.passwd = None
+        self.host = host
+        self.port = port
+        self.use_tls = use_tls
+        self.prefix = prefix
+        self.st = stats
+        self.sess = None
+        self.step = 0
+
+    async def _open(self):
+        ctx = None
+        if self.use_tls:
+            ctx = ssl.create_default_context()
+            ctx.check_hostname = False
+            ctx.verify_mode = ssl.CERT_NONE
+        return await asyncio.open_connection(self.host, self.port, ssl=ctx)
+
+    async def _fetch(self, path, timeout=30):
+        """A request with Connection: close: the response ends with EOF, so the
+        latency is the time up to the last byte."""
+        r, w = await self._open()
+        try:
+            req = ("GET %s HTTP/1.1\r\nHost: %s\r\n"
+                   "Connection: close\r\nUser-Agent: brisk-load\r\n" % (path, self.host))
+            if self.sess:
+                req += "Cookie: sess=%s\r\n" % self.sess
+            req += "\r\n"
+            w.write(req.encode())
+            await w.drain()
+            data = await asyncio.wait_for(r.read(), timeout)
+            return data
+        finally:
+            w.close()
+            try:
+                await w.wait_closed()
+            except Exception:
+                pass
+
+    async def login(self):
+        path = "%sindex.php?name=%s" % (self.prefix, self.name)
+        if self.passwd is not None:
+            # challenge login: the token has to be combined with the md5 of
+            # the password, exactly as j_login_manager() does in the real client
+            try:
+                chal = await self._fetch("%sindex_wr.php?mesg=getchallenge&cli_name=%s"
+                                         % (self.prefix, self.name))
+                tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+            except Exception:
+                self.st.login_err += 1
+                return False
+            mp = hashlib.md5(self.passwd.encode()).hexdigest()
+            priv = hashlib.md5((tok + mp).encode()).hexdigest()
+            path += "&pass_private=" + priv
+        try:
+            body = await self._fetch(path)
+        except Exception:
+            self.st.login_err += 1
+            return False
+        m = SESS_RE.search(body)
+        if not m:
+            self.st.login_err += 1
+            code = body.split(b"\r\n", 1)[0].split(b" ")[1:2]
+            if code:
+                self.st.http(code[0].decode(errors="replace"))
+            return False
+        self.sess = m.group(1).decode()
+        return True
+
+    async def reader(self, stop):
+        """The comet stream: it reopens when the server closes it, as the real
+        client does."""
+        while not stop.is_set():
+            try:
+                r, w = await self._open()
+            except Exception:
+                self.st.stream_err += 1
+                await asyncio.sleep(1)
+                continue
+            try:
+                req = ("GET %sindex_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr"
+                       " HTTP/1.1\r\nHost: %s\r\nCookie: sess=%s\r\n"
+                       "User-Agent: brisk-load\r\n\r\n" % (self.prefix, self.host, self.sess))
+                w.write(req.encode())
+                await w.drain()
+                while not stop.is_set():
+                    chunk = await asyncio.wait_for(r.read(65536), 120)
+                    if not chunk:
+                        break
+                    self.st.stream_bytes += len(chunk)
+                    self.st.stream_deliv += chunk.count(b"chatt_sub(")
+            except asyncio.TimeoutError:
+                self.st.stream_err += 1
+            except Exception:
+                self.st.stream_err += 1
+            finally:
+                w.close()
+                try:
+                    await w.wait_closed()
+                except Exception:
+                    pass
+            if not stop.is_set():
+                self.st.stream_reconn += 1
+                await asyncio.sleep(0.3)
+
+    async def writer(self, stop, period):
+        await asyncio.sleep(random.uniform(0, period))
+        while not stop.is_set():
+            mesg = "chatt%%7Ccarico%d" % random.randint(0, 99999)
+            path = "%sindex_wr.php?sess=%s&stp=%d&mesg=%s" % (
+                self.prefix, self.sess, self.step, mesg)
+            self.step += 1
+            t0 = time.monotonic()
+            try:
+                body = await self._fetch(path, timeout=30)
+                dt = (time.monotonic() - t0) * 1000.0
+                head = body.split(b"\r\n", 1)[0]
+                if b" 200" in head:
+                    self.st.write_lat.append(dt)
+                else:
+                    self.st.write_err += 1
+                    self.st.http(head.decode(errors="replace")[:32])
+            except Exception:
+                self.st.write_err += 1
+            await asyncio.sleep(period * random.uniform(0.8, 1.2))
+
+
+async def run(args):
+    st = Stats()
+    stop = asyncio.Event()
+    clients = [Client(args.base + i, args.host, args.port, args.tls, args.prefix, st,
+                      auth=args.auth)
+               for i in range(args.clients)]
+
+    # staggered entry: a wave of simultaneous logins would measure a transient,
+    # not the steady state
+    t0 = time.monotonic()
+    ok = 0
+    for i in range(0, len(clients), args.batch):
+        group = clients[i:i + args.batch]
+        res = await asyncio.gather(*[c.login() for c in group])
+        ok += sum(1 for r in res if r)
+        await asyncio.sleep(args.ramp)
+    print("  logins succeeded: %d/%d in %.1fs" % (ok, len(clients), time.monotonic() - t0),
+          flush=True)
+    if ok == 0:
+        return st, 0
+
+    live = [c for c in clients if c.sess]
+    tasks = []
+    for c in live:
+        tasks.append(asyncio.create_task(c.reader(stop)))
+        if not args.silent:
+            tasks.append(asyncio.create_task(c.writer(stop, args.period)))
+
+    await asyncio.sleep(args.warmup)
+    st.write_lat.clear()          # the transient does not count
+    st.write_err = 0
+    base_bytes = st.stream_bytes
+    base_deliv = st.stream_deliv
+    t1 = time.monotonic()
+
+    await asyncio.sleep(args.duration)
+    dur = time.monotonic() - t1
+    st.stream_bytes -= base_bytes
+    st.stream_deliv -= base_deliv
+
+    stop.set()
+    for t in tasks:
+        t.cancel()
+    await asyncio.gather(*tasks, return_exceptions=True)
+    return st, dur
+
+
+def main():
+    p = argparse.ArgumentParser()
+    p.add_argument("--host", default="127.0.0.1")
+    p.add_argument("--port", type=int, default=8082)
+    p.add_argument("--tls", action="store_true")
+    p.add_argument("--prefix", default="/brisk/")
+    p.add_argument("--clients", type=int, default=25)
+    p.add_argument("--base", type=int, default=1)
+    p.add_argument("--batch", type=int, default=10)
+    p.add_argument("--ramp", type=float, default=0.5)
+    p.add_argument("--warmup", type=float, default=20)
+    p.add_argument("--duration", type=float, default=60)
+    p.add_argument("--period", type=float, default=10)
+    p.add_argument("--auth", action="store_true", help="registered users (load001...)")
+    p.add_argument("--silent", action="store_true", help="streams only, no writes")
+    args = p.parse_args()
+
+    st, dur = asyncio.run(run(args))
+    lat = sorted(st.write_lat)
+    def pct(q):
+        if not lat:
+            return float("nan")
+        return lat[min(len(lat) - 1, int(len(lat) * q))]
+
+    print("  writes: %d in %.0fs (%.1f/s)" % (len(lat), dur, len(lat) / dur if dur else 0))
+    if lat:
+        print("  latency ms: p50 %.1f  p95 %.1f  p99 %.1f  max %.1f  mean %.1f" % (
+            pct(.50), pct(.95), pct(.99), lat[-1], statistics.fmean(lat)))
+    print("  errors: writes %d, logins %d, streams %d" % (
+        st.write_err, st.login_err, st.stream_err))
+    print("  streams: %.0f KB read (%.0f KB/s), %d messages delivered (%.0f/s), %d reopenings" % (
+        st.stream_bytes / 1024.0, st.stream_bytes / 1024.0 / dur if dur else 0,
+        st.stream_deliv, st.stream_deliv / dur if dur else 0, st.stream_reconn))
+    if st.http_err:
+        print("  unexpected responses: %s" % st.http_err)
+    sys.stdout.flush()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/load/brisk_sample.sh b/test/load/brisk_sample.sh
new file mode 100755 (executable)
index 0000000..79ce2a9
--- /dev/null
@@ -0,0 +1,37 @@
+#!/bin/bash
+# Samples the resources of daemon and frontend during a load test.
+# usage: brisk_sample.sh <seconds> <interval> <label>
+DUR="${1:-60}"; INT="${2:-5}"; TAG="${3:-x}"
+
+dpid="$(pgrep -f 'php \./brisk-spush\.php' | head -1)"
+if [ -z "$dpid" ]; then echo "daemon not found"; exit 1; fi
+
+# cumulative cpu of a set of processes, in seconds
+cpusec() { local tot=0 t; for p in $*; do
+    t=$(awk '{print ($14+$15)/'"$(getconf CLK_TCK)"'}' /proc/$p/stat 2>/dev/null || echo 0)
+    tot=$(echo "$tot $t" | awk '{print $1+$2}'); done; echo "$tot"; }
+
+# the frontend: nginx (direct mode) or apache (descriptor handover)
+fpids() { { pgrep -f 'nginx: worker'; pgrep -x apache2; } | tr '\n' ' '; }
+
+d0=$(cpusec $dpid); f0=$(cpusec $(fpids)); t0=$(date +%s.%N)
+maxrss=0; maxfd=0; maxconn=0; n=0
+end=$(( $(date +%s) + DUR ))
+while [ $(date +%s) -lt $end ]; do
+    sleep "$INT"
+    rss=$(awk '/VmRSS/{print $2}' /proc/$dpid/status 2>/dev/null || echo 0)
+    fd=$(ls /proc/$dpid/fd 2>/dev/null | wc -l)
+    conn=$(ss -x 2>/dev/null | grep -c 'brisk[0-9]*\.sock')
+    [ "$rss" -gt "$maxrss" ] && maxrss=$rss
+    [ "$fd" -gt "$maxfd" ] && maxfd=$fd
+    [ "$conn" -gt "$maxconn" ] && maxconn=$conn
+    n=$((n+1))
+done
+d1=$(cpusec $dpid); f1=$(cpusec $(fpids)); t1=$(date +%s.%N)
+
+echo "$d0 $d1 $f0 $f1 $t0 $t1 $maxrss $maxfd $maxconn" | awk '{
+  el = $6 - $5;
+  printf "  daemon: cpu %.0f%% of one core, rss %.0f MB, %d open fds\n", ($2-$1)/el*100, $7/1024, $8;
+  printf "  frontend: cpu %.0f%% of one core (every process)\n", ($4-$3)/el*100;
+  printf "  unix connections towards the daemon (max): %d\n", $9;
+}'
diff --git a/test/load/brisk_step.sh b/test/load/brisk_step.sh
new file mode 100755 (executable)
index 0000000..5463a52
--- /dev/null
@@ -0,0 +1,33 @@
+#!/bin/bash
+# One step of the load test: restarts the daemon so as to start from an empty
+# room, launches the generator and samples the resources while it runs.
+#
+# usage: brisk_step.sh <clients> <duration> <write_period> [--silent] [--tls]
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing (it happened: eighty loops alive for two days). kill 0
+# kills the process group, which setsid makes exclusive to this script.
+trap "kill 0" EXIT INT TERM
+
+N="${1:-50}"; DUR="${2:-120}"; PER="${3:-10}"; shift 3
+EXTRA="$*"
+WARM=30
+PORT="${PORT:-8082}"
+echo "$EXTRA" | grep -q -- "--tls" && PORT=8444
+
+# empty room: the users of the previous step would stay connected
+p=$(pgrep -f 'php \./brisk-spush\.php'); [ -n "$p" ] && kill $p; sleep 2
+s=$(pgrep -u www-data -x screen); [ -n "$s" ] && kill $s 2>/dev/null; sleep 1
+rm -f /tmp/brisk.log
+su -s /bin/bash www-data -c "cd /home/brisk/web/brisk/spush && screen -d -m -S brisk -L -Logfile /tmp/brisk.log ./brisk-spush.php"
+sleep 4
+
+echo "=== $N clients, $DUR s, one write every $PER s $EXTRA ==="
+( sleep $((WARM + 3)); /root/load/brisk_sample.sh "$DUR" 5 "$N" ) > /tmp/sample.out 2>&1 &
+SAMP=$!
+cd /root/load
+timeout $((WARM + DUR + 120)) python3 brisk_load.py --auth --clients "$N" \
+        --warmup "$WARM" --duration "$DUR" --period "$PER" --port "$PORT" $EXTRA
+wait $SAMP
+cat /tmp/sample.out
+echo "  daemon log: $(wc -l < /tmp/brisk.log) lines, of which unexpected: $(tr -d '#\r' < /tmp/brisk.log | grep -icE 'warning|notice|error|fatal')"
+echo "  nginx errors: $(grep -cE ' \[(error|crit|alert)\] ' /var/log/nginx/error.log 2>/dev/null || echo 0)"
diff --git a/test/load/burst.py b/test/load/burst.py
new file mode 100755 (executable)
index 0000000..d026732
--- /dev/null
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""Burst of simultaneous connections on the unix sockets of the daemon.
+
+It measures how many the listen queue refuses (EAGAIN) when they all arrive
+together: the case nginx meets when many players come in at the same instant.
+"""
+import socket
+import sys
+
+pfx = sys.argv[1] if len(sys.argv) > 1 else "/home/brisk/priv/brisk"
+npool = int(sys.argv[2]) if len(sys.argv) > 2 else 10
+n = int(sys.argv[3]) if len(sys.argv) > 3 else 200
+only0 = len(sys.argv) > 4 and sys.argv[4] == "single"
+
+ok = eagain = other = 0
+keep = []
+for i in range(n):
+    idx = 0 if only0 else i % npool
+    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+    s.setblocking(False)
+    try:
+        s.connect("%s%d.sock" % (pfx, idx))
+        ok += 1
+        keep.append(s)
+    except BlockingIOError:
+        # a non blocking connect on a unix socket either succeeds at once or
+        # fails: EAGAIN here means the listen queue is full
+        eagain += 1
+        s.close()
+    except OSError as e:
+        if e.errno == 11:
+            eagain += 1
+        else:
+            other += 1
+        s.close()
+
+print("  out of %d simultaneous connections: %d accepted, %d refused (queue full), %d other errors"
+      % (n, ok, eagain, other))
+for s in keep:
+    s.close()
diff --git a/test/load/game.sh b/test/load/game.sh
new file mode 100755 (executable)
index 0000000..5368d20
--- /dev/null
@@ -0,0 +1,78 @@
+#!/bin/bash
+# Five automatic players come in, sit down and form the table.
+# Then they stay connected: the hand is played by hand.sh.
+#
+# usage: game.sh [table] [port]
+#
+# It lives in /root/load and not in /tmp, which is tmpfs: a restart of the
+# container used to wipe the scripts.
+
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing. kill 0 kills the group, which setsid makes exclusive.
+trap "kill 0" EXIT INT TERM
+
+TAB="${1:-4}"; PORTA="${2:-8444}"
+B="https://127.0.0.1:${PORTA}/brisk"
+CURL="curl -sSk"
+cd /root/load
+rm -f auth.txt r?.stream k?.stream mani.txt tok.txt
+
+echo "== 1. login of five authenticated users =="
+for pair in "uno one" "due two" "tre thr" "qua for" "cin fiv"; do
+    set -- $pair; U=$1; P=$2
+    TOK=$($CURL -m 15 "$B/index_wr.php?mesg=getchallenge&cli_name=$U" | cut -d'|' -f2)
+    MP=$(printf '%s' "$P" | md5sum | cut -d' ' -f1)
+    PRIV=$(printf '%s%s' "$TOK" "$MP" | md5sum | cut -d' ' -f1)
+    S=$($CURL -m 20 "$B/index.php?name=$U&pass_private=$PRIV" \
+        | grep -oE 'sess = "[0-9a-f]+"' | head -1 | sed 's/.*"\([0-9a-f]*\)".*/\1/')
+    echo "$U $S" >> auth.txt
+    printf "   %-4s %s\n" "$U" "${S:-FALLITO}"
+done
+
+echo "== 2. room, and sitting down at table $TAB =="
+i=0
+while read U S; do
+    i=$((i+1))
+    ( while true; do
+        $CURL -N -m 90 -b "sess=$S" \
+          "$B/index_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr" >> r$i.stream 2>/dev/null
+        sleep 0.3
+      done ) &
+    sleep 1
+done < auth.txt
+sleep 3
+while read U S; do
+    $CURL -m 20 -o /dev/null -b "sess=$S" "$B/index_wr.php?sess=$S&stp=0&mesg=sitdown%7C$TAB"
+    sleep 1
+done < auth.txt
+sleep 4
+
+TK=$(grep -ohE 'createCookie\("table_token", "[0-9a-f]+"' r?.stream | head -1 | grep -oE '[0-9a-f]{10,}')
+echo "   table_token: ${TK:-TABLE NOT FORMED}"
+echo "$TK" > tok.txt
+[ -z "$TK" ] && exit 1
+
+echo "== 3. game table =="
+i=0
+while read U S; do
+    i=$((i+1))
+    $CURL -m 20 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+      "$B/briskin5/index.php"
+    ( while true; do
+        $CURL -N -m 100 -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+          "$B/briskin5/index_rd.php?stat=&subst=&step=-1&from=table_php&transp=xhr" >> k$i.stream 2>/dev/null
+        sleep 0.3
+      done ) &
+    sleep 1
+done < auth.txt
+sleep 6
+
+for i in 1 2 3 4 5; do
+    U=$(sed -n "${i}p" auth.txt | awk '{print $1}')
+    P=$(grep -oE "card_send\([0-9]+,[0-9]+,[0-9]+" k$i.stream | head -1 | sed 's/card_send(\([0-9]*\),.*/\1/')
+    H=$(grep -oE "card_send\($P,[0-9]+,[0-9]+" k$i.stream | sed 's/.*,//' | sort -n -u | tr '\n' ' ')
+    echo "$i $U $P $H" >> mani.txt
+    printf "   %-4s pos %s (%d cards)\n" "$U" "$P" "$(echo $H | wc -w)"
+done
+echo "   ready: now hand.sh plays the hand"
+wait
diff --git a/test/load/ghostswap.py b/test/load/ghostswap.py
new file mode 100755 (executable)
index 0000000..51bc15d
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Access by the same user from a second browser (different sessions).
+
+The first stream must be sent back to the login, where ghost_sess explains
+that the session was assigned to another browser.
+"""
+import hashlib, re, socket, ssl, sys, time
+HOST, PORT, PFX = "127.0.0.1", 8444, "/brisk/"
+USER = sys.argv[1] if len(sys.argv) > 1 else "load310"
+
+def conn():
+    ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
+    return ctx.wrap_socket(socket.create_connection((HOST, PORT), 10), server_hostname=HOST)
+
+def get(path, cookie=None):
+    c = conn()
+    req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+    if cookie: req += "Cookie: sess=%s\r\n" % cookie
+    c.sendall((req + "\r\n").encode())
+    buf = b""
+    while True:
+        d = c.recv(65536)
+        if not d: break
+        buf += d
+    c.close(); return buf
+
+def login():
+    chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+    tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+    priv = hashlib.md5((tok + hashlib.md5(USER.encode()).hexdigest()).encode()).hexdigest()
+    m = re.search(rb'sess = "([0-9a-f]+)"', get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv)))
+    return m.group(1).decode() if m else None
+
+def stream(sess):
+    c = conn()
+    c.sendall(("GET %sindex_rd.php?sess=%s&stat=&subst=&step=-1&from=index_php&transp=xhr HTTP/1.1\r\n"
+               "Host: %s\r\nCookie: sess=%s\r\n\r\n" % (PFX, sess, HOST, sess)).encode())
+    return c
+
+a = login(); print("  first access:   %s" % a)
+sa = stream(a); sa.settimeout(5)
+t0 = time.monotonic(); iniz = b""
+while time.monotonic() - t0 < 3:
+    try: d = sa.recv(65536)
+    except socket.timeout: break
+    if not d: break
+    iniz += d
+print("  first stream open (%d bytes)" % len(iniz))
+
+b = login(); print("  second access:  %s  (session %s)" % (b, "different" if b != a else "THE SAME"))
+sb = stream(b)
+time.sleep(1)
+
+sa.settimeout(8); cong = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 6:
+    try: d = sa.recv(65536)
+    except socket.timeout: break
+    if not d: break
+    cong += d
+print("  the first stream received %d bytes" % len(cong))
+print("     stops the stream:    %s" % (b"xstm.stop" in cong))
+print("     goes back to login:  %s" % (b"location.assign" in cong))
+print("     only shows a notice: %s" % (b"new notify" in cong))
+sa.close(); sb.close()
diff --git a/test/load/hand.sh b/test/load/hand.sh
new file mode 100755 (executable)
index 0000000..6d4d6e2
--- /dev/null
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Plays a whole hand at the table formed by game.sh: auction, call and forty
+# cards.
+#
+# usage: hand.sh [table] [port]
+
+TAB="${1:-4}"; PORTA="${2:-8444}"
+B="https://127.0.0.1:${PORTA}/brisk"
+CURL="curl -sSk"
+cd /root/load
+TK=$(cat tok.txt)
+
+send() {
+    local T=$1 M=$2
+    local S=$(sed -n "${T}p" auth.txt | awk '{print $2}')
+    $CURL -m 15 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+      "$B/briskin5/index_wr.php?sess=$S&stp=0&mesg=$(printf '%s' "$M" | sed 's/|/%7C/g')"
+}
+
+# index of the player whose turn it is, 0 if nobody
+turn() {
+    for i in 1 2 3 4 5; do
+        [ "$(grep -oE 'remark_(on|off)' k$i.stream 2>/dev/null | tail -1)" = "remark_on" ] && { echo $i; return; }
+    done
+    echo 0
+}
+# an observer other than the player whose turn it is: the play is recognised
+# from its stream, because the one playing gets remark_off and not card_play
+obs() { [ "$1" = "1" ] && echo 2 || echo 1; }
+
+echo "== auction =="
+BID=0
+for r in $(seq 1 10); do
+    T=$(turn); [ "$T" = "0" ] && { sleep 2; T=$(turn); }; [ "$T" = "0" ] && break
+    U=$(sed -n "${T}p" auth.txt | awk '{print $1}')
+    if [ "$BID" = "0" ]; then send $T "asta|0|0"; BID=$T; echo "   $U calls"
+    else send $T "asta|-1|0"; echo "   $U passes"; fi
+    sleep 2
+    grep -q "choose_seed" k$BID.stream && { echo "   $(sed -n "${BID}p" auth.txt | awk '{print $1}') wins"; break; }
+done
+
+HB=$(awk -v n=$BID 'NR==n {for(j=4;j<=NF;j++) printf "%s ", $j}' mani.txt)
+for a in 0 10 20 30; do echo " $HB " | grep -q " $a " || { CALL=$a; break; }; done
+echo "   calls card $CALL"
+send $BID "choose|$CALL"
+sleep 3
+
+echo "== play =="
+declare -A HAND
+for i in 1 2 3 4 5; do HAND[$i]=$(awk -v n=$i 'NR==n {for(j=4;j<=NF;j++) printf "%s ", $j}' mani.txt); done
+played=0
+for n in $(seq 1 70); do
+    [ $played -ge 40 ] && break
+    T=$(turn); [ "$T" = "0" ] && { sleep 2; T=$(turn); }
+    [ "$T" = "0" ] && { echo "   no active turn"; break; }
+    U=$(sed -n "${T}p" auth.txt | awk '{print $1}'); O=$(obs $T); OK=0
+    for C in ${HAND[$T]}; do
+        SZ=$(stat -c%s k$O.stream)
+        # the coordinates are those of the playing area: fixed ones would
+        # land every card on the same spot
+        send $T "play|$C|$((330 + RANDOM % 90))|$((260 + RANDOM % 60))"
+        sleep 1
+        if tail -c +$((SZ+1)) k$O.stream | grep -q "card_play("; then
+            HAND[$T]=$(echo ${HAND[$T]} | tr ' ' '\n' | grep -v "^$C$" | tr '\n' ' ')
+            played=$((played+1)); OK=1
+            printf "   %2d. %-4s -> %2d\n" $played "$U" "$C"
+            break
+        fi
+    done
+    [ $OK -eq 0 ] && { echo "   !! $U stuck"; break; }
+done
+echo "   === cards played: $played ==="
diff --git a/test/load/play_human.sh b/test/load/play_human.sh
new file mode 100755 (executable)
index 0000000..302330e
--- /dev/null
@@ -0,0 +1,77 @@
+#!/bin/bash
+# Drives the four bots while the fifth seat is taken by a person.
+#
+# The bots never call: they pass the auction and leave the call to the human.
+# When it is their turn they first try to pass; if after a second the turn is
+# still theirs it means the auction is over, and then they play the first card
+# the server accepts. That way there is no need to know which phase we are in.
+B="https://127.0.0.1:8444/brisk"; TAB="${1:-4}"; cd /tmp
+CURL="curl -sSk"
+MAXWAIT="${2:-900}"
+
+echo "waiting for the table to form..."
+for n in $(seq 1 $MAXWAIT); do
+    [ -s tok.txt ] && [ -s mani.txt ] && break
+    sleep 1
+done
+TK=$(cat tok.txt 2>/dev/null)
+if [ -z "$TK" ]; then echo "table not formed, leaving"; exit 1; fi
+echo "table $TAB, token $TK"
+cat mani.txt
+
+send() { local T=$1 M=$2
+    local S=$(sed -n "${T}p" auth.txt | awk '{print $2}')
+    $CURL -m 15 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+      "$B/briskin5/index_wr.php?sess=$S&stp=0&mesg=$(printf '%s' "$M" | sed 's/|/%7C/g')"
+}
+
+# indice del bot a cui tocca, 0 se tocca all'umano o a nessuno
+turn() {
+    for i in 1 2 3 4; do
+        [ "$(grep -oE 'remark_(on|off)' k$i.stream 2>/dev/null | tail -1)" = "remark_on" ] && { echo $i; return; }
+    done
+    echo 0
+}
+# an observer other than the player whose turn it is
+obs() { [ "$1" = "1" ] && echo 2 || echo 1; }
+
+declare -A HAND
+for i in 1 2 3 4; do
+    HAND[$i]=$(awk -v n=$i 'NR==n {for(j=4;j<=NF;j++) printf "%s ", $j}' mani.txt)
+done
+
+played=0; idle=0
+echo "=== playing: the bots pass the auction, the call is up to the human ==="
+while [ $played -lt 40 ] && [ $idle -lt 300 ]; do
+    T=$(turn)
+    if [ "$T" = "0" ]; then
+        idle=$((idle + 1)); sleep 1; continue
+    fi
+    idle=0
+    U=$(sed -n "${T}p" auth.txt | awk '{print $1}')
+
+    # first guess: we are in the auction, the bot passes
+    SZ=$(stat -c%s k$T.stream)
+    send $T "asta|-1|0"
+    sleep 1
+    if [ "$(turn)" != "$T" ]; then
+        echo "   $U passes"
+        continue
+    fi
+
+    # the turn did not move: we are playing
+    O=$(obs $T); OK=0
+    for C in ${HAND[$T]}; do
+        SZ=$(stat -c%s k$O.stream)
+        send $T "play|$C|$((330 + RANDOM % 90))|$((260 + RANDOM % 60))"
+        sleep 1
+        if tail -c +$((SZ+1)) k$O.stream | grep -q "card_play("; then
+            HAND[$T]=$(echo ${HAND[$T]} | tr ' ' '\n' | grep -v "^$C$" | tr '\n' ' ')
+            played=$((played+1)); OK=1
+            printf "   %2d. %-8s plays %2d\n" $played "$U" "$C"
+            break
+        fi
+    done
+    [ $OK -eq 0 ] && { echo "   !! $U cannot play, waiting"; sleep 2; }
+done
+echo "=== cards played by bots and human: $played (out of 40) ==="
diff --git a/test/load/ramp_probe.sh b/test/load/ramp_probe.sh
new file mode 100755 (executable)
index 0000000..7c8ae66
--- /dev/null
@@ -0,0 +1,17 @@
+#!/bin/bash
+# Measures what a client receives while 150 users come into the room.
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing (it happened: eighty loops alive for two days). kill 0
+# kills the process group, which setsid makes exclusive to this script.
+trap "kill 0" EXIT INT TERM
+
+B="http://127.0.0.1:8082/brisk"
+TOK=$(curl -sS -m 10 "$B/index_wr.php?mesg=getchallenge&cli_name=load303" | cut -d"|" -f2)
+MP=$(printf "%s" "load303" | md5sum | cut -d" " -f1)
+PRIV=$(printf "%s%s" "$TOK" "$MP" | md5sum | cut -d" " -f1)
+S=$(curl -sS -m 20 "$B/index.php?name=load303&pass_private=$PRIV" | grep -oE "sess = \"[0-9a-f]+\"" | head -1 | sed "s/.*\"\(.*\)\"/\1/")
+( curl -sS -N -m 60 -b "sess=$S" "$B/index_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr" > /tmp/ramp.stream 2>/dev/null ) &
+sleep 3
+cd /root/load
+timeout 100 python3 brisk_load.py --auth --clients 150 --base 101 --silent --warmup 5 --duration 35 --port 8082 > /tmp/load5.log 2>&1
+wait
diff --git a/test/load/sit4.sh b/test/load/sit4.sh
new file mode 100755 (executable)
index 0000000..409f54d
--- /dev/null
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Four automatic players sit at the table and wait for the fifth: a human
+# being with a real browser. They stay connected and keep their streams open,
+# so the table forms as soon as the human sits down.
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing (it happened: eighty loops alive for two days). kill 0
+# kills the process group, which setsid makes exclusive to this script.
+trap "kill 0" EXIT INT TERM
+
+B="https://127.0.0.1:8444/brisk"; TAB="${1:-4}"; cd /tmp
+CURL="curl -sSk"
+rm -f auth.txt r?.stream k?.stream mani.txt tok.txt
+
+for u in load001 load002 load003 load004; do
+  TOK=$($CURL -m 15 "$B/index_wr.php?mesg=getchallenge&cli_name=$u" | cut -d'|' -f2)
+  MP=$(printf '%s' "$u" | md5sum | cut -d' ' -f1)
+  PRIV=$(printf '%s%s' "$TOK" "$MP" | md5sum | cut -d' ' -f1)
+  S=$($CURL -m 20 "$B/index.php?name=$u&pass_private=$PRIV" | grep -oE 'sess = "[0-9a-f]+"' | head -1 | sed 's/.*"\([0-9a-f]*\)".*/\1/')
+  echo "$u $S" >> auth.txt
+  printf "   %s %s\n" "$u" "${S:-FAILED}"
+done
+
+i=0; while read U S; do i=$((i+1))
+  ( while true; do $CURL -N -m 90 -b "sess=$S" \
+      "$B/index_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr" >> r$i.stream 2>/dev/null
+    sleep 0.3; done ) &
+  sleep 0.5; done < auth.txt
+sleep 3
+
+while read U S; do
+  $CURL -m 20 -o /dev/null -b "sess=$S" "$B/index_wr.php?sess=$S&stp=0&mesg=sitdown%7C$TAB"
+  sleep 1
+done < auth.txt
+echo "   four seated at table $TAB, waiting for the fifth"
+
+for n in $(seq 1 900); do
+  TK=$(grep -ohE 'createCookie\("table_token", "[0-9a-f]+"' r?.stream | head -1 | grep -oE '[0-9a-f]{10,}')
+  [ -n "$TK" ] && break
+  sleep 1
+done
+if [ -z "$TK" ]; then echo "   table not formed within 15 minutes"; exit 1; fi
+echo "$TK" > tok.txt
+echo "   table formed: $TK"
+
+i=0; while read U S; do i=$((i+1))
+  $CURL -m 20 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" "$B/briskin5/index.php"
+  ( while true; do $CURL -N -m 100 -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+      "$B/briskin5/index_rd.php?stat=&subst=&step=-1&from=table_php&transp=xhr" >> k$i.stream 2>/dev/null
+    sleep 0.3; done ) &
+  sleep 0.7; done < auth.txt
+sleep 6
+
+for i in 1 2 3 4; do
+  U=$(sed -n "${i}p" auth.txt | awk '{print $1}')
+  P=$(grep -oE "card_send\([0-9]+,[0-9]+,[0-9]+" k$i.stream | head -1 | sed 's/card_send(\([0-9]*\),.*/\1/')
+  H=$(grep -oE "card_send\($P,[0-9]+,[0-9]+" k$i.stream | sed 's/.*,//' | sort -n -u | tr '\n' ' ')
+  echo "$i $U $P $H" >> mani.txt
+  printf "   %s in position %s, %d cards\n" "$U" "$P" "$(echo $H | wc -w)"
+done
+echo "   ready"
+wait
diff --git a/test/load/slowprobe.py b/test/load/slowprobe.py
new file mode 100755 (executable)
index 0000000..25ba020
--- /dev/null
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Slow client: it connects, stops reading while a burst happens in the room,
+then resumes.
+
+If in the meantime the server queued more than COMM_N messages, the history is
+lost and the client is sent back to the initial page (splash included). That is
+recognised from the markers of the full initialisation, prefs_load() and
+notify_ex(), which in normal operation arrive only once.
+"""
+import hashlib
+import re
+import socket
+import ssl
+import sys
+import time
+
+HOST = "127.0.0.1"
+PORT = 8444
+USER = sys.argv[1] if len(sys.argv) > 1 else "load370"
+PAUSA = float(sys.argv[2]) if len(sys.argv) > 2 else 45.0
+PFX = "/brisk/"
+
+
+def conn():
+    s = socket.create_connection((HOST, PORT), 10)
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    return ctx.wrap_socket(s, server_hostname=HOST)
+
+
+def get(path, cookie=None):
+    c = conn()
+    req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+    if cookie:
+        req += "Cookie: sess=%s\r\n" % cookie
+    c.sendall((req + "\r\n").encode())
+    buf = b""
+    while True:
+        d = c.recv(65536)
+        if not d:
+            break
+        buf += d
+    c.close()
+    return buf
+
+
+chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+mp = hashlib.md5(USER.encode()).hexdigest()
+priv = hashlib.md5((tok + mp).encode()).hexdigest()
+body = get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv))
+m = re.search(rb'sess = "([0-9a-f]+)"', body)
+if not m:
+    print("login fallito")
+    sys.exit(1)
+sess = m.group(1).decode()
+print("  session: %s" % sess)
+
+c = conn()
+req = ("GET %sindex_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr"
+       " HTTP/1.1\r\nHost: %s\r\nCookie: sess=%s\r\n\r\n" % (PFX, HOST, sess))
+c.sendall(req.encode())
+
+# phase 1: read the initialisation
+c.settimeout(8)
+iniz = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 6:
+    try:
+        d = c.recv(65536)
+    except socket.timeout:
+        break
+    if not d:
+        break
+    iniz += d
+print("  initialisation: %d bytes, prefs_load: %d" % (
+    len(iniz), iniz.count(b"prefs_load(")))
+
+# phase 2: stop reading; the burst happens now
+print("  not reading for %.0f seconds..." % PAUSA)
+sys.stdout.flush()
+time.sleep(PAUSA)
+
+# phase 3: resume
+c.settimeout(15)
+dopo = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 12:
+    try:
+        d = c.recv(65536)
+    except socket.timeout:
+        break
+    if not d:
+        break
+    dopo += d
+c.close()
+
+reinit = dopo.count(b"prefs_load(")
+print("  resumed: %d bytes, room updates: %d, reinitialisations: %d"
+      % (len(dopo), dopo.count(b"j_stand_cont("), reinit))
+print("  OUTCOME: %s" % ("HISTORY LOST, sent back to the initial page" if reinit
+                       else "history preserved"))
diff --git a/test/load/stop.sh b/test/load/stop.sh
new file mode 100755 (executable)
index 0000000..85a6e3c
--- /dev/null
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Shuts the whole test bench down and shows the final state.
+# To be run ALWAYS at the end of a test session.
+#
+# It does not use "pkill -f": the pattern would end up in the command line of
+# this very script, which would kill itself and leave the targets alive. That
+# happened several times, and it is how eighty loops survived for two days.
+
+MIO=$$
+PAT="game\\.sh|hand\\.sh|game_pkg|game_https|game_t5|game_t9|hand_pkg|hand_https|sit4|play_human"
+PAT="$PAT|join4|gjoin|ramp_probe|brisk_load|slowprobe|wsprobe|takeover"
+PAT="$PAT|ghostswap|burst"
+
+pidlist() {
+    ps ax -o pid=,args= | awk -v mio="$MIO" '$1 != mio { print }' \
+        | grep -E "$PAT" | grep -v grep | awk '{ print $1 }'
+}
+
+echo "test scripts found: $(pidlist | grep -c .)"
+for p in $(pidlist); do kill "$p" 2>/dev/null; done
+sleep 3
+for p in $(pidlist); do kill -9 "$p" 2>/dev/null; done
+for p in $(pgrep -x curl); do kill -9 "$p" 2>/dev/null; done
+sleep 2
+
+# The capture files grow without bound: two days of runaway loops had filled
+# the disk, which is the same one as the host's.
+rm -f /tmp/*.stream /tmp/*.html 2>/dev/null
+
+d="$(pgrep -f 'php \./brisk-spush\.php' | head -1)"
+echo
+echo "=== final state ==="
+printf "  test scripts running:   %s\n" "$(pidlist | grep -c .)"
+printf "  curl running:           %s\n" "$(pgrep -x curl | wc -l)"
+printf "  daemon:                 %s process, %s descriptors (10 are the listening sockets)\n" \
+       "$(pgrep -f 'php \./brisk-spush\.php' | wc -l)" "$(ls /proc/$d/fd 2>/dev/null | wc -l)"
+printf "  disk space:             %s\n" \
+       "$(df -h / | awk 'NR==2 { print $4 " free (" $5 " used)" }')"
+printf "  daemon log:             %s\n" "$(du -sh /tmp/brisk.log 2>/dev/null | cut -f1)"
+printf "  load:                   %s\n" "$(uptime | sed 's/.*average: //')"
diff --git a/test/load/takeover.py b/test/load/takeover.py
new file mode 100755 (executable)
index 0000000..14d7949
--- /dev/null
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""Two streams on the same session: the first one must be dismissed.
+
+It opens a stream, opens a second one with the same session and looks at what
+the first receives before being closed. Before the fix it received only the
+closing of the socket, and the client on the other side reopened forever.
+"""
+import base64
+import hashlib
+import os
+import re
+import socket
+import ssl
+import sys
+import time
+
+HOST, PORT, PFX = "127.0.0.1", 8444, "/brisk/"
+USER = sys.argv[1] if len(sys.argv) > 1 else "load350"
+TRANSP = sys.argv[2] if len(sys.argv) > 2 else "xhr"
+
+
+def conn():
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    return ctx.wrap_socket(socket.create_connection((HOST, PORT), 10), server_hostname=HOST)
+
+
+def get(path, cookie=None):
+    c = conn()
+    req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+    if cookie:
+        req += "Cookie: sess=%s\r\n" % cookie
+    c.sendall((req + "\r\n").encode())
+    buf = b""
+    while True:
+        d = c.recv(65536)
+        if not d:
+            break
+        buf += d
+    c.close()
+    return buf
+
+
+chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+priv = hashlib.md5((tok + hashlib.md5(USER.encode()).hexdigest()).encode()).hexdigest()
+m = re.search(rb'sess = "([0-9a-f]+)"',
+              get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv)))
+sess = m.group(1).decode()
+print("  session: %s, transport of the first stream: %s" % (sess, TRANSP))
+
+
+def apri(transp):
+    c = conn()
+    if transp.startswith("websocket"):
+        key = base64.b64encode(os.urandom(16)).decode()
+        req = ("GET %sindex_rd_wss.php?sess=%s&stat=&subst=&step=-1&from=index_php&transp=%s"
+               " HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+               "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nCookie: sess=%s\r\n\r\n"
+               % (PFX, sess, transp, HOST, key, sess))
+    else:
+        req = ("GET %sindex_rd.php?sess=%s&stat=&subst=&step=-1&from=index_php&transp=%s"
+               " HTTP/1.1\r\nHost: %s\r\nCookie: sess=%s\r\n\r\n"
+               % (PFX, sess, transp, HOST, sess))
+    c.sendall(req.encode())
+    return c
+
+
+primo = apri(TRANSP)
+primo.settimeout(6)
+iniz = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 4:
+    try:
+        d = primo.recv(65536)
+    except socket.timeout:
+        break
+    if not d:
+        break
+    iniz += d
+print("  first stream open: %d bytes of initialisation" % len(iniz))
+
+secondo = apri("xhr")
+print("  second stream opened on the same session")
+
+primo.settimeout(10)
+congedo = b""
+chiuso = False
+t0 = time.monotonic()
+while time.monotonic() - t0 < 8:
+    try:
+        d = primo.recv(65536)
+    except socket.timeout:
+        break
+    if not d:
+        chiuso = True
+        break
+    congedo += d
+
+print("  the first stream received %d bytes, then %s"
+      % (len(congedo), "closed by the server" if chiuso else "no close"))
+print("     stops the stream (xstm.stop):  %s" % (b"xstm.stop" in congedo))
+print("     shows the notice (new notify): %s" % (b"new notify" in congedo))
+print("     sends back to the login:       %s" % (b"location.assign" in congedo))
+if TRANSP.startswith("websocket") and congedo:
+    print("     first byte 0x%02x (0x81 = text frame)" % congedo[0])
+primo.close()
+secondo.close()
diff --git a/test/load/wsprobe.py b/test/load/wsprobe.py
new file mode 100755 (executable)
index 0000000..1268a42
--- /dev/null
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""Minimal websocket client to exercise the brisk channel.
+
+It does a challenge login, asks for the upgrade on index_rd_wss.php and reads
+frames for a few seconds, reporting how many arrive and when the connection is
+closed. It tells a server defect apart from the state of one browser tab.
+"""
+import base64
+import hashlib
+import os
+import socket
+import ssl
+import sys
+import time
+import urllib.parse
+
+HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
+PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8444
+USER = sys.argv[3] if len(sys.argv) > 3 else "load390"
+PASSWD = sys.argv[4] if len(sys.argv) > 4 else None
+SECS = float(sys.argv[5]) if len(sys.argv) > 5 else 20.0
+PFX = "/brisk/"
+if PASSWD is None:
+    PASSWD = USER
+
+
+def conn():
+    s = socket.create_connection((HOST, PORT), 10)
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    return ctx.wrap_socket(s, server_hostname=HOST)
+
+
+def get(path, cookie=None):
+    c = conn()
+    req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+    if cookie:
+        req += "Cookie: sess=%s\r\n" % cookie
+    req += "\r\n"
+    c.sendall(req.encode())
+    buf = b""
+    while True:
+        try:
+            d = c.recv(65536)
+        except Exception:
+            break
+        if not d:
+            break
+        buf += d
+    c.close()
+    return buf
+
+
+def login():
+    chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+    tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+    mp = hashlib.md5(PASSWD.encode()).hexdigest()
+    priv = hashlib.md5((tok + mp).encode()).hexdigest()
+    body = get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv))
+    import re
+    m = re.search(rb'sess = "([0-9a-f]+)"', body)
+    return m.group(1).decode() if m else None
+
+
+def ws(sess):
+    key = base64.b64encode(os.urandom(16)).decode()
+    c = conn()
+    path = ("%sindex_rd_wss.php?sess=%s&stat=&subst=&step=-1&from=index_php"
+            "&transp=websocketsec" % (PFX, sess))
+    req = ("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
+           "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
+           "Sec-WebSocket-Version: 13\r\nCookie: sess=%s\r\n\r\n"
+           % (path, HOST, key, sess))
+    c.sendall(req.encode())
+    c.settimeout(SECS)
+    head = b""
+    while b"\r\n\r\n" not in head:
+        d = c.recv(4096)
+        if not d:
+            print("  connection closed during the handshake")
+            return
+        head += d
+    status = head.split(b"\r\n", 1)[0].decode(errors="replace")
+    print("  answer to the upgrade: %s" % status)
+    rest = head.split(b"\r\n\r\n", 1)[1]
+    if b"101" not in status.encode():
+        print("  body received: %d bytes (not a websocket)" % len(rest))
+        return
+
+    nbytes = len(rest)
+    nframes = 0
+    t0 = time.monotonic()
+    while time.monotonic() - t0 < SECS:
+        try:
+            d = c.recv(65536)
+        except socket.timeout:
+            break
+        except Exception as e:
+            print("  read error: %s" % e)
+            break
+        if not d:
+            print("  closed by the server after %.1f s and %d bytes" % (
+                time.monotonic() - t0, nbytes))
+            return
+        nbytes += len(d)
+        nframes += d.count(b"\x81") + d.count(b"\x82")
+    print("  stayed open %.1f s, %d bytes received, ~%d frames" % (
+        time.monotonic() - t0, nbytes, nframes))
+    c.close()
+
+
+s = login()
+print("session: %s" % s)
+if s:
+    ws(s)