Wave 2 of the relay architecture review 2026-10-14, on top of 5a0331e. In-process wake-ups (Redis only with FN_RELAY_REPLICAS>1) and size-first polls; per-IP signin bucket, bcrypt off the event loop, SHA-256 app credentials with bcrypt rehash; account-service aud/iss/jti behind FN_ACCOUNT_TOKEN_STRICT (off); batched presence, roster_tags(digest) and prekeys partial indexes, cached key-log head with a per-IP limit, set-based retention, batched last-seen writes; streaming attachments with auth before the body; FN_FEDERATION_PEERS host=key pins; per-reporter limit and hourly report digest; /metrics on FN_METRICS_PORT (off by default); docs say self-hosting needs Postgres. Full suite 971/971 twice with Postgres, e2e conformance 266/0/2. Generated with Claude Code.
Wave 2 of the relay architecture review 2026-10-14, on top of 5a0331e. In-process wake-ups (Redis only with FN_RELAY_REPLICAS>1) and size-first polls; per-IP signin bucket, bcrypt off the event loop, SHA-256 app credentials with bcrypt rehash; account-service aud/iss/jti behind FN_ACCOUNT_TOKEN_STRICT (off); batched presence, roster_tags(digest) and prekeys partial indexes, cached key-log head with a per-IP limit, set-based retention, batched last-seen writes; streaming attachments with auth before the body; FN_FEDERATION_PEERS host=key pins; per-reporter limit and hourly report digest; /metrics on FN_METRICS_PORT (off by default); docs say self-hosting needs Postgres. Full suite 971/971 twice with Postgres, e2e conformance 266/0/2. Generated with Claude Code.
One relay keeps a waiter per held poll in memory (WakeHub). The poll
registers once, before its first cursor query, and Delivery.write signals
it after the commit, so a send landing between the query and the wait is
kept and the poll returns at once. A held poll holds no Redis connection.
With FN_RELAY_REPLICAS above 1 wake-ups still go through BRPOP on a Redis
list, behind the same Waiter interface; registration there deletes tokens
left by earlier sends instead of popping them later. A failed wait sleeps
one second before the next cursor query. The Redis pool default is 32 per
loop on one relay and stays 512 with replicas.
The cursor query reads id and payload length first, cuts at the 2 MiB
budget, then loads payloads for the kept rows only. Response shape is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relay review 2026-10-14, three owner-approved changes, each safe to deploy
before anything else moves.
Sign-in: a second failure bucket per address (FN_SIGNIN_MAX_PER_IP, 30 per
15 minutes, 0 = off) sits beside the address+username pair, so one address
can no longer try a password against every username. A success clears only
the pair. Every bcrypt hash and verify in AccountRoutes and PasswordReset now
runs on application.threadPool through offLoop.
App credentials: the relay mints every one as 32 bytes from the CSPRNG
(randomHexToken). New rows are stored as sha256:<hex>; a bcrypt row is
verified once and rewritten as SHA-256 on success, only when the secret has
the 64-hex shape the relay mints.
Account-service tokens: /messenger/session reads iss, aud (string or list)
and jti. FN_ACCOUNT_TOKEN_STRICT (off by default) logs and counts tokens that
lack them, carry the wrong ones, or replay a jti, and accepts them; on, it
refuses them. jti is spent through the replay cache with a TTL to the token's
exp, after the name checks so a refused handle does not burn the token. Boot
fails with strict on and FN_ACCOUNT_TOKEN_ISSUER unset. SECURITY.md has the
rollout order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FN_FEDERATION_PEERS takes host, host=key or host=key1|key2. A pinned peer
whose descriptor publishes another key is refused (keyNotPinned, 401).
Boot warns for each bare host and for each malformed pin.
- Content reports: FN_REPORT_RATE (default 10/hr) per reporter through the
existing limiter, 429 over it. One mail per report replaced by
ReportDigest: at most one mail an hour listing reports since the last,
none when there are none. Watermark in a new report_digests table, seeded
at the newest existing report; content_reports is unchanged.
- RelayMetrics counters and a NIO listener on FN_METRICS_PORT (off by
default, 127.0.0.1 by default) answering only GET /metrics. Boot refuses
FN_METRICS_PORT equal to FN_PORT.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Presence by token: three queries per request (device tags, account
tags, users) with digest IN (...), down from two or three per token.
New index roster_tags_digest; device_roster_tags_digest already existed.
- Key log head: KeyLogHeadCache serves (size, root, signature) after one
MAX(seq) read, and loads only leaves past the cached seq. It refreshes
on the first read after an append commits, never inside the append's
transaction, and stays correct across replicas. GET /e2e/log/head is
metered at 3600 per address per hour.
- Retention: expired devices picked by one SQL query (ids only), orphaned
push tokens deleted with NOT EXISTS, used prekeys deleted with a bare
`used` predicate matching the new partial index prekeys_used_created.
- noteAliveOrRefuse reads only suspended_at per request. Last-seen is
written at most once per heartbeat and once per 60 s per account,
tracked in memory per process.
- AddScanIndexes builds both indexes CONCURRENTLY on Postgres: FluentKit
runs migrations outside a transaction. An INVALID leftover is rebuilt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RelayCapacity.shared is process-global. WakeTests holds 21 polls at once,
which moved the exact peakHeld that CapacityTests asserts. Both now take
one lock (ProcessEnvironmentLock.withExclusiveHeldPolls). The wake-race
test allows 5 s against a 30 s hold, so a loaded runner does not fail it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds flamenet_wake_errors_total and counts both catch blocks in
RoutesWake (send and wait). Only the replica (Redis) path can fail;
the in-process waiter cannot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Uploads are authenticated before the body is read: token, proof headers,
device and account are checked first, then Content-Length (required, 411
without it) against attachmentMaxBytes and the rate limits. The body then
streams to storage while it is counted and hashed. The proof signature,
which covers the body's SHA-256, is checked after the last byte, and a
blob whose proof fails, or whose body ends short of Content-Length, is
deleted.
S3 PUTs are signed UNSIGNED-PAYLOAD and sent with a known length through
AsyncHTTPClient. The digest is unknown until the last byte, so
x-amz-checksum-sha256 cannot go in a header, and B2 refuses aws-chunked.
GETs stream the bucket's body to the client, Range still asked of the
bucket. The disk store writes to a .partial file and renames it.
Vapor runs a streamed body's closure a second time when writing the
response fails; the bucket body is handed out once, because a second read
traps the process.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WakeTests "a send that commits between the cursor query and the wait"
failed in full runs (14 s, 36 s, 42 s against a 5 s bound) and passed
alone. The wake was never lost: with timestamps, the send committed and
signalled at +13.41 s and the poll returned at +14.12 s. The 13 s went
before the send, in a process-wide stall.
A `sample` taken during a stall showed all 8 cooperative threads busy:
7 in Bcrypt.hash at the default cost 12, called synchronously from
fixtures (RosterTagTests, MobileRoutesTests, MobileExtrasTests,
PasswordResetTests account helpers), and 1 in blockingShutdown's
semaphore. One cost-12 hash is 0.31 s in a debug build; cost 4 is
1.3 ms. Every fixture now hashes at cost 4, as WaveZeroBSupport already
did, and a source scan fails if one goes back to the default. The full
run drops from about 60 s to 45 s.
WakeTests now times the poll from the moment the send committed, so a
stall before the send cannot read as a lost wake. With the product's
pending flag disabled it still fails, at 31.8 s.
DeliveryOrderPostgresTests counted every ungranted advisory lock on the
server, including other suites' key-log appends in their own databases,
so the second send could start before the first had inserted ("free"
got id 1). The wait now counts only this database's lock on holdKey.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The streaming upload path repeats the account check before reading the
body, and its copy still refused any subject without a row on an
issuing relay. Same rule as Routes.noteAliveOrRefuse now: refuse only
ids in deleted_accounts. Conformance 266/0/2 (was failing 6 in
sealed attachments); full suite 971/971 twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's configuration-doc check found FN_ACCOUNT_TOKEN_ISSUER,
FN_ACCOUNT_TOKEN_STRICT and FN_SIGNIN_MAX_PER_IP read but not
documented. configuration.leaf and relay.leaf regenerated with
Scripts/build-doc-pages.sh; 9 generated pages match their sources.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing in favour of a PR from the branch itself. This one was opened over AGit against refs/pull/252/head, so it cannot pick up the CI fix in #253 that its image job needs: that job failed twice here on mac1 and passes on mac2. The branch release/relay-review-wave2-2026-10-14 now has main merged into it, including that fix, and carries the same 18 commits.
Closing in favour of a PR from the branch itself. This one was opened over AGit against refs/pull/252/head, so it cannot pick up the CI fix in #253 that its image job needs: that job failed twice here on mac1 and passes on mac2. The branch release/relay-review-wave2-2026-10-14 now has main merged into it, including that fix, and carries the same 18 commits.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Wave 2 of the relay architecture review 2026-10-14, on top of
5a0331e. In-process wake-ups (Redis only with FN_RELAY_REPLICAS>1) and size-first polls; per-IP signin bucket, bcrypt off the event loop, SHA-256 app credentials with bcrypt rehash; account-service aud/iss/jti behind FN_ACCOUNT_TOKEN_STRICT (off); batched presence, roster_tags(digest) and prekeys partial indexes, cached key-log head with a per-IP limit, set-based retention, batched last-seen writes; streaming attachments with auth before the body; FN_FEDERATION_PEERS host=key pins; per-reporter limit and hourly report digest; /metrics on FN_METRICS_PORT (off by default); docs say self-hosting needs Postgres. Full suite 971/971 twice with Postgres, e2e conformance 266/0/2. Generated with Claude Code.WakeTests "a send that commits between the cursor query and the wait" failed in full runs (14 s, 36 s, 42 s against a 5 s bound) and passed alone. The wake was never lost: with timestamps, the send committed and signalled at +13.41 s and the poll returned at +14.12 s. The 13 s went before the send, in a process-wide stall. A `sample` taken during a stall showed all 8 cooperative threads busy: 7 in Bcrypt.hash at the default cost 12, called synchronously from fixtures (RosterTagTests, MobileRoutesTests, MobileExtrasTests, PasswordResetTests account helpers), and 1 in blockingShutdown's semaphore. One cost-12 hash is 0.31 s in a debug build; cost 4 is 1.3 ms. Every fixture now hashes at cost 4, as WaveZeroBSupport already did, and a source scan fails if one goes back to the default. The full run drops from about 60 s to 45 s. WakeTests now times the poll from the moment the send committed, so a stall before the send cannot read as a lost wake. With the product's pending flag disabled it still fails, at 31.8 s. DeliveryOrderPostgresTests counted every ungranted advisory lock on the server, including other suites' key-log appends in their own databases, so the second send could start before the first had inserted ("free" got id 1). The wait now counts only this database's lock on holdKey. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Closing in favour of a PR from the branch itself. This one was opened over AGit against refs/pull/252/head, so it cannot pick up the CI fix in #253 that its image job needs: that job failed twice here on mac1 and passes on mac2. The branch release/relay-review-wave2-2026-10-14 now has main merged into it, including that fix, and carries the same 18 commits.
Pull request closed