Files
rust-xmpp/tests/bind2_archive_latency_network.py
T
wt e9329ccbb2
Test, build and publish / All tests and Linux binary (push) Failing after 23s
Test, build and publish / Container smoke test and Gitea publish (push) Skipped
- Исправлена задержка доставки MUC с растущим архивом: после каждого
сообщения
  больше не выполняется полный scan/rewrite архива для expiry. Очистка
  остаётся
  в maintenance и перед MAM-запросами; append и crash checkpoint до
  fanout сохранены.
- Неизменённые native config/persistent index не перезаписываются при
  каждом
  сообщении. Удалённые файлы восстанавливаются, ошибки экспорта
  повторяются.
- CSI больше не сохраняет SM snapshot при отсутствии изменения durable
  очереди.
  Изменённая очередь сохраняется до отправки; при ошибке записи очередь
  откатывается.
- Личные сообщения, IQ и входящая федерация к аккаунтам больше не
  захватывают
  общий MUC mutex, ожидая несвязанную работу с комнатными архивами.
- Добавлены регрессии native exports/archive scanning/CSI rollback и
  реальный
  TLS latency check в verify-local.sh, автоматически включённый и в CI.

Измерения и границы подтверждения:
[docs/MESSAGE-LATENCY.md](docs/MESSAGE-LATENCY.md).
Результаты итогового прогона:
[docs/VALIDATION-0.8.1.md](docs/VALIDATION-0.8.1.md).

- Личные сообщения и SM ACK больше не ждут сетевую отправку push.
  Уведомления
  отправляются через очередь1024 с максимум8параллельных маршрутов;
  worker
  и его задачи отменяются при завершении serve. Ошибки очереди/маршрута
  логируются.
- Offline сообщение сохраняется до публикации push, чтобы проснувшийся
  клиент
  мог сразу получить его. Подтверждена очередь при первом TLS stream
  push gateway.
- По умолчанию CSI считает важными bodyless OMEMO legacy/2, delivery
  receipts
  и chat markers. Явный список `csi_important_payloads` заменяет
  расширенные defaults.
  Низкоприоритетный headline по-прежнему буферизуется. Policy
  обновляется и при SM resume.
- Выбор адресатов и carbons фильтрует владельцев до захвата session
  mutex;
  зависшая сессия чужого аккаунта не задерживает этот путь. Duplicate SM
  ACK
  без изменения durable counters/queue не переписывает snapshot.
- Bind2 и MAM metadata IQ читают только первый/последний native record
  вместо
  сканирования всей истории. Индекс не требуется; recovery/лимиты записи
  сохранены.
- Добавлен `message-processing-slow`: обработка message дольше250мс,
  поля
  duration_ms/route, без payload/токенов. Это время обработки сервером,
  не peer receipt.
- Новые сетевые регрессии включены в verify-local.sh/GitHub CI.

Причины, изменение CSI policy, измерения и ограничения:
[docs/PERSONAL-MESSAGE-LATENCY.md](docs/PERSONAL-MESSAGE-LATENCY.md).
2026-10-21 11:20:09 +07:00

172 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Measure real TLS/SASL2 Bind2 reconnects with a large personal MAM history.
Usage: python3 tests/bind2_archive_latency_network.py --binary BINARY \
--records 100000 --output REPORT.json
Seeds valid native records before startup, then checks exact first/last metadata
on every fresh connection. Latency measurements have no performance threshold.
The executable is copied before use so concurrent rebuilds cannot replace it.
"""
import argparse
import datetime
import hashlib
import json
from pathlib import Path
import shutil
import socket
import statistics
import subprocess
import tempfile
import time
import xml.etree.ElementTree as ET
from sasl2_bind import BIND2, SASL2, Client
MAM = 'urn:xmpp:mam:2'
BASE_TIME = 1767225600
def stamp(seconds):
return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def distribution(values):
ordered = sorted(values)
return {'samples_ms': values, 'median_ms': statistics.median(values),
'p95_ms': ordered[min(len(ordered) - 1, int(len(ordered) * .95))],
'max_ms': ordered[-1]}
def exercise(binary, root, records, samples):
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
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)
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', 'sasl2', 'sasl2_bind2', 'mam', 'carbons', 'csi_simple', 'smacks'],
'default_archive_policy': True, 'archive_expires_after': 'never'},
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}))
subprocess.run([str(binary), 'create-user', str(config), 'alice', '--password-stdin'],
input='secret\n', text=True, check=True, capture_output=True)
archive = root/'data'/'localhost'/'archive'/'alice.list'
archive.parent.mkdir(parents=True, exist_ok=True)
with archive.open('w') as output:
for number in range(records):
output.write("item({name='message';attr={xmlns='jabber:client';type='chat';"
f"from='bob@localhost';stamp='{stamp(BASE_TIME + number)}'" + "};"
f"key='seed-{number}';when={BASE_TIME + number};with='bob@localhost';"
"{name='body';attr={};'seed personal history'}});\n")
expected = {'start': {'id': 'seed-0', 'timestamp': stamp(BASE_TIME)},
'end': {'id': f'seed-{records - 1}', 'timestamp': stamp(BASE_TIME + records - 1)}}
archive_bytes = archive.stat().st_size
index_before_start = archive.with_suffix('.lidx').exists()
assert not index_before_start, 'seed unexpectedly has a native index'
auth_ms, connect_ms, metadata_ms = [], [], []
metadata_samples, iq_metadata_samples = [], []
with (root/'server.log').open('w+') as log:
server = subprocess.Popen([str(binary), 'serve', str(config), '--experimental'], stdout=log, stderr=log)
try:
deadline = time.monotonic() + 30
while True:
assert server.poll() is None, 'server stopped before readiness'
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
break
except OSError:
assert time.monotonic() < deadline, 'server did not start'
time.sleep(.05)
for sample in range(samples):
# Each sample reconnects and authenticates the same account.
# Client verifies the advertised SASL2/Bind2 inline features.
connected = time.perf_counter()
client = Client(port, root/'cert.pem')
client.sock.settimeout(30)
try:
begun = time.perf_counter()
response = client.auth(f"<bind xmlns='{BIND2}'><tag>HistoryReconnect</tag></bind>")
arrived = time.perf_counter()
auth_ms.append((arrived - begun) * 1000)
connect_ms.append((arrived - connected) * 1000)
jid = response.find('{' + SASL2 + '}authorization-identifier')
assert jid is not None and jid.text.startswith('alice@localhost/HistoryReconnect/'), 'missing bound JID'
bound = response.find('{' + BIND2 + '}bound')
assert bound is not None, 'missing Bind2 bound result'
metadata = bound.find('{' + MAM + '}metadata')
assert metadata is not None, 'missing MAM metadata'
actual = {}
for edge in ('start', 'end'):
node = metadata.find('{' + MAM + '}' + edge)
assert node is not None, f'missing MAM {edge}'
actual[edge] = dict(node.attrib)
assert actual == expected, (actual, expected)
assert len(list(metadata)) == 2, 'unexpected MAM metadata children'
metadata_samples.append(actual)
# Session is immediately bound; the post-success features
# must not ask this client to perform legacy resource bind.
assert b'xmpp-bind' not in client.until(b'</stream:features>')
query_id = f'metadata-{sample}'
begun = time.perf_counter()
client.send(f"<iq type='get' id='{query_id}'><metadata xmlns='{MAM}'/></iq>")
iq = ET.fromstring(client.until(b'</iq>'))
metadata_ms.append((time.perf_counter() - begun) * 1000)
assert iq.get('type') == 'result' and iq.get('id') == query_id, 'metadata IQ failed'
iq_metadata = iq.find('{' + MAM + '}metadata')
assert iq_metadata is not None, 'metadata IQ payload missing'
iq_actual = {}
for edge in ('start', 'end'):
node = iq_metadata.find('{' + MAM + '}' + edge)
assert node is not None, f'missing metadata IQ {edge}'
iq_actual[edge] = dict(node.attrib)
assert iq_actual == expected, (iq_actual, expected)
assert len(list(iq_metadata)) == 2, 'unexpected metadata IQ children'
iq_metadata_samples.append(iq_actual)
finally:
client.close()
assert archive.stat().st_size == archive_bytes, 'reconnect modified seeded history'
finally:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
return {'status': 'pass', 'seeded_personal_archive_records': records, 'archive_bytes': archive_bytes,
'archive_expires_after': 'never', 'index_present_before_start': index_before_start,
'reconnect_samples': samples, 'expected_metadata': expected, 'observed_metadata': metadata_samples,
'mam_metadata_iq': distribution(metadata_ms), 'observed_iq_metadata': iq_metadata_samples,
'sasl2_bind2_success': distribution(auth_ms), 'tcp_starttls_to_bind2_success': distribution(connect_ms)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', required=True)
parser.add_argument('--records', type=int, default=100000)
parser.add_argument('--samples', type=int, default=10)
parser.add_argument('--output')
args = parser.parse_args()
assert args.records > 0 and args.samples > 0
source = Path(args.binary).resolve()
with tempfile.TemporaryDirectory(prefix='xmpp-bind2-archive-latency-') as directory:
root = Path(directory)
binary = root/'rust-xmpp'
shutil.copy2(source, binary)
report = {'binary_source': str(source), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(),
'scope': 'real loopback TCP/STARTTLS/SASL2 Bind2; isolated personal MAM fixture; no production latency claim',
'timing': 'raw ordered samples; auth timer starts before authenticate and ends at parsed success; full timer includes TCP/STARTTLS/features; metadata IQ timer runs from send to parsed result',
'result': exercise(binary, root, args.records, args.samples)}
text = json.dumps(report, indent=2) + '\n'
if args.output:
Path(args.output).write_text(text)
print(text, end='')
if __name__ == '__main__':
main()