fetch(): with keep-alive and a small response, every call keeps a Web Streams handle id; the process aborts after ~1M calls
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- rust, typescript
- Domain
- backend-api-design, networking
Research direction
Start by reading crates/perry-stdlib/src/streams/idalloc.rs, especially the handle allocation and retirement behavior described by the panic. Build the supplied TypeScript reproduction and compare small versus big responses with the keep-alive server to investigate where handles remain live. Done when the small-response loop completes without unbounded RSS growth or exhausting the handle-id band.
Written by the indexing model from the issue text.
Description
What happened
Sequential fetch() + text() calls to a local HTTP/1.1 keep-alive server that answers a 16-byte body never release something per call. RSS grows in steps (~230 MB, ~410 MB, ~780 MB). After about 1 000 000 calls the process aborts:
thread '<unnamed>' panicked at crates/perry-stdlib/src/streams/idalloc.rs:130:9:
Web Streams handle id band exhausted: 1048576 ids live or quarantined (see PERRY_STREAM_ID_QUARANTINE)
The same loop runs 1 100 000 calls with flat memory when the response is 16 KiB (same keep-alive server), or when the server closes the connection after each response (python3 -m http.server, HTTP/1.0).
What you expected
The loop completes with flat memory, as it does with 16 KiB responses or without keep-alive.
Minimal reproduction
// leak.ts
import { readFileSync } from "node:fs";
const file = process.argv[2] ?? "small";
for (let i = 1; i <= 1100000; i++) {
const r = await fetch("http://127.0.0.1:9081/" + file);
await r.text();
if (i % 100000 === 0) {
const rss = readFileSync("/proc/self/status", "utf8").split("\n").find((l: string) => l.startsWith("VmRSS:"));
console.log(file, i, rss);
}
}
console.log("done");
A keep-alive server (Python standard library; /small returns 16 bytes, anything else 16 KiB):
python3 -c '
import http.server
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
wbufsize = 65536
def do_GET(self):
body = b"x" * (16 if self.path == "/small" else 16384)
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
http.server.ThreadingHTTPServer(("127.0.0.1", 9081), H).serve_forever()
' &
Command you ran:
perry compile leak.ts -o leak
./leak small # aborts after ~1 000 000 calls (~160 s)
./leak big # completes
Environment
- Perry version: 0.5.1661 (
mainat c9573408b, 2026-10-18, built from source) - Host OS: Debian 13, Linux 6.12, x86-64
- Target: native
- Rust toolchain (if building from source): rustc 1.101.0-nightly (db8f076d2 2026-10-03), LLVM 22.1.8
- Installed via: from source:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static -p perry-ext-http, thenscripts/gc_call_effects/sidecars.sh target/release
Diagnostic output
./leak small:
small 100000 VmRSS: 80484 kB
small 200000 VmRSS: 136644 kB
small 300000 VmRSS: 229212 kB
small 400000 VmRSS: 234688 kB
small 500000 VmRSS: 411640 kB
small 600000 VmRSS: 417272 kB
small 700000 VmRSS: 422364 kB
small 800000 VmRSS: 427504 kB
small 900000 VmRSS: 433112 kB
small 1000000 VmRSS: 783588 kB
thread '<unnamed>' panicked at crates/perry-stdlib/src/streams/idalloc.rs:130:9:
Web Streams handle id band exhausted: 1048576 ids live or quarantined (see PERRY_STREAM_ID_QUARANTINE)
Aborted (core dumped)
Max RSS 1.13 GB. Reproduced twice.
Controls, 1 100 000 calls each, both complete:
| Server | Response | RSS at 1 000 000 / 1 100 000 calls | Max RSS |
|---|---|---|---|
| keep-alive (above) | 16 KiB (./leak big) |
52 / 58 MB | 67 MB |
python3 -m http.server (HTTP/1.0, connection closed after each response), file with 16 bytes |
16 bytes | 51 / 46 MB | 51 MB |
Anything else
- The retired-id quarantine holds at most 16 384 ids by default (
PERRY_STREAM_ID_QUARANTINE), so reaching 1 048 576 means these ids stay live rather than retired. - The release 0.5.1520 shows the same growth (78 → 258 MB over 300 000 calls).
- Impact for us: a server that reaches its database through
fetchto a local API aborts after about a million calls.
- Dominant language
- Rust
- Stars
- 4.9k
- Forks
- 166
- Avg merge
- 2h 54m
- Merged PRs (30d)
- 922
Getting set up
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from PerryTS/perry
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
Maintainers usually reply within 1 day
-
bug
Difficulty 3/5 1-2 days Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
collect_packages_in_node_modules walks symlinked node_modules with no visited set: 3 fully-connected workspace packages hang perry compile at Collecting modules (100% CPU, no output)Possibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 3/5 1-2 days Newbie friendliness 76/100
Maintainers usually reply within 1 day
Similar issues
-
area:io needs-info needs-triage
Difficulty 2/5 1-3 hours Newbie friendliness 63/100
bherbruck/solvecraft#3216 · 1 comment ·
-
bug triage/needs-triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
praxis-proxy/grid#412 ·
Maintainers usually reply within 1 day
-
priority: low
Difficulty 2/5 1-3 hours Newbie friendliness 63/100
storytold/photocraft#7824 · 1 comment ·
Maintainers usually reply within 1 day
-
area:ui chore could help wanted
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day