zip(list, str) yields nothing — and segfaults if any loop ran before it
x = [10, 20, 30]
s = "abc"
for v in x: # <- ANY preceding for-loop
print("l", v)
for a, b in zip(x, s):
print("z", a, b) # CPython: z 10 a / z 20 b / z 30 c
pxx prints the three l lines and then SIGSEGVs.
Without the preceding loop the same zip does not crash — it silently produces NO iterations at all, and execution continues:
x = [10, 20, 30]
s = "abc"
for a, b in zip(x, s):
print("z", a, b) # pxx: nothing
print("after") # pxx: after
zip(list, list) is correct in both shapes, and iterating a string on its own
(for c in s) is correct, so it is specifically a STRING as a zip operand.
The silent-empty form is the more dangerous of the two: a zip that produces no
pairs looks like empty input rather than like a bug.
Found while sweeping iteration constructs (for over list/dict/str, enumerate, zip, range with step and negative step, list/dict comprehensions, break, continue) against CPython — everything else in that sweep matched exactly.
Measured with the compiler at 33db0107d.
Gate
make test-nilpy + self-host byte-identical, plus zip over every operand pair
of (list, str, dict, range) — with and without a preceding loop, since the
preceding loop is what turns the silent case into the crash.
RESOLVED — convert the operand instead of walking it as a list
PyParseForZip desugars to hidden tyClass locals plus TPyList.count and
TPyList.at. A STRING operand's handle went into that tyClass slot and count
read it as a list — hence zero iterations in the quiet case, and a crash once
any earlier loop had changed what that memory held.
New PyZipAsList wraps each operand at parse time when it is not already a
list: a string through pylib's list(AnsiString) — the same conversion
list("abc") already uses, one character per element — and a VARIANT through
pylist_v, which dispatches on the run-time tag. An operand that is already a
list is returned untouched, so the common path is unchanged.
Python's zip takes any iterable, so converting is the faithful answer; rejecting would have been the lazy one.
Verified against CPython: zip(list, str), zip(str, list), zip(str, str),
zip(list, list), zip(list, list(dict)), each with and without a preceding
loop — all match, and so do the three earlier repro programs plus the full
iteration sweep that used to dump core.
Gate
tools/gate.sh full.
Log
- 2026-07-30 — resolved, commit eaf0490e4.