#!/usr/bin/env python3 """Fixture-only slow durable IO: two accounts, four TLS/SM resources, MAM/PEP. Builds only a tiny C LD_PRELOAD interposer; uses an isolated copy of --binary. Every intercepted fsync/fdatasync still executes. Directory syncs under the fixture data root are delayed too and counted separately. No mount changes. Timing includes durable work, with client ACKs withheld during each sample. """ import argparse import asyncio import collections import hashlib import json import os import pathlib import re import shutil import socket import ssl import subprocess import sys import tempfile import time import xml.etree.ElementTree as ET from muc_sm_load_network import Client, CLIENT, SM from muc_restart_network import stop from message_latency_network import slot CARBONS = 'urn:xmpp:carbons:2' PUBSUB = 'http://jabber.org/protocol/pubsub' PHASE_FIELDS = ('pre_routing_ms', 'sender_mam_ms', 'recipient_mam_ms', 'delivery_ms', 'carbons_ms', 'offline_push_ms', 'handled_ms') SLOW_FIELDS = {'duration_ms', 'route', 'target_count', 'carbon_count', *PHASE_FIELDS} def slow_events(path, delay, rounds, allow_legacy=False): events = [] for line in path.read_text().splitlines(): if 'event="message-processing-slow"' not in line: continue fields = dict(re.findall(r'(?:^|\s)"([^"\s]+)"="([^"\n]*)"', line)) legacy = allow_legacy and set(fields) == {'duration_ms', 'route'} assert legacy or set(fields) == SLOW_FIELDS, ('unexpected slow-event fields', sorted(fields)) assert not any(value in line for value in ('slow fixture ', 'body-settled-', 'body-avatar_pending-', '= 250 and event['route'] == 'local', event if not legacy: assert all(event[key] >= 0 for key in PHASE_FIELDS), event assert sum(event[key] for key in PHASE_FIELDS) <= event['duration_ms']+len(PHASE_FIELDS), event assert event['target_count'] == 1 and event['carbon_count'] == 2, event events.append(event) # At >=100ms the retained durable commits alone exceed the 250ms threshold. # Smaller optional delays may legitimately produce no warning. if delay >= 100: assert len(events) == 2*rounds, ('missing/extra slow diagnostics',len(events),2*rounds) return events class WireReader: def __init__(self, reader, owner): self.reader, self.owner = reader, owner self.parser = ET.XMLPullParser(events=('start', 'end')) self.parser.feed("") self.root = next(self.parser.read_events())[1] self.depth = 0 def __getattr__(self, name): return getattr(self.reader, name) async def read(self, size): data = await self.reader.read(size) self.parser.feed(data) for event, node in self.parser.read_events(): self.depth += 1 if event == 'start' else -1 if event != 'end' or self.depth: continue now = time.monotonic_ns() if node.tag == f'{{{CLIENT}}}message': for message in node.iter(f'{{{CLIENT}}}message'): if message.get('id'): kind = 'direct' for carbon in ('sent', 'received'): if node.find(f'{{{CARBONS}}}{carbon}') is not None: kind = carbon self.owner.arrivals[(message.get('id'), kind)].append(now) if node.tag == f'{{{SM}}}a': self.owner.ack_times[int(node.get('h'))] = now self.root.remove(node) return data class MeasuredClient(Client): def __init__(self, *args): super().__init__(*args) self.arrivals = collections.defaultdict(list) self.ack_times = {} self.reader = WireReader(self.reader, self) async def iq(client, identifier, payload): await client.stanza(f"{payload}") result = await client.wait(lambda: client.iqs.pop(identifier, None), identifier) assert result.get('type') == 'result', ET.tostring(result) def sync_rows(path): return [json.loads(row) for row in path.read_text().splitlines()] if path.exists() else [] def counts(rows): result = collections.Counter(f"{row['store']}:{row['kind']}:{row['op']}" for row in rows) assert all(row['result'] == 0 for row in rows), 'real sync failed' return {'total': len(rows), 'by_store_kind_op': dict(sorted(result.items()))} async def exercise(root, port, args, log): context = ssl.create_default_context(cafile=str(root/'cert.pem')) clients = [] report = [] try: for number in range(4): client = await MeasuredClient.connect(number, port, context, 2) clients.append(client) client.auto_ack = False await client.enable() await iq(client, f'carbons-{number}', f"") await client.stanza('') await client.fence(f'ready-{number}') alice, bob, alice_other, bob_other = clients avatar = 'urn:xmpp:avatar:metadata' # Self-account subscriptions make avatar events reach sibling resources. await iq(alice, 'avatar-initial', f"") for client in (alice, alice_other): await iq(client, f'subscribe-{client.number}', f"") for phase in ('settled', 'avatar_pending'): for turn in range(args.rounds): # Fence all previous ACK work before taking operation counts. for client in clients: await client.ack() await client.fence(f'before-{phase}-{turn}-{client.number}') await client.handled() before = len(sync_rows(log)) start_ns = time.monotonic_ns() if phase == 'avatar_pending': # Publication remains ahead of the body on this stream; no # client ACK is sent for its IQ result or PEP notifications. await alice.stanza(f"") identifier = f'body-{phase}-{turn}' begun = time.monotonic_ns() await alice.stanza(f"slow fixture {identifier}") await alice.handled() events = [] for client, kind in ((bob, 'direct'), (alice_other, 'sent'), (bob_other, 'received')): await client.wait(lambda c=client,k=kind: c.arrivals[(identifier,k)], identifier+' '+kind) arrivals = client.arrivals[(identifier, kind)] assert len(arrivals) == 1, (identifier, kind, arrivals) events.append((kind, (arrivals[0]-begun)/1e6)) end_ns = time.monotonic_ns() rows = sync_rows(log)[before:] assert rows, 'interposer recorded no data-root sync operations' assert all(row['end_ns']-row['begin_ns'] >= args.current_delay_ms*1000000 for row in rows), 'configured sync delay was not applied' snapshots = [slot(root,c) for c in clients] for index in (1,2,3): assert sum(identifier in xml for xml in snapshots[index]['pending']) == 1, (identifier,index) if phase == 'avatar_pending': assert all(any(avatar in xml and 'event' in xml for xml in snapshots[index]['pending']) for index in (0,2)), 'avatar PEP notifications were not durably pending' report.append({'phase': phase, 'round': turn, 'delivery_ms': dict(events), 'sender_sm_handled_ms': (alice.ack_times[alice.inputs]-begun)/1e6, 'publication_plus_delivery_ms': (end_ns-start_ns)/1e6, 'sync': counts(rows), 'pending_per_resource': [len(snapshot['pending']) for snapshot in snapshots]}) assert all(not client.errors for client in clients), [c.errors for c in clients] # Both ends archive each personal body exactly once, despite carbons. archive_counts = [sum(line.startswith('item(') for line in path.open()) for path in sorted((root/'data'/'localhost'/'archive').glob('*.list'))] expected = args.history_records + 2*args.rounds assert archive_counts == [expected, expected], (archive_counts, expected) return {'samples': report, 'mam_records_per_account': archive_counts, 'status': 'pass'} finally: await asyncio.gather(*(c.close_raw() for c in clients), return_exceptions=True) def check_shim_scope(work, shim): """Prove that both sync APIs preserve success and exclude non-data files.""" root = work/'shim-scope-data' root.mkdir() log = work/'shim-scope.jsonl' code = """import os,pathlib,sys root,outside=map(pathlib.Path,sys.argv[1:]) for path in (root/'inside',outside): with path.open('wb') as stream: stream.write(b'fixture') stream.flush() os.fsync(stream.fileno()) os.fdatasync(stream.fileno()) fd=os.open(root,os.O_RDONLY) os.fsync(fd) os.close(fd) """ env = dict(os.environ, LD_PRELOAD=str(shim), XMPP_SLOW_SYNC_ROOT=str(root), XMPP_SLOW_SYNC_LOG=str(log), XMPP_SLOW_SYNC_MS='0') subprocess.run([sys.executable,'-c',code,str(root),str(work/'outside')], env=env,check=True,capture_output=True) rows = sync_rows(log) assert len(rows) == 3, rows assert collections.Counter((r['op'],r['kind']) for r in rows) == { ('fsync','file'):1, ('fdatasync','file'):1, ('fsync','directory'):1} assert all(row['result'] == 0 for row in rows) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--binary', default='target/release/rust-xmpp') parser.add_argument('--delay-ms', type=int, nargs='+', default=[0, 100]) parser.add_argument('--rounds', type=int, default=3) parser.add_argument('--history-records', type=int, default=100) parser.add_argument('--output') parser.add_argument('--allow-legacy-phase-logs', action='store_true', help='baseline binaries only: permit old duration/route diagnostics without phase metrics') args = parser.parse_args() assert args.rounds > 0 and args.history_records >= 0 assert all(0 <= delay <= 10000 for delay in args.delay_ms) with tempfile.TemporaryDirectory(prefix='xmpp-slow-storage-') as directory: work = pathlib.Path(directory) binary = work/'rust-xmpp' shutil.copy2(pathlib.Path(args.binary).resolve(), binary) shim = work/'slow_sync.so' subprocess.run(['cc', '-shared', '-fPIC', '-O2', '-Wall', '-Wextra', '-Werror', str(pathlib.Path(__file__).parent/'fixtures'/'slow_sync.c'), '-o', str(shim), '-ldl'], check=True) check_shim_scope(work, shim) report = {'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'scope': 'loopback STARTTLS; four SM resources/two accounts; MAM + pending avatar metadata PEP; fixture-only fsync/fdatasync including data directories; real sync retained; operation windows include background SM heartbeat', 'cases': []} for delay in args.delay_ms: root = work/f'delay-{delay}' root.mkdir() subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost', '-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] config = root/'config.json' config.write_text(json.dumps({'global': {'data_path': str(root/'data'), 'interfaces':['127.0.0.1'], 'c2s_ports':[port], 's2s_ports':[], 'http_ports':[], 'https_ports':[], 'modules_enabled':['tls','saslauth','smacks','csi_simple','ping','mam','pep','carbons'], 'archive_expires_after':'never', 'default_archive_policy':True, 'ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}}, 'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256'}}}})) for number in range(2): subprocess.run([str(binary),'create-user',str(config),f'load-{number}','--password-stdin'], input='load-test-password\n',text=True,check=True,capture_output=True) archive = root/'data'/'localhost'/'archive'/f'load%2d{number}.list' archive.parent.mkdir(parents=True,exist_ok=True) with archive.open('w') as stream: for index in range(args.history_records): stream.write("item({name='message';attr={xmlns='jabber:client';type='chat'};" f"key='seed-{index}';when={time.time()};with='load-{1-number}@localhost';" "{name='body';attr={};'history'}});\n") sync_log = root/'sync.jsonl' env = dict(os.environ, LD_PRELOAD=str(shim), XMPP_SLOW_SYNC_ROOT=str(root/'data'), XMPP_SLOW_SYNC_LOG=str(sync_log), XMPP_SLOW_SYNC_MS=str(delay)) with (root/'server.log').open('w+') as log: server = subprocess.Popen([str(binary),'serve',str(config),'--experimental'],env=env,stdout=log,stderr=log) try: deadline = time.monotonic()+60 while True: assert server.poll() is None, log.read() try: with socket.create_connection(('127.0.0.1',port),timeout=.1): break except OSError: assert time.monotonic()