diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-14 02:29:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-14 02:29:13 +0200 |
| commit | a1aaf31a27d1c1b65efc3c6a25fc6cc8771578ea (patch) | |
| tree | e6cd48a0385221194bf0f3290576bdfe437b4841 /packages/meshbay-hub/tests | |
| parent | 392b5e4a53aace725794c7bbabf9e95fb4e1b9c5 (diff) | |
| download | meshbay-a1aaf31a27d1c1b65efc3c6a25fc6cc8771578ea.tar.gz | |
fix(hub): Argon2 runs off the event loop, on exactly one worker
One derivation is 256 MB and a quarter to half a second of CPU (240 ms here,
485 ms on meshbay.org). All eleven call sites — sign-in, registration, the two
rehashes, passphrase change, reset and account deletion — ran it inline in an
async handler, so every one stopped the whole hub for that long: no request
served, no node socket read, no offer relayed. Measured on a local hub during
eight concurrent sign-ins, the worst `/v1/health` response went from 232 ms to
10 ms; the sign-ins themselves take the same time.
It could not simply go to a thread pool. Two concurrent `lanes=4` derivations
deadlock inside OpenSSL and never return, at no CPU — reproduced on
cryptography 50.0.x / OpenSSL 4.0.x both locally and on meshbay.org, while
`lanes=1` does not. `lanes` is part of every stored hash, so it is not ours to
change, and inline on the loop two derivations could never overlap, which is
the only reason production never hung.
So `auth.hash_password_off_loop` / `verify_password_off_loop` hand the work to
a dedicated executor with exactly one worker. Not a semaphore around
`to_thread`: a cancelled request would release its permit while its thread was
still deriving, and the next derivation would start beside it. One worker also
bounds Argon2's memory to one derivation whatever the number of callers.
`test_argon2_off_loop.py` reads every module for a direct call, pins the single
worker, runs four derivations and four sign-ins concurrently to completion, and
checks the loop keeps turning during a derivation; each fails with its guard
removed. CLAUDE.md and AV9 state the rule and the trap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_argon2_off_loop.py | 91 |
1 files changed, 91 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_argon2_off_loop.py b/packages/meshbay-hub/tests/test_argon2_off_loop.py new file mode 100644 index 0000000..b156ebc --- /dev/null +++ b/packages/meshbay-hub/tests/test_argon2_off_loop.py @@ -0,0 +1,91 @@ +""" +Argon2 runs off the event loop, and never two at a time. + +One derivation is 256 MB and a quarter to half a second of CPU. On the loop it +stopped the whole hub for that long at every sign-in. In a thread pool it would +have been worse: two concurrent `lanes=4` derivations deadlock inside OpenSSL +and never return (`auth._argon2_executor`). These pin both halves. +""" + +import asyncio +import base64 +import pathlib +import re +import time + +import pytest +from meshbay_hub import auth + +SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" + + +def test_nothing_derives_argon2_on_the_event_loop(): + """Every line of every module, not the first match: a call added above an + existing one must not hide behind it.""" + direct = re.compile(r"(?<![\w.])(hash_password|verify_password)\s*\(") + offenders = [] + for path in SRC.rglob("*.py"): + if path.name == "auth.py": + continue + for n, line in enumerate(path.read_text().splitlines(), 1): + if direct.search(line): + offenders.append(f"{path.relative_to(SRC)}:{n}: {line.strip()}") + assert not offenders, ( + "these derive Argon2 on the calling thread; use the *_off_loop versions:\n" + + "\n".join(offenders)) + + +def test_the_executor_has_exactly_one_worker(): + """More than one lets two `lanes=4` derivations overlap, and they deadlock.""" + assert auth._argon2_executor._max_workers == 1 + + +@pytest.mark.asyncio +async def test_concurrent_derivations_all_return(): + pw_hash, salt = auth.hash_password("k" * 44) + results = await asyncio.wait_for(asyncio.gather(*[ + auth.verify_password_off_loop("k" * 44, pw_hash, salt, auth.current_pw_version()) + for _ in range(4)]), timeout=30) + assert results == [True] * 4 + + +@pytest.mark.asyncio +async def test_the_loop_keeps_turning_while_argon2_runs(): + pw_hash, salt = auth.hash_password("k" * 44) + started = time.perf_counter() + auth.verify_password("k" * 44, pw_hash, salt, auth.current_pw_version()) + inline = time.perf_counter() - started + + gaps, done = [], asyncio.Event() + + async def ticker(): + last = time.perf_counter() + while not done.is_set(): + await asyncio.sleep(0.005) + now = time.perf_counter() + gaps.append(now - last) + last = now + + task = asyncio.create_task(ticker()) + await asyncio.sleep(0.02) + assert await auth.verify_password_off_loop( + "k" * 44, pw_hash, salt, auth.current_pw_version()) + done.set() + await task + # Inline, the loop stalls for the whole derivation; off it, for scheduling noise. + assert max(gaps) < inline / 2, (max(gaps), inline) + + +@pytest.mark.asyncio +async def test_concurrent_sign_ins_all_complete(client): + key = base64.b64encode(b"k" * 32).decode() + names = [f"concurrent{i}" for i in range(4)] + for name in names: + r = await client.post("/v1/users/register", json={ + "username": name, "email": f"{name}@example.test", "auth_key": key}) + assert r.status_code == 201, r.text + + responses = await asyncio.wait_for(asyncio.gather(*[ + client.post("/v1/users/login", json={"username": n, "auth_key": key}) + for n in names]), timeout=60) + assert [r.status_code for r in responses] == [200] * 4 |