#!/usr/bin/env python3 """Exercise durable MUC/SM hot paths over real STARTTLS with bounded fanout. python3 tests/muc_sm_load_network.py --binary target/release/rust-xmpp --clients 500 Defaults to 100 resources; --clients 500 uses 100 accounts and 10 rooms of 50. JSON contains observed latency/RSS/CPU, slot counts and exact fanout assertions. All sockets are drained continuously. SIGKILL precedes socket cleanup; ten representatives resume with unacknowledged room output and send without rejoin. This is a loopback smoke measurement, not a production capacity guarantee. """ import argparse import asyncio import base64 import collections import hashlib import json import pathlib import signal import shutil import socket import ssl import subprocess import sys import tempfile import time import xml.etree.ElementTree as ET sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'scripts')) from load_smoke import Client as BaseClient, STREAM, distribution, process_metrics, proc_pid SM = 'urn:xmpp:sm:3' MUC = 'http://jabber.org/protocol/muc' CLIENT = 'jabber:client' def start(binary, config, port, log): # Recovery of hundreds of durable resources legitimately exceeds the # five-second deadline used by the tiny functional restart fixture. server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) deadline = time.monotonic() + 60 while time.monotonic() < deadline: assert server.poll() is None, 'server stopped during startup' try: with socket.create_connection(('127.0.0.1', port), timeout=.1): return server except OSError: time.sleep(.05) server.kill() server.wait() raise AssertionError('server did not start within 60 seconds') class Client(BaseClient): def __init__(self, number, user, reader, writer): super().__init__(number, user, reader, writer) self.received = self.inputs = self.acked = 0 self.token = None self.presences = {} self.iqs = {} self.controls = collections.defaultdict(list) self.bodies = collections.Counter() self.errors = [] self.changed = asyncio.Event() self.auto_ack = True self.task = None self.failure = None @classmethod async def connect(cls, number, port, context, accounts, bind=True): reader, writer = await asyncio.wait_for(asyncio.open_connection('127.0.0.1', port), 60) user = f'load-{number % accounts}' client = cls(number, user, reader, writer) try: await client.send(STREAM) assert b'starttls' in await client.until(b'') await client.send("") assert b'proceed' in await client.until(b'/>') await writer.start_tls(context, server_hostname='localhost', ssl_handshake_timeout=60) await client.send(STREAM) await client.until(b'') auth = base64.b64encode(f'\0{user}\0load-test-password'.encode()).decode() await client.send(f"{auth}") assert b'success' in await client.until(b'/>') await client.send(STREAM) await client.until(b'') if bind: await client.send(f"load-{number}") assert client.jid.encode() in await client.until(b'') client.task = asyncio.create_task(client.drain()) return client except BaseException: writer.close() raise async def stanza(self, text): self.inputs = (self.inputs + 1) % (1 << 32) await self.send(text) async def drain(self): parser = ET.XMLPullParser(events=('start', 'end')) parser.feed("") root = next(parser.read_events())[1] depth = 0 try: while chunk := await self.reader.read(65536): parser.feed(chunk) if hasattr(parser, 'flush'): parser.flush() for event, node in parser.read_events(): depth += 1 if event == 'start' else -1 if event != 'end' or depth != 0: continue local = node.tag.split('}')[-1] if node.tag in (f'{{{CLIENT}}}iq', f'{{{CLIENT}}}presence', f'{{{CLIENT}}}message'): self.received = (self.received + 1) % (1 << 32) if node.get('type') == 'error': self.errors.append(ET.tostring(node).decode()) if local == 'presence': self.presences[node.get('from')] = node elif local == 'iq': self.iqs[node.get('id')] = node elif local == 'message': for body in node.findall(f'{{{CLIENT}}}body'): self.bodies[body.text] += 1 elif node.tag == f'{{{SM}}}r': if self.auto_ack: await self.ack() else: self.controls[node.tag].append(node) root.remove(node) self.changed.set() raise EOFError('server closed stream') except asyncio.CancelledError: raise except Exception as error: self.failure = error self.changed.set() async def wait(self, predicate, description): async def observe(): while True: self.changed.clear() result = predicate() if isinstance(result, ET.Element) or result: return result if self.failure: raise RuntimeError(f'client {self.number}: {description}: {self.failure}') await self.changed.wait() return await asyncio.wait_for(observe(), 120) async def control(self, name): key = f'{{{SM}}}{name}' await self.wait(lambda: self.controls[key], name) return self.controls[key].pop(0) async def enable(self): await self.send(f"") node = await self.control('enabled') assert node.get('resume') == 'true' self.token = node.get('id') assert self.token async def fence(self, identifier): await self.stanza(f"") result = await self.wait(lambda: self.iqs.pop(identifier, None), identifier) assert result.get('type') == 'result', ET.tostring(result) async def ack(self): self.acked = self.received await self.send(f"") async def handled(self): await self.send(f"") value = int((await self.control('a')).get('h')) assert value == self.inputs, (self.number, value, self.inputs) return value async def close_raw(self): if self.task: self.task.cancel() await asyncio.gather(self.task, return_exceptions=True) self.writer.close() try: await asyncio.wait_for(self.writer.wait_closed(), 3) except (OSError, ssl.SSLError, TimeoutError): pass def slots(root): paths = list((root / 'data').rglob('rust_smacks/*.json')) return {'count': len(paths), 'bytes': sum(p.stat().st_size for p in paths)} async def exercise(args, root, config, port, log): clients = [] server = await asyncio.to_thread(start, args.binary, config, port, log) pid = proc_pid(server.pid) samples = [] running = True async def sample(): while running: samples.append(process_metrics(pid)) await asyncio.sleep(.2) sampler = asyncio.create_task(sample()) gate = asyncio.Semaphore(args.concurrency) context = ssl.create_default_context(cafile=str(root / 'cert.pem')) report = {'clients': args.clients, 'accounts': args.accounts, 'rooms': args.rooms, 'message_rounds': args.rounds, 'concurrency': args.concurrency, 'binary': args.binary, 'binary_sha256': hashlib.sha256(pathlib.Path(args.binary).read_bytes()).hexdigest(), 'before': process_metrics(pid)} begun = time.perf_counter() def save(phase): report['phase'] = phase report['status'] = 'running' report['elapsed_seconds'] = round(time.perf_counter() - begun, 3) pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n') print(f'MUC+SM load: {phase} ({report["elapsed_seconds"]}s)', file=sys.stderr, flush=True) try: async def setup(number): async with gate: started = time.perf_counter() client = await Client.connect(number, port, context, args.accounts) clients.append(client) await client.enable() await client.stanza('') await client.fence(f'connected-{number}') await client.ack() return time.perf_counter() - started connect_times = await asyncio.gather(*(setup(i) for i in range(args.clients))) clients.sort(key=lambda c: c.number) report['connect_seconds'] = round(time.perf_counter() - begun, 3) report['connect_latency'] = distribution(connect_times) save('connected') groups = [[c for c in clients if c.number % args.rooms == room] for room in range(args.rooms)] started = time.perf_counter() async def join(client): async with gate: room = f'load-{client.number % args.rooms}@rooms.localhost' await client.stanza(f"") result = await client.wait(lambda: client.presences.get(f'{room}/Nick-{client.number}'), 'self MUC join') assert result.get('type') != 'error', ET.tostring(result) await asyncio.gather(*(join(c) for c in clients)) # Each room has exactly N nicknames, including self, on every resource. for group in groups: for client in group: expected = {f'load-{client.number % args.rooms}@rooms.localhost/Nick-{other.number}' for other in group} await client.wait(lambda c=client,e=expected: e.issubset(c.presences), 'complete join fanout') await asyncio.gather(*(c.fence(f'joined-{c.number}') for c in clients)) await asyncio.gather(*(c.ack() for c in clients)) await asyncio.gather(*(c.handled() for c in clients)) report['join_seconds'] = round(time.perf_counter() - started, 3) report['joined'] = process_metrics(pid) report['join_presence_deliveries'] = sum(len(g) ** 2 for g in groups) report['slots_joined'] = slots(root) assert report['slots_joined']['count'] == args.clients, report['slots_joined'] save('joined') delivered = 0 started = time.perf_counter() for turn in range(args.rounds): for index, group in enumerate(groups): sender = group[turn % len(group)] text = f'durable-load-{turn}-{index}' await sender.stanza(f"{text}") async def receive(client): text = f'durable-load-{turn}-{client.number % args.rooms}' await client.wait(lambda: client.bodies[text] >= 1, text) assert client.bodies[text] == 1, (client.number, text, client.bodies[text]) await asyncio.gather(*(receive(c) for c in clients)) delivered += len(clients) await asyncio.gather(*(c.fence(f'round-{turn}-{c.number}') for c in clients)) await asyncio.gather(*(c.ack() for c in clients)) report['message_seconds'] = round(time.perf_counter() - started, 3) report['room_messages_sent'] = args.rounds * args.rooms report['room_messages_delivered'] = delivered assert delivered == args.clients * args.rounds save('fanout_verified') assert not [(c.number,c.errors) for c in clients if c.errors] # Freeze acknowledgements, then deliberately receive one more message # per room without acknowledging it. Its durable replay is mandatory. representatives = [group[0] for group in groups] for client in clients: client.auto_ack = False for index, sender in enumerate(representatives): await sender.stanza(f"pending-{index}") await asyncio.gather(*(c.wait(lambda c=c: c.bodies[f'pending-{c.number % args.rooms}'] == 1, 'pending room message') for c in clients)) await asyncio.gather(*(c.fence(f'before-kill-{c.number}') for c in clients)) await asyncio.gather(*(c.handled() for c in clients)) expected = {c.number: {'token': c.token, 'acked': c.acked, 'handled': c.inputs} for c in representatives} report['before_kill'] = process_metrics(pid) report['slots_before_kill'] = slots(root) save('before_sigkill') server.send_signal(signal.SIGKILL) assert await asyncio.to_thread(server.wait, 10) == -signal.SIGKILL await asyncio.gather(*(c.close_raw() for c in clients)) clients.clear() started = time.perf_counter() server = await asyncio.to_thread(start, args.binary, config, port, log) pid = proc_pid(server.pid) report['restart_seconds'] = round(time.perf_counter() - started, 3) async def recover(number): async with gate: old = expected[number] client = await Client.connect(number, port, context, args.accounts, bind=False) client.auto_ack = False client.received = old['acked'] client.acked = old['acked'] client.inputs = old['handled'] client.token = old['token'] clients.append(client) await client.send(f"") result = await client.control('resumed') assert result.get('previd') == client.token assert int(result.get('h')) == client.inputs, (number, ET.tostring(result),client.inputs) room = number % args.rooms await client.wait(lambda: client.bodies[f'pending-{room}'] == 1, 'unacknowledged room replay') await client.wait(lambda: client.iqs.get(f'before-kill-{number}'), 'unacknowledged IQ replay') # No available presence or MUC join is sent after SM resume. text = f'after-resume-{room}' await client.stanza(f"{text}") await client.wait(lambda: client.bodies[text] == 1, 'recovered membership groupchat echo') await client.fence(f'after-resume-{number}') await client.ack() await client.handled() assert not client.errors, client.errors # No message already acknowledged before SIGKILL may replay. assert not any(count for text,count in client.bodies.items() if text and text.startswith('durable-load-')), client.bodies return number recovered = await asyncio.gather(*(recover(c.number) for c in representatives)) report['resumed_resources'] = len(recovered) report['replayed_pending_room_messages'] = len(recovered) report['membership_groupchat_deliveries_after_resume'] = len(recovered) report['after_resume'] = process_metrics(pid) report['slots_after_resume'] = slots(root) report['total_seconds'] = round(time.perf_counter() - begun, 3) report['elapsed_seconds'] = report['total_seconds'] report['peak_rss_kib'] = max((s.get('rss_kib',0) for s in samples), default=0) report['status'] = 'pass' report['phase'] = 'completed' return report except BaseException as error: report['status'] = 'fail' report['failure'] = repr(error) report['elapsed_seconds'] = round(time.perf_counter() - begun, 3) pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n') raise finally: running = False sampler.cancel() await asyncio.gather(sampler, return_exceptions=True) if server.poll() is None: server.terminate() try: await asyncio.to_thread(server.wait, 20) except subprocess.TimeoutExpired: server.kill() await asyncio.to_thread(server.wait) await asyncio.gather(*(c.close_raw() for c in clients), return_exceptions=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--binary', default='target/release/rust-xmpp') parser.add_argument('--clients', type=int, default=100) parser.add_argument('--accounts', type=int, default=100) parser.add_argument('--rooms', type=int, default=10) parser.add_argument('--rounds', type=int, default=3) parser.add_argument('--concurrency', type=int, default=20) parser.add_argument('--output', default='/tmp/muc-sm-load.json') args = parser.parse_args() args.binary = str(pathlib.Path(args.binary).resolve()) args.accounts = min(args.accounts,args.clients) assert 1 <= args.rooms <= args.clients <= 4000 assert 1 <= args.accounts <= args.clients and args.rounds > 0 and args.concurrency > 0 with tempfile.TemporaryDirectory(prefix='xmpp-durable-muc-load-') as tmp: root = pathlib.Path(tmp) # Release builds/packaging may replace the source binary during a long # run; this private executable is the same inode generation on restart. shutil.copy2(args.binary, root / 'rust-xmpp') args.binary = str(root / 'rust-xmpp') subprocess.run(['openssl','req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:P-256', '-nodes','-days','1','-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost', '-addext','basicConstraints=critical,CA:FALSE','-keyout',str(root/'key.pem'), '-out',str(root/'cert.pem')],check=True,capture_output=True) with socket.socket() as probe: probe.bind(('127.0.0.1',0)) port = probe.getsockname()[1] cfg = {'global': {'data_path':str(root/'data'),'interfaces':['127.0.0.1'], 'c2s_ports':[port],'s2s_ports':[],'http_ports':[],'https_ports':[], 'smacks_hibernation_time':600,'muc_recovery_grace_period':600, 'modules_enabled':['tls','saslauth','smacks','disco','ping'], 'ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}}, 'hosts': {'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256'}}, 'rooms.localhost':{'component':'muc','options':{'muc_room_locking':False, 'muc_room_default_history_length':0,'max_history_messages':0}}}} config = root/'config.json' config.write_text(json.dumps(cfg)) for number in range(args.accounts): subprocess.run([args.binary,'create-user',str(config),f'load-{number}','--password-stdin'], input='load-test-password\n',text=True,check=True,capture_output=True) with (root/'server.log').open('w+') as log: try: report = asyncio.run(exercise(args,root,config,port,log)) except BaseException: log.flush();log.seek(0) print(log.read(),file=sys.stderr) raise pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n') print(json.dumps(report,indent=2)) if __name__ == '__main__': main()