From ec9440c7bfd755e555fe73692fddc70c935f097e Mon Sep 17 00:00:00 2001 From: Matteo Nastasi Date: Sun, 13 Sep 2026 12:41:31 +0200 Subject: [PATCH] test/load: the test bench used for the checks 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) Claude-Session: https://claude.ai/code/session_014M1jiEq9cHdE5SE5j6vFuE --- test/load/README | 101 +++++++++++++++ test/load/brisk_load.py | 261 ++++++++++++++++++++++++++++++++++++++ test/load/brisk_sample.sh | 37 ++++++ test/load/brisk_step.sh | 33 +++++ test/load/burst.py | 40 ++++++ test/load/game.sh | 78 ++++++++++++ test/load/ghostswap.py | 65 ++++++++++ test/load/hand.sh | 72 +++++++++++ test/load/play_human.sh | 77 +++++++++++ test/load/ramp_probe.sh | 17 +++ test/load/sit4.sh | 61 +++++++++ test/load/slowprobe.py | 103 +++++++++++++++ test/load/stop.sh | 40 ++++++ test/load/takeover.py | 109 ++++++++++++++++ test/load/wsprobe.py | 116 +++++++++++++++++ 15 files changed, 1210 insertions(+) create mode 100644 test/load/README create mode 100755 test/load/brisk_load.py create mode 100755 test/load/brisk_sample.sh create mode 100755 test/load/brisk_step.sh create mode 100755 test/load/burst.py create mode 100755 test/load/game.sh create mode 100755 test/load/ghostswap.py create mode 100755 test/load/hand.sh create mode 100755 test/load/play_human.sh create mode 100755 test/load/ramp_probe.sh create mode 100755 test/load/sit4.sh create mode 100755 test/load/slowprobe.py create mode 100755 test/load/stop.sh create mode 100755 test/load/takeover.py create mode 100755 test/load/wsprobe.py diff --git a/test/load/README b/test/load/README new file mode 100644 index 0000000..5072bed --- /dev/null +++ b/test/load/README @@ -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', + from generate_series(1,400) i; + +where 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 index 0000000..6a73bc4 --- /dev/null +++ b/test/load/brisk_load.py @@ -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 index 0000000..79ce2a9 --- /dev/null +++ b/test/load/brisk_sample.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Samples the resources of daemon and frontend during a load test. +# usage: brisk_sample.sh