What happens. com.atproto.repo.listRecords is public and spends no sync budget and no in-flight turn. getRecord and every com.atproto.sync.* read do spend both (routes/repo.rs:919-935 compared with routes/repo.rs:790). A page holds up to 100 records (wire.rs:748), and since 8c05d5f84 a record can be up to 1,000,000 bytes, so one page can be about 100 MB. The handler reads it on the async worker, with no spawn_blocking. It re-encodes every record to compute its CID (wire.rs:906) and buffers the whole JSON body before sending any of it. The only limit is the server-wide 512 requests in flight (lib.rs:175).
How to reproduce. From any account that can write (an agent, or an app with a repo grant on one collection), store 100 records of about 990 KB in one collection. The default write meter allows 64 MiB an hour, so this takes about two hours. Then, from one address with no credential, run 20 parallel loops of GET /xrpc/com.atproto.repo.listRecords?repo=<did>&collection=<nsid>&limit=100. Each request holds about twice the page in memory (the values, then the serialized body) and keeps a worker busy encoding and hashing about 100 MB.
Why it matters. The deployment runs two async workers. Two such requests at once stall every other route, writes and OAuth included. A few dozen at once is several GB, enough to get a small instance killed for running out of memory. That takes a high-reliability host offline, and the reads need no credential. Putting listRecords under the same per-address budget and turn as getRecord, and running it on the blocking pool or capping a page by bytes, would close this.
Fixed by https://tangled.org/permadeath.com/didbot/pulls/1263, now on main.