Skip to content

A timing test that stops measuring the machine, and a relay boot that stops adopting a stranger - #394

Merged
Apolloccrypt merged 2 commits into
mainfrom
fix/login-timing-robust
Sep 3, 2026
Merged

A timing test that stops measuring the machine, and a relay boot that stops adopting a stranger#394
Apolloccrypt merged 2 commits into
mainfrom
fix/login-timing-robust

Conversation

@Apolloccrypt

Copy link
Copy Markdown
Owner

Two tests that block the deploy gate when the machine under them is busy, and neither
of them was measuring what it claimed to measure.

1. admin/test/login-timing.test.js: the range assertion was an extreme-value statistic

Since #379 this suite runs in two jobs. A parallel PR run saw it red at the twelve-failure
level: exists p50 752.29 ms against absent p50 751.44 ms, a difference of 0.85 ms, with the
two [min, max] ranges missing each other by a hair. Green on the rerun.

The medians were never the problem. The range overlap was. [min, max] is decided by the
single slowest and single fastest request in each group, and at eight samples those are two
draws from a tail.

How bad. 40 real measurements per case per level on a loaded machine, resampled 20 000
times per cell:

assertion samples false red, per level catches a real 5 ms offset
min < max overlap (old) 8 6.9% - 8.9% 67% - 94%
min < max overlap 24 0.00% - 0.04% 20% - 67%
IQR overlap 24 0.10% - 0.24% 100%
p10..p90 overlap (new) 24 0.000% 94% - 100%

Roughly one run in five over three levels is what the old assertion cost. The interquartile
band the brief suggested is stricter still, but it is too strict: with a 0.85 ms apparent
offset injected, the difference that turned the run above red, IQR overlap goes red 15.5% of
the time at the bottom level. The central 80% band is the one that holds: 0.000% at a true
offset, at most 0.075% at 0.85 ms, and it still catches 5 ms almost always and 20 ms always.

What the suite asserts now, per level, on both routes:

  1. both medians are held to the floor the answer is designed to take (unchanged);
  2. |p50(exists) - p50(absent)| < max(2 ms, 1% of that floor), so 2.5 ms at a 250 ms floor
    (four times tighter than the flat 10 ms it replaces) and 22.5 ms at 2250 ms;
  3. the central 80% of the two groups overlap: p10..p90, not min..max;
  4. 24 samples per case per level instead of 8 (and 24 instead of 6 on the backup route);
  5. one repeat of the level before any of it is allowed to fail. A machine that was briefly
    busy costs a rerun; a machine that is leaking the answer fails both rounds, and the
    discarded round's numbers go into the message.

This is a stricter detector than the one it replaces on every axis measured, not a looser one.

Measurements, on a machine under a parallel CPU hog

/api/user/login, stub relay, 16-way hog

level floor case n p50 (ms) p10..p90 min..max
0 250 ms exists 40 252.03 250.96 .. 253.64 250.37 .. 254.47
0 250 ms absent 40 251.86 250.61 .. 253.10 250.35 .. 255.57
verdict dp50 0.17 ms (tolerance 2.50) bands overlap: yes
12 750 ms exists 40 754.23 752.82 .. 757.07 752.27 .. 758.76
12 750 ms absent 40 754.37 753.22 .. 757.13 752.65 .. 758.42
verdict dp50 0.13 ms (tolerance 7.50) bands overlap: yes
20 2250 ms exists 40 2254.34 2253.31 .. 2256.09 2252.64 .. 2262.53
20 2250 ms absent 40 2254.14 2252.80 .. 2257.12 2252.56 .. 2259.39
verdict dp50 0.20 ms (tolerance 22.50) bands overlap: yes

/api/user/login-with-backup, real relay and real argon2, 8-way hog

level floor case n p50 (ms) p10..p90 min..max
0 1500 ms exists 24 1502.58 1501.88 .. 1503.50 1501.48 .. 1504.34
0 1500 ms absent 24 1502.57 1501.98 .. 1503.71 1501.11 .. 1504.34
verdict dp50 0.01 ms (tolerance 15.00) bands overlap: yes
4 2250 ms exists 24 2252.56 2251.72 .. 2253.34 2251.56 .. 2253.53
4 2250 ms absent 24 2253.06 2251.93 .. 2253.75 2251.85 .. 2254.14
verdict dp50 0.51 ms (tolerance 22.50) bands overlap: yes

Ten runs under load, green

run result checks wall clock
1 GREEN 4/4 tests, 0 failed 406.5 s
2 GREEN 4/4 tests, 0 failed 408.6 s
3 GREEN 4/4 tests, 0 failed 407.9 s
4 GREEN 4/4 tests, 0 failed 408.0 s
5 GREEN 4/4 tests, 0 failed 400.8 s
6 GREEN 4/4 tests, 0 failed 409.7 s
7 GREEN 4/4 tests, 0 failed 408.4 s
8 GREEN 4/4 tests, 0 failed 403.3 s
9 GREEN 4/4 tests, 0 failed 405.8 s
10 GREEN 4/4 tests, 0 failed 411.9 s

node --test admin/test/login-timing.test.js against a real redis, with eight busy-loop workers pinned alongside on a sixteen-core machine. 10 of 10 green, 19 checks each.

Sabotage, hard red, twice each

The suite has to keep catching the thing it exists for. Three sabotages of admin/server.js,
each run twice:

sabotage of admin/server.js what it undoes run 1 run 2
full the 250 ms floor removed, the throttle no longer mirrored into it, throttled_upstream dropped so the relay only charges an address that exists RED, 2 tests RED, 2 tests
oracle floor kept, only the mirror and throttled_upstream dropped RED, 2 tests RED, 2 tests
backup the 1500 ms floor removed on the backup route, throttled_upstream dropped RED, 1 test RED, 1 test

The numbers the suite printed, from the failing rounds:

  • full, level 0: exists p50 8.28 [7.24, 8.81], absent p50 3.76 [3.30, 4.42]. Floor gone.
  • oracle, level 12: exists p50 510.60 [509.32, 513.32], absent p50 251.98 [251.25, 253.00].
    A 258.62 ms gap against a 7.50 ms tolerance, and the two bands are disjoint by 256 ms. Both
    the median test and the band test are red on those numbers; the floor test is simply the
    first assertion to fire.
  • backup, level 0: exists p50 2532.65 [2511.11, 2547.86], absent p50 3.65 [3.32, 4.05].
    A 2.5 second oracle.

Each sabotage failed on both the first round and the repeat, so the one retry does not soften them.

Cost

The suite goes from about 1.9 minutes to about 6.8 minutes: 24 samples instead of 8 and 6,
and at twenty prior failures every single answer is held for 2.25 s by design. The admin unit
job pays about half of that (its backup case is declared skipped there, ADMIN_TEST_SKIP=relay);
the crypto job pays all of it. That is the price of a timing test that does not have to be
rerun.

2. relay/test/_relay-server.js: boot() adopted whatever answered on the port

Run 33713795533 on main (d3c126d, a screenshot-suite change) went red in the crypto job on
route-billing-entitlements.test.js:57, "#315: a paid period on disk is still bounded after a
restart": after the restart the admin endpoint answered
401 {"error":"ADMIN_TOKEN required for admin endpoints"} instead of 200. It reads like a
restart bug. It is not.

freePort() asks the OS for a free port, closes the listener and hands the number on. Between
that close and the child's own listen() the port belongs to nobody. The route suites run as
node --test test/route-*.test.js, which runs the files in parallel processes, and every one
of them boots relays through this function.

The old wait loop then made the collision invisible: it probed GET /health on the port from
its very first iteration, long before its own child could be listening, and took any 200 as
"we are up". When another suite's relay held that port, that relay answered, boot() returned
a handle pointing at it, and the caller's own child died of EADDRINUSE unnoticed. The test then
talked to a stranger with a different ADMIN_TOKEN, which is exactly the 401 above.

The fix, both halves:

  1. the child's own stdout must carry relay_started for this port before anything is probed.
    Only the process that owns the socket logs that line, so it is proof of ownership rather
    than a guess about timing;
  2. when the caller set an ADMIN_TOKEN, one authenticated admin request must not come back
    401 before the handle is returned. Same proof from the other end, and it is the exact
    symptom the suite hit.

restart() also stopped sleeping 150 ms at the old process and now awaits its exit. relay.js
zeroizes its blobs, flushes the CT/STH queues and writes users.json on SIGTERM, and the next
boot reads that same users.json; 150 ms was a guess at how long that takes.

Deterministic reproduction

A foreign relay with a different ADMIN_TOKEN is put on the port boot() is then handed:

harness outcome
main boot() returned a handle on the foreign relay's port; the admin request answered 401 {"error":"ADMIN_TOKEN required for admin endpoints"}. The CI failure, exactly.
this branch boot() refused the foreign relay, retried on a fresh port, admin request 200 with the entitlements.
this branch minus the relay_started wait boot() threw: "the relay answering on port N does not share this suite's ADMIN_TOKEN". Loud, but no recovery.

Removing only the wait step from the fixed version leaves the authenticated probe, which still
refuses, so the failure is loud instead of wrong. It is the wait step that lets the boot
recover.

Ten runs of the whole parallel route job, green

node --test relay/test/route-*.test.js relay/test/parasign-signs-quota.test.js, the crypto job's
own command line, ten times with an 8-way CPU hog alongside: 10 of 10 green, 91 tests each,
15.3 s to 21.9 s per run. route-billing-entitlements.test.js on its own: 16 of 16.

Gates

gate result
scripts/check-test-declarations.sh OK, 133 suites parse, one declaration per name
tests/static-sanity.sh PASS, all hard checks clear
npx eslint@9 . clean
admin unit suite (ADMIN_TEST_SKIP=relay) 67/67
relay route suites + parasign-signs-quota 91/91
admin/test/login-timing.test.js full, with the real relay 19 checks, green

Not fixed here, but measured

Under a hog of twelve or more workers on this sixteen-core machine, ten argon2id verifications
at 64 MiB take longer than the 1500 ms floor on /api/user/login-with-backup, the admin logs
answer overran its floor and the suite fails on that, with the right diagnostic
(raise PARAMANT_LOGIN_BACKUP_MIN_ANSWER_MS). That is the product's design envelope being
exceeded, not a wobbly statistic, and the suite is right to say so. Measured: at a 12-way hog,
exists p50 2127.41 ms against absent p50 1504.18 ms with 23 overruns logged. The loaded runs
above use an 8-way hog, which keeps the machine inside that envelope. A GitHub runner has four
vCPUs and runs nothing else, so this is not what CI sees, but it is worth writing down.

relay/test/route-redis-outage.test.js fails the same way under a 16-way hog (its redis
deadline is exceeded, 503 redis_unavailable); untouched here.

Apolloccrypt and others added 2 commits September 3, 2026 08:38
login-timing: the [min, max] range overlap is an extreme-value statistic and
at eight samples it went falsely red 6.9% to 8.9% per level, which is what a
0.85 ms difference on a pull-request run turned into. Replaced by 24 samples,
a median tolerance of max(2 ms, 1% of the designed answer time), an overlap of
the central 80% of each group, and one repeat of a level before it may fail.
Measured false-red 0.000% per level, and it still catches a real 5 ms offset
94% to 100% of the time where the old assertion caught it 67% to 94%.

_relay-server: boot() probed /health on a port from its first iteration and
took any 200 as its own child, so a port collision between the parallel route
suites handed the caller another suite's relay. That is the 401 "ADMIN_TOKEN
required for admin endpoints" that route-billing-entitlements #315 hit after a
restart. It now waits for its own child's relay_started on that port and, when
a token is set, proves ownership with one authenticated request. restart()
awaits the old process instead of sleeping 150 ms at it.
@Apolloccrypt
Apolloccrypt merged commit c1457aa into main Sep 3, 2026
14 checks passed
@Apolloccrypt
Apolloccrypt deleted the fix/login-timing-robust branch September 5, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant