0173 - A request the floor refused is collected for again, with a backoff
Date: 2026-11-02
Area: gc, memory-limit
Related: ADR 0110, #1990
Context
ADR 0110 makes a request that
does not fit force one collection before it is refused. When that collection
still leaves no room, the collector records FForcedCollectFloor, the number
of bytes live after it, and answers a later request that does not fit beside
that level without walking the heap. The record keeps a guest that catches the
RangeError and retries at constant cost per attempt.
The record is a statement about a counter, and a program drops what it held
without moving any counter: an unreachable object stays in BytesAllocated
until a collection frees it. Routine threshold collection is off while
bytecode runs. So a program that fills the heap, catches the refusal, drops
what it held and retries the same size is refused until something calls
Goccia.gc(), whose call ADR 0110 names as the way out:
fill(); // refused after 33 buffers of 8 MB, then returnsnew ArrayBuffer(8000000); // refused: nothing is reachable, the floor says nonew ArrayBuffer(100); // fitsnew ArrayBuffer(8000000); // refused againGoccia.gc();new ArrayBuffer(8000000); // fits
A fetch response body is reserved through the same path and behaves the same. Re-arming forcing when the event loop takes its next task would not help this shape, which is one synchronous run, and re-arming only for fetch would leave every other reservation as it was.
Decision
The floor still answers a repeat of a refused request, but every
FForcedCollectRetryAfter-th request it answers collects after all.
- The counter
FForcedCollectRefusalscounts the requests the floor has answered since the last forced collection. When it reachesFForcedCollectRetryAfter, the request forces a collection as if no floor were on record. FForcedCollectRetryAfterstarts at 1, so the first retry after a refusal always collects: that is the retry after a drop. A retry collection that makes no room doubles it, up to 65,536 (FORCED_COLLECT_MAX_RETRY_AFTER). One that makes room, any collection (Collect,CollectYoung) and a drop of the counters below the floor reset it to 1.- A retry loop that drops nothing therefore walks the heap on attempts 1, 2, 4, 8 and so on, a logarithmic number of times, and then once per 65,536 attempts, not once per attempt. The initial refusal's collection does not count against the interval, so a program that fills, drops and retries in a cycle gets its collection on every first retry.
- The state is written under
FAccountingLockwith the floor, and the floor stays per request size.
Consequences
- The sequence above now reads
fits, fits, fits, fitswithoutGoccia.gc(). - Only the first retry after the initial refusal is guaranteed to collect. If that
collection makes no room, the interval becomes 2, and a retry that follows a
drop but not a counter change can be refused without a collection;
Goccia.gc()remains the fallback. A retry collection that sweeps some of what the program held but still cannot fit the request doubles the interval like any other retry that makes no room: the sweep settles its bytes in bulk and does not count as a counter drop. Only a release below the floor while the collection runs (a reservation given back, an object unregistered outside the sweep) keeps the interval at 1. - A refused retry loop pays more than the O(1) it paid before. On a heap of
20,000 small objects and about 250 MB of buffers under
--max-memory=256MiB, one forced collection takes about 14 ms, and 200,000 refused attempts took 4.0 s before and 6.5 s after. The extra walks come from the restarts after each real collection (the VM's pressure checkpoints also collect while the heap is near the ceiling and reset the interval), about a dozen per restart. The cost stays proportional to the number of real collections, not to attempts. Goccia.gc()remains the way to force the issue: it collects and clears the record. After more than 65,536 refused attempts that dropped nothing, a drop can again meet a refusal until the interval ends or the program collects.- ADR 0110's third refusal shape (a request larger than the budget) and the re-entrancy guard are unchanged; so is the gate's uncatchable refusal.
Alternatives rejected
- Re-arm at the next event-loop task. It leaves a synchronous retry refused, which is the reported shape, and a loop that awaits between attempts would walk the heap once per turn.
- Re-arm for fetch bodies only. It fixes one caller of a rule that every reservation shares.
- Measure time or instructions between collections. Wall time makes
whether a request fits depend on timing, which
--deterministicrules out, and the instruction counter is armed only under an instruction limit. - Document
Goccia.gc()as the required step. ADR 0110 already does; the pattern it leaves, refused, fits, refused, is what the report was about.