#!/usr/bin/env python3
"""Deferred online SM durability mode over real STARTTLS.
`smacks_online_durable = false` defers per-stanza durable commits for live
sessions (Prosody's streaming model), flushing on offline transitions and a
periodic background pass. This fixture proves:
- the server runs correctly in deferred mode (message, MAM and carbons work),
- a message delivered to an OFFLINE/hibernated resource is durably committed
before the sender's stream-management acknowledgement (no loss on crash),
- an ONLINE resource's deferred queue is made durable by the background flush
(waiting for the flush interval then re-reading the durable snapshot).
This is a loopback wire check, not a production-capacity guarantee.
"""
import argparse
import asyncio
import json
import pathlib
import socket
import ssl
import subprocess
import sys
import tempfile
import time
from message_latency_network import slot
from slow_storage_latency_network import MeasuredClient
CARBONS = "urn:xmpp:carbons:2"
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"
async def exercise(root, port, args):
context = ssl.create_default_context(cafile=str(root / "cert.pem"))
clients = []
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
for client in clients:
await client.handled()
print("setup+settle ok", file=sys.stderr, flush=True)
# ---- Offline delivery must be durable before sender ACK ----
# Detach bob_other ungracefully: it stays a resumable slot with no
# transport (sender == None), so its writes still commit durably even in
# deferred mode.
await bob_other.close_raw()
await asyncio.sleep(0.5)
print("bob_other detached", file=sys.stderr, flush=True)
await alice.stanza(
f"offline-durable"
)
await alice.handled()
print("offline message sent+handled", file=sys.stderr, flush=True)
# The offline target's queue must already be in the durable JSON
# (offline transitions commit durably regardless of deferred mode).
offline = slot(root, bob_other)
assert any("offline-durable" in xml for xml in offline["pending"]), offline[
"pending"
]
# ---- Online delivery reaches the live recipient and its sent carbon ----
identifier = "online-deferred"
await alice.stanza(
f"{identifier}"
)
await alice.handled()
print(
"online message sent+handled; waiting for live delivery",
file=sys.stderr,
flush=True,
)
for client, kind in ((bob, "direct"), (alice_other, "sent")):
await client.wait(
lambda c=client, k=kind: c.arrivals.get((identifier, k)),
f"{identifier} {kind}",
)
print(
f"arrived {kind} at client {client.number}", file=sys.stderr, flush=True
)
print(
"online delivery ok; waiting for flush to persist",
file=sys.stderr,
flush=True,
)
# Wait for the 1s background flush; the live deferred queues must then be
# durable on disk for bob (direct) and alice_other (sent carbon).
await asyncio.sleep(1.5)
ok = any(
"online-deferred" in xml
for client in (bob, alice_other)
for xml in slot(root, client)["pending"]
)
assert ok, (slot(root, bob)["pending"], slot(root, alice_other)["pending"])
assert all(not client.errors for client in clients), [c.errors for c in clients]
return "pass"
finally:
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")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="deferred-sm-") as directory:
work = pathlib.Path(directory)
binary = pathlib.Path(args.binary).resolve()
root = work / "data-root"
root.mkdir()
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-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,
)
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",
"mam",
"pep",
"carbons",
],
"smacks_online_durable": False,
"smacks_flush_interval": 1,
"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,
)
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() + 60
while True:
assert server.poll() is None, log.read()
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
break
except OSError:
assert time.monotonic() < deadline, "server startup timeout"
time.sleep(0.05)
status = asyncio.run(asyncio.wait_for(exercise(root, port, args), 50))
print(f"deferred online SM: {status}")
except BaseException:
log.flush()
log.seek(0)
print(log.read(), file=sys.stderr)
raise
finally:
server.kill()
server.wait()
if __name__ == "__main__":
main()