Browse documentation

0127 - The collector does not mark the registers of closed numeric frames

Date: 2026-10-04 Area: bytecode runtime

A closed numeric frame (ADR 0101) took its register window without clearing the slots below a watermark that was local to one native VM entry and only ever rose. Ordinary frames, native callbacks and resumed generators use the same slots between two scalar recursions and leave object references there, and the collector marks the whole live register arena, so a collection during the next scalar recursion followed references to freed objects (#1364). The collector now marks the register arena only up to the first closed numeric frame's window while one is live, and a closed numeric frame clears nothing. That loses nothing, because a closed numeric frame stores only scalars and the pinned NaN, infinity and -0 values, and writes each register before reading it. It rests on closed numeric frames being the innermost frames whenever one is live: they call only themselves, since the proof admits no other call and a concise arrow body compiles its self-calls as OP_CALL_SELF_NUM, not as tail calls. Development builds assert in SetupNewFrame that no frame is set up while a closed numeric frame is live; a change that lets one call out, such as proper tail calls in concise arrow bodies, has to bring the marking back for them.

Because the collector no longer marks a closed numeric frame's window, the safety of OP_CALL_SELF_NUM rests entirely on the compiler's numeric-only proof. That proof is not serialized (ADR 0101), so a loaded .gbc could park an object in the unmarked window and have the collector reclaim it. The binary verifier therefore rewrites a loaded OP_CALL_SELF_NUM in a synchronous arrow to the ordinary self-call OP_CALL_SELF, whose frame is marked, and rejects the opcode in any other template kind. Code compiled in process keeps the fast path; a .gbc produced by the bundler loses it for its numeric self-recursions and runs them on ordinary frames. OP_CALL_SELF is runtime-only: it takes opcode 255, reserved at the top of the range outside TGocciaOpCode, so it never collides with an appended file opcode and the loader rejects it in a file.

Considered Options

Keeping the clear on push and tracking, VM-wide, the slots that only closed numeric frames had written since any other window used them also fixes the bug, without depending on which frames can run above closed numeric frames. It costs every ordinary call a compare in AcquireRegisters. Measured with callgrind on the production runner, it added 0.13% to a recursive Fibonacci on ordinary frames and saved 0.21% on the closed numeric one; the chosen design costs ordinary calls nothing and saves 0.73% on the closed numeric Fibonacci, because the push no longer tests a watermark.