Files
rust-xmpp/tests/mam_broken_baseline_network.py
T
wt 100005379b
Test, build and publish / All tests and Linux binary (push) Failing after 26s
Test, build and publish / Container smoke test and Gitea publish (push) Skipped
MAM archive storage fault must not drop live message delivery
Monal-iOS reconnect-looped with connection-closed reason=
protocol-or-transport-error on every message. The cause was a damaged
MAM archive redo baseline ('archive redo baseline changed in place'): the
native archive at data/<host>/archive/<user>.list no longer matched the
anchor recorded in its .rredo journal, so every archive_for_user() call
returned Err and the '?' propagated up through authenticated_transport,
turning a storage fault into a dropped session.

Delivery is an attribute of MAM, not a prerequisite. Make archival
best-effort at the three inbound/outbound message sites (sender,
recipient, incoming federation): on storage failure the original stanza
is still routed, both sessions stay alive, and a sanitized
'mam-archive-skipped' log is emitted instead of a connection-closed.

Offline storage-checkpoint now repairs the damaged journal: prepare_rewrite
detects an untrusted baseline (changed in place, shorter, missing, or
journal overflows bound), treats the durably synced native as authoritative
and retires the untrusted journal, so MAM history reads and future appends
work again after handoff. Live appends remain fail-closed (the journal is
preserved, never silently dropped).

Tests: redo_rewrite_tests::runtime_append_fails_closed... and
offline_checkpoint_retires_untrusted_redo_baseline; new network regression
mam_broken_baseline_network.py proves message delivery and both sessions
survive a broken baseline and that storage-checkpoint restores MAM reads.
2026-10-21 18:52:53 +07:00

339 lines
13 KiB
Python

#!/usr/bin/env python3
"""A damaged MAM redo baseline must not take down the live message session.
Usage: python3 tests/mam_broken_baseline_network.py target/release/rust-xmpp
Reproduces the production symptom reported with Monal: the native archive at
`data/<host>/archive/<user>.list` no longer matches the anchor recorded in its
`.rredo` journal ("archive redo baseline changed in place"). On every message the
previous build drained the session with `connection-closed reason=
protocol-or-transport-error`, so the client reconnect-looped and its message was
never delivered.
The archive store is a durable attribute of delivery, not a prerequisite. This
test seeds an archive for `load-0`, corrupts its redo baseline offline to force
exactly that recovery error, then verifies that:
* a fresh message is still routed load-0 -> load-1,
* BOTH sessions survive (ping round-trip), no `connection-closed`,
* the runtime stays fail-closed (the untrusted journal is NOT silently dropped),
* the offline `storage-checkpoint` repair retires the untrusted journal and
restores MAM history reads on the now-authoritative native file.
"""
import argparse
import hashlib
import json
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from _wire_client import Client, local
REDO_MAGIC = b"XARRED01"
REDO_HEADER = 96
BODY = "must-survive-broken-baseline"
def start(binary, config, port, log):
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=0.1):
return server
except OSError:
time.sleep(0.05)
server.kill()
server.wait()
raise AssertionError("server did not start")
def stop(server):
server.terminate()
try:
code = server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
raise AssertionError("SIGTERM did not complete orderly shutdown")
assert code == 0, f"SIGTERM shutdown exited {code}"
def corrupt_baseline(path):
"""Force the `archive redo baseline changed in place` recovery error.
Rebuilds a rotated-style journal header leaving the native file untouched:
the header records the native length as a non-empty baseline but stores a
wrong anchor. `recover_redo` then rejects it exactly as when an external
writer rewrote the native file in place after the journal rotated.
"""
native = path.read_bytes()
length = len(native)
assert length > 0, "seed produced an empty native MAM archive"
header = bytearray(REDO_HEADER)
header[:8] = REDO_MAGIC
header[8:16] = length.to_bytes(8, "little")
header[16:32] = b"\0" * 16 # dev/ino are only a cache identity, not validated
tail = native[-min(length, 4096) :]
real_anchor = hashlib.sha256(tail).digest()
wrong = bytearray(real_anchor)
wrong[0] ^= 0xFF
header[32:64] = wrong
header[64:96] = hashlib.sha256(header[:64]).digest()
path.with_suffix(".rredo").write_bytes(header)
assert hashlib.sha256(header[:64]).digest() == header[64:96], (
"rebuilt header checksum"
)
assert header[32:64] != real_anchor, "anchor did not diverge from native"
def run(binary):
with tempfile.TemporaryDirectory(prefix="xmpp-mam-broken-") as tmp:
root = Path(tmp)
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-days",
"1",
"-keyout",
str(root / "key.pem"),
"-out",
str(root / "cert.pem"),
"-subj",
"/CN=localhost",
"-addext",
"subjectAltName=DNS:localhost",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
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",
"ping",
"mam",
"carbons",
],
"smacks_hibernation_time": 600,
"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(
[
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:
server = None
try:
# Seed: leave a real, valid MAM entry for load-0 so its archive
# carries a genuine .list + .rredo pair with a non-zero baseline.
server = start(binary, config, port, log)
alice = Client(
port, root / "cert.pem", "load-0", "load-test-password", "desktop"
)
bob = Client(
port, root / "cert.pem", "load-1", "load-test-password", "desktop"
)
alice.available()
bob.available()
alice.send(
f"<message to='{bob.jid}' type='chat' id='seed'><body>seed</body></message>"
)
bob.body("seed")
alice.sync()
stop(server)
server = None
for client in (alice, bob):
client.close()
finally:
if server is not None and server.poll() is None:
server.kill()
server.wait()
# The sender localpart is percent-encoded on disk (`load-0` -> `load%2d0`).
sender_archive = root / "data" / "localhost" / "archive" / "load%2d0.list"
assert sender_archive.exists(), "seed did not create load-0 MAM archive"
assert sender_archive.with_suffix(".rredo").exists(), (
"seed did not attach load-0 redo journal"
)
corrupt_baseline(sender_archive)
damaged_journal = sender_archive.with_suffix(".rredo").read_bytes()
with (root / "server.log").open("a") as log:
server = None
try:
server = start(binary, config, port, log)
alice = Client(
port, root / "cert.pem", "load-0", "load-test-password", "desktop"
)
bob = Client(
port, root / "cert.pem", "load-1", "load-test-password", "desktop"
)
alice.available()
bob.available()
# A damaged MAM baseline must not drop the dispatch: deliver and
# keep both sessions alive.
alice.send(
f"<message to='{bob.jid}' type='chat' id='live'><body>{BODY}</body></message>"
)
bob.body(BODY)
# Ping round-trips prove neither session was closed by the archive fault.
alice.sync()
bob.sync()
alice.send(
f"<message to='{bob.jid}' type='chat' id='second'><body>second</body></message>"
)
bob.body("second")
alice.sync()
bob.sync()
finally:
if server is not None and server.poll() is None:
server.kill()
server.wait()
log_text = (root / "server.log").read_text()
skipped = [l for l in log_text.splitlines() if "mam-archive-skipped" in l]
assert skipped, (
"server did not log the best-effort archive skip (isolation inactive?)"
)
assert not any(
"connection-closed" in l and "protocol-or-transport-error" in l
for l in log_text.splitlines()
), "a session was closed by the archive fault"
assert not any("session-error-label" in l for l in log_text.splitlines()), (
"unhandled archive fault labelled a session error"
)
# Runtime must stay fail-closed: the untrusted journal is preserved.
assert sender_archive.with_suffix(".rredo").read_bytes() == damaged_journal, (
"runtime dropped the untrusted redo journal instead of failing closed"
)
# Offline handoff must repair: checkpoint retires the untrusted journal
# so the now-authoritative native file becomes a clean one-journal base.
result = subprocess.run(
[binary, "storage-checkpoint", str(config)],
capture_output=True,
text=True,
check=True,
)
assert "Durable native archive handoff:" in result.stdout
assert not sender_archive.with_suffix(".rredo").exists(), (
"storage-checkpoint did not retire the untrusted redo journal"
)
# After repair MAM history is readable again (endpoint reads now succeed).
phase3_offset = len((root / "server.log").read_text().splitlines())
with (root / "server.log").open("a") as log:
server = None
try:
server = start(binary, config, port, log)
alice = Client(
port, root / "cert.pem", "load-0", "load-test-password", "desktop"
)
bob = Client(
port, root / "cert.pem", "load-1", "load-test-password", "desktop"
)
alice.available()
bob.available()
# A MAM query to load-0's own archive must serve history.
# Query the bare account JID (not the full resource).
alice.send(
f"<iq type='set' id='mam' to='load-0@localhost'>"
f"<query xmlns='urn:xmpp:mam:2'/>"
f"<x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'>"
f"<value>urn:xmpp:mam:2</value></field></x></iq>"
)
result_iq = alice.iq("mam")
assert (
result_iq.find(
".//{urn:ietf:params:xml:ns:xmpp-stanzas}internal-server-error"
)
is None
), ET.tostring(result_iq)
# After reconnect the sender archive is fresh (journal was retired);
# a new message archives cleanly.
alice.send(
f"<message to='{bob.jid}' type='chat' id='post-repair'><body>post-repair</body></message>"
)
bob.body("post-repair")
alice.sync()
finally:
if server is not None and server.poll() is None:
server.kill()
server.wait()
post_log = (root / "server.log").read_text().splitlines()[phase3_offset:]
fresh_errors = [
l for l in post_log if "archive redo baseline changed in place" in l
]
assert not fresh_errors, (
"archive fault returned after offline repair:\n" + "\n".join(fresh_errors)
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", default="target/release/rust-xmpp")
args = parser.parse_args()
run(args.binary)
print("PASS: damaged MAM redo baseline does not drop live message delivery")
if __name__ == "__main__":
main()