A counted-loop counter is 32-bit, so a large range never terminates
- Type: bug (silent wrong value + hang) — Track N
- Found: 2026-08-06, bughunting with
tools/pydiff.py.
Measured (before, self-hosted binary at 08bea9451; identical on pinned)
for i in range(3000000000, 3000000003):
print("i", i)
print("done")
i -1294967296
i -1294967295
i -1294967294
… <- never reaches `done`; killed at the timeout
-1294967296 is 3000000000 taken mod 2^32, read signed. The counter can never
reach 3000000003, so the loop is unbounded.
range() itself is fine — only the counted-loop lowering is wrong:
print(list(range(3000000000, 3000000003))) # [3000000000, 3000000001, 3000000002] CORRECT
print(len(range(3000000000, 3000000003))) # 3 CORRECT
The boundary is exactly 2^31: range(2147483645, 2147483647) iterates correctly,
range(2147483647, 2147483650) wraps.
Cause
compiler/pyparser.inc, the range() arm of the for parser:
symIdx := PyProgSym(name);
if symIdx < 0 then symIdx := AllocVar(name, tyInteger); { 4 bytes }
AllocVar is called directly, bypassing PyNoteLocalType — which is where
every other inferred NilPy local now gets widened to a 64-bit cell
([[bug-nilpy-augmented-assignment-truncates-to-32-bits]]). The lowering's own
comment already noted the counter "is typed tyInteger"; that is precisely the
defect. The neighbouring
[[bug-nilpy-range-over-a-variant-bound-loops-forever]] is the same family — a
range bound that does not fit what the counter assumes — fixed for variants,
missed for width.
Fix
Allocate the counter (and the comprehension's hidden loop name) tyInt64. A
NilPy int is a 64-bit cell everywhere else: the x: int annotation, class
fields, and every inferred local.
Verified
| case | after |
|---|---|
range(3000000000, 3000000003) |
yields 3000000000..3000000002, then done |
range(2147483646, 2147483650) |
4 iterations |
list(range(…)) / len(range(…)) |
unchanged, still correct |
| small ranges, nested loops, comprehensions | unchanged |
test/test_nilpy_int_promotion_default.npy extended with the large-range loop,
the 2^31-straddling count and the list(range(...)) control; all 45 lines diff
clean against CPython. tools/gate.sh quick GREEN.
Follow-up found in the same probe, NOT fixed here
The counter's SURVIVING value after the loop is wrong, independently of width — filed as [[bug-nilpy-for-range-counter-survives-with-the-wrong-value]].
Second site, same bug (found by sweeping the class)
The fix above covered the VISIBLE counter. Grepping the rest of
pyparser.inc for AllocVar(..., tyInteger) — i.e. every NilPy local that
bypasses the PyNoteLocalType choke point — turned up a second range counter:
when the loop variable is VARIANT-typed (because the same name is also bound to
a non-int elsewhere), the lowering counts with its own hidden int and boxes it
into the variant per iteration
([[bug-nilpy-range-for-variant-loop-var]]'s machinery). That hidden counter was
tyInteger too, so
i = "a string"
for i in range(3000000000, 3000000003): ...
still looped forever after the first fix. Widened to tyInt64 as well, and the
test carries the variant-typed case beside the plain one.
Three sites in that grep were left alone deliberately: the container-iteration
length/index pair and the zip() quartet are bounded by a container's element
count, not by user-supplied range bounds, so they are a different (and far more
remote) risk — worth revisiting only if a >2^31-element container becomes
reachable.
Log
- 2026-08-06 — found, fixed and verified in one pass; second site found by sweeping the bug class and fixed in the same pass.