269 lines
14 KiB
Python
269 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Isolated Linux multi-account TCP/STARTTLS load smoke; stdlib and openssl only.
|
|
Reports observations, not production capacity guarantees or memory-leak proof.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import socket
|
|
import ssl
|
|
import statistics
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
STREAM = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>"
|
|
|
|
|
|
def proc_pid(child_pid):
|
|
"""Resolve this direct child's procfs PID when /proc exposes the host namespace."""
|
|
status = pathlib.Path('/proc/self/status').read_text()
|
|
parent = next(int(line.split()[1]) for line in status.splitlines() if line.startswith('Pid:'))
|
|
children = [p.name for p in pathlib.Path('/proc').iterdir() if p.name.isdigit()]
|
|
for candidate in children:
|
|
try:
|
|
fields = dict(line.split(':', 1) for line in pathlib.Path(f'/proc/{candidate}/status').read_text().splitlines() if ':' in line)
|
|
except (OSError, ProcessLookupError):
|
|
continue
|
|
namespaces = fields.get('NSpid', fields['Pid']).split()
|
|
if int(namespaces[-1]) == child_pid and int(fields['PPid'].strip()) == parent and fields['Name'].strip() == 'rust-xmpp':
|
|
return int(candidate)
|
|
raise RuntimeError('cannot resolve owned server process in procfs')
|
|
|
|
|
|
def process_metrics(pid):
|
|
try:
|
|
status = pathlib.Path(f'/proc/{pid}/status').read_text()
|
|
rss = next(int(line.split()[1]) for line in status.splitlines() if line.startswith('VmRSS:'))
|
|
fields = pathlib.Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
|
|
cpu = (int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK')
|
|
return {'rss_kib': rss, 'cpu_seconds': cpu, 'fd_count': len(list(pathlib.Path(f'/proc/{pid}/fd').iterdir()))}
|
|
except (OSError, StopIteration):
|
|
return {'unavailable': True}
|
|
|
|
|
|
def distribution(values):
|
|
if not values:
|
|
return {}
|
|
values = sorted(values)
|
|
return {'samples': len(values), 'median_ms': round(statistics.median(values)*1000, 3),
|
|
'p95_ms': round(values[min(len(values)-1, int(len(values)*.95))]*1000, 3),
|
|
'max_ms': round(values[-1]*1000, 3)}
|
|
|
|
|
|
class Client:
|
|
def __init__(self, number, user, reader, writer):
|
|
self.number, self.user, self.reader, self.writer = number, user, reader, writer
|
|
self.jid = f"{user}@localhost/load-{number}"
|
|
|
|
async def send(self, text):
|
|
self.writer.write(text.encode())
|
|
await self.writer.drain()
|
|
|
|
async def until(self, end):
|
|
return await asyncio.wait_for(self.reader.readuntil(end), 60)
|
|
|
|
@classmethod
|
|
async def connect(cls, number, port, context, accounts):
|
|
reader, writer = await asyncio.wait_for(asyncio.open_connection('127.0.0.1', port), 30)
|
|
user = "load" if accounts == 1 else f"load-{number % accounts}"
|
|
client = cls(number, user, reader, writer)
|
|
try:
|
|
await client.send(STREAM)
|
|
assert b'starttls' in await client.until(b'</stream:features>')
|
|
await client.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
|
assert b'proceed' in await client.until(b'/>')
|
|
await writer.start_tls(context, server_hostname='localhost', ssl_handshake_timeout=30)
|
|
await client.send(STREAM)
|
|
assert b'PLAIN' in await client.until(b'</stream:features>')
|
|
auth = base64.b64encode(f'\0{user}\0load-test-password'.encode()).decode()
|
|
await client.send(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{auth}</auth>")
|
|
assert b'success' in await client.until(b'/>')
|
|
await client.send(STREAM)
|
|
assert b'xmpp-bind' in await client.until(b'</stream:features>')
|
|
await client.send(f"<iq type='set' id='bind'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>load-{number}</resource></bind></iq>")
|
|
reply = await client.until(b'</iq>')
|
|
assert client.jid.encode() in reply, reply
|
|
return client
|
|
except BaseException:
|
|
writer.close()
|
|
raise
|
|
|
|
async def ping(self, round_number):
|
|
start = time.perf_counter()
|
|
await self.send(f"<iq type='get' id='ping-{round_number}' to='localhost'><ping xmlns='urn:xmpp:ping'/></iq>")
|
|
reply = ET.fromstring(await self.until(b'</iq>'))
|
|
assert reply.get('type') == 'result' and reply.get('id') == f'ping-{round_number}'
|
|
return time.perf_counter()-start
|
|
|
|
async def close(self):
|
|
try:
|
|
await self.send('</stream:stream>')
|
|
except (OSError, ssl.SSLError):
|
|
pass
|
|
self.writer.close()
|
|
try:
|
|
await asyncio.wait_for(self.writer.wait_closed(), 10)
|
|
except (OSError, ssl.SSLError, TimeoutError):
|
|
pass
|
|
|
|
|
|
async def run_load(count, accounts, concurrency, rounds, message_rounds, port, context, pid):
|
|
clients, failures, setup_times, samples = [], [], [], []
|
|
sampling = True
|
|
|
|
async def sample():
|
|
while sampling:
|
|
samples.append(process_metrics(pid))
|
|
await asyncio.sleep(.1)
|
|
|
|
sampler = asyncio.create_task(sample())
|
|
gate = asyncio.Semaphore(concurrency)
|
|
started = time.perf_counter()
|
|
|
|
async def connect(number):
|
|
async with gate:
|
|
start = time.perf_counter()
|
|
try:
|
|
client = await Client.connect(number, port, context, accounts)
|
|
clients.append(client)
|
|
setup_times.append(time.perf_counter()-start)
|
|
except Exception as error:
|
|
failures.append({'phase': 'connect', 'resource': number, 'error': repr(error)})
|
|
|
|
before = process_metrics(pid)
|
|
try:
|
|
await asyncio.gather(*(connect(i) for i in range(count)))
|
|
setup_seconds = time.perf_counter()-started
|
|
connected_metrics = process_metrics(pid)
|
|
ping_times = []
|
|
for round_number in range(rounds):
|
|
results = await asyncio.gather(*(c.ping(round_number) for c in clients), return_exceptions=True)
|
|
for client, result in zip(clients, results):
|
|
if isinstance(result, Exception):
|
|
failures.append({'phase': 'ping', 'resource': client.number, 'error': repr(result)})
|
|
else:
|
|
ping_times.append(result)
|
|
# Explicit full JIDs avoid presence fanout and deliver exactly one message per resource.
|
|
message_start = time.perf_counter()
|
|
clients.sort(key=lambda c: c.number)
|
|
delivered = 0
|
|
for batch in range(message_rounds):
|
|
for i, client in enumerate(clients):
|
|
target = clients[(i+1) % len(clients)]
|
|
await client.send(f"<message to='{target.jid}' type='chat' id='m-{batch}-{client.number}'><body>load-smoke-{client.number}</body></message>")
|
|
|
|
async def receive_message(index, client):
|
|
reply = ET.fromstring(await client.until(b'</message>'))
|
|
expected = clients[(index-1) % len(clients)]
|
|
assert reply.get('id') == f'm-{batch}-{expected.number}', ET.tostring(reply)
|
|
assert reply.get('from') == expected.jid, ET.tostring(reply)
|
|
assert reply.find('{jabber:client}body').text == f'load-smoke-{expected.number}'
|
|
|
|
results = await asyncio.gather(*(receive_message(i, c) for i, c in enumerate(clients)), return_exceptions=True)
|
|
for client, result in zip(clients, results):
|
|
if isinstance(result, Exception):
|
|
failures.append({'phase': 'message', 'batch': batch, 'resource': client.number, 'error': repr(result)})
|
|
else:
|
|
delivered += 1
|
|
message_seconds = time.perf_counter()-message_start
|
|
steady = process_metrics(pid)
|
|
await asyncio.gather(*(c.close() for c in clients))
|
|
await asyncio.sleep(2)
|
|
after = process_metrics(pid)
|
|
return {'total_load_wall_seconds': round(time.perf_counter()-started, 3), 'requested_resources': count, 'authenticated_bound_resources': len(clients),
|
|
'connection_setup_concurrency': concurrency, 'setup_wall_seconds': round(setup_seconds, 3),
|
|
'setup_latency': distribution(setup_times), 'ping_rounds': rounds, 'ping_round_trip': distribution(ping_times),
|
|
'accounts': accounts, 'message_rounds': message_rounds, 'messages_sent': len(clients)*message_rounds, 'messages_received': delivered, 'message_batch_seconds': round(message_seconds, 3),
|
|
'metrics_before': before, 'metrics_connected': connected_metrics, 'metrics_after_workload': steady,
|
|
'metrics_two_seconds_after_disconnect': after,
|
|
'sampled_peak_rss_kib': max((s['rss_kib'] for s in samples if 'rss_kib' in s), default=None),
|
|
'fd_delta_after_disconnect': after['fd_count']-before['fd_count'] if 'fd_count' in after and 'fd_count' in before else None,
|
|
'failures': failures,
|
|
'scope': f'One loopback host, {accounts} accounts with distinct resources, TLS+PLAIN, ping and directed messages; not a production benchmark or proof of no memory leaks.'}
|
|
finally:
|
|
sampling = False
|
|
await sampler
|
|
await asyncio.gather(*(c.close() for c in clients), return_exceptions=True)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--binary', default=str(pathlib.Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp'))
|
|
parser.add_argument('--clients', type=int, default=500)
|
|
parser.add_argument('--archive', action='store_true', help='enable persistent MAM and verify both account archives')
|
|
parser.add_argument('--accounts', type=int, default=1)
|
|
parser.add_argument('--message-rounds', type=int, default=1)
|
|
parser.add_argument('--concurrency', type=int, default=50)
|
|
parser.add_argument('--rounds', type=int, default=3)
|
|
args = parser.parse_args()
|
|
assert 1 <= args.clients <= 4000 and args.concurrency > 0 and args.rounds > 0 and 1 <= args.accounts <= args.clients and args.message_rounds > 0
|
|
with tempfile.TemporaryDirectory(prefix='rust-xmpp-load-smoke-') as directory:
|
|
work = pathlib.Path(directory)
|
|
cert, key = work/'cert.pem', work/'key.pem'
|
|
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(key), '-out', str(cert)], check=True, capture_output=True)
|
|
with socket.socket() as probe:
|
|
probe.bind(('127.0.0.1', 0))
|
|
port = probe.getsockname()[1]
|
|
config = {'global': {'data_path': str(work/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port],
|
|
's2s_ports': [], 'http_ports': [], 'https_ports': [], 'allow_registration': False,
|
|
'modules_enabled': ['tls', 'saslauth', 'disco', 'ping'],
|
|
'ssl': {'certificate': str(cert), 'key': str(key)}},
|
|
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}}}}
|
|
if args.archive:
|
|
config['global']['modules_enabled'].append('mam')
|
|
config['global']['default_archive_policy'] = True
|
|
config['global']['archive_expires_after'] = 'never'
|
|
path = work/'config.json'
|
|
path.write_text(json.dumps(config))
|
|
for account in range(args.accounts):
|
|
user = 'load' if args.accounts == 1 else f'load-{account}'
|
|
subprocess.run([args.binary, 'create-user', str(path), user, '--password-stdin'], input='load-test-password\n', text=True, capture_output=True, check=True)
|
|
with open(work/'server.log', 'w+') as log:
|
|
process = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log)
|
|
try:
|
|
for _ in range(100):
|
|
if process.poll() is not None:
|
|
raise RuntimeError('server exited during startup')
|
|
try:
|
|
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
|
break
|
|
except OSError:
|
|
time.sleep(.05)
|
|
else:
|
|
raise RuntimeError('startup timed out')
|
|
time.sleep(.2)
|
|
context = ssl.create_default_context(cafile=str(cert))
|
|
report = asyncio.run(run_load(args.clients, args.accounts, args.concurrency, args.rounds, args.message_rounds, port, context, proc_pid(process.pid)))
|
|
if args.archive:
|
|
archives = list((work/'data'/'localhost'/'archive').glob('*.list'))
|
|
records = sum(sum(1 for line in archive.open() if line.startswith('item(')) for archive in archives)
|
|
expected = report['messages_received'] * (1 if args.accounts == 1 else 2)
|
|
report['mam'] = {'archive_files': len(archives), 'records': records, 'expected_records': expected}
|
|
assert records == expected, report['mam']
|
|
print(json.dumps(report, indent=2))
|
|
if report['failures'] or report['authenticated_bound_resources'] != args.clients:
|
|
raise SystemExit(1)
|
|
except BaseException:
|
|
log.flush()
|
|
log.seek(0)
|
|
print('SERVER LOG:\n'+log.read())
|
|
raise
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|