Skip to content

fix(vm): widen OP_LINE operand to 32-bit — line numbers wrapped at 65536 (#630)#663

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/op-line-32bit-630
Jul 18, 2026
Merged

fix(vm): widen OP_LINE operand to 32-bit — line numbers wrapped at 65536 (#630)#663
InauguralPhysicist merged 1 commit into
mainfrom
fix/op-line-32bit-630

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #630

The lexer, parser, and EigsChunk.lines[] all carry line as int and were correct. But the OP_LINE bytecode operand was 16-bit, so every line the VM tracked at runtime was line % 65536.

The sharp form: rc=0, wrong answer, no diagnostic

Two assignments exactly 65536 lines apart collapse onto one line identity:

what is x at 4464   ->  222      # should be 111

222 is the value bound on line 70000, which wrapped onto stamp 4464. No error, exit 0. Everything reading g_vm.current_line was affected — temporal at/when/state_at, runtime-error and stack-trace line numbers, and the trace-tape line context — all silently wrong past line 65535. That is exactly the regime generated programs (iLambdaAi) reach, and it's the one consumer that can't eyeball a wrong line.

Fix

OP_LINE carries a 32-bit operand (a program would need 2³¹ lines to overflow that). Line stamps are deduped — emitted only when the line changes — so the 2 extra bytes per change are negligible, and this is the correctness-first VM tier, not a perf path.

  • No opcode number changed → the test_opcode_abi.c static-asserts are unaffected.
  • No bytecode is serialized to disk (the tape records nondet events and re-compiles from source; --bundle attaches source, not bytecode) → no tape/bundle format bump.

Every operand-stride site (a missed one desyncs the walk silently)

site change
compiler.c chunk_emit_u32 / emit_op_u32 / emit_line emits 4 bytes
vm.c read_u32 + CASE(LINE) + leaf-accessor mini-executor stride 4
chunk.c verifier chunk_verify special-cases OP_LINE (4-byte skip); removed from op_verify_operands
chunk.c disasm dedicated 32-bit branch; removed from op_has_u16
chunk.c leaf-accessor compile-scan strides 4
jit.c supported-prefix scanner + thunk emitter, 3→5 byte stride

Verification

  • Repro fixed both tiers. The issue's exact case gives 111, 222 under interpreter and JIT (was 222, 222). A hot loop forced to JIT-compile past line 65536 returns the correct result and agrees byte-for-byte with EIGS_JIT_OFF.
  • Error lines past 65535 now report the true line, not the wrapped one.
  • Regression test [120] (generated at test time, like the [114] constant-pool-ceiling sibling): asserts the temporal query = 111/222, error line > 65535, and JIT/interpreter agreement. Each assertion fails on the pre-fix VM (222/222, line 4469).
  • Verifier path (test_vm_run_bytecode, hand-built bytecode) green under ASan — confirms the chunk_verify stride stays in lockstep.

Gates

  • Release suite 3078/3078
  • ASan + UBSan detect_leaks=1 3082/3082, leak tally 0, no sanitizer errors
  • make jit-smoke passes

🤖 Generated with Claude Code

…536 (#630)

The lexer, parser, and EigsChunk.lines[] all carry line as int and were
correct, but the OP_LINE bytecode operand was 16-bit, so every line the VM
tracked was line % 65536. Two assignments exactly 65536 lines apart
collapsed onto one line identity:

    what is x at 4464   ->  222   (should be 111 — 222 is bound at line 70000,
                                   which wrapped onto stamp 4464)

rc=0, no diagnostic. Everything reading g_vm.current_line was affected:
temporal at/when/state_at, runtime-error and stack-trace line numbers, and
the trace-tape line context — all silently wrong past line 65535, which is
exactly the regime generated programs (iLambdaAi) reach.

Fix: OP_LINE carries a 32-bit operand. A program would need 2^31 lines to
overflow that. Line stamps are deduped (emit only on change), so the 2 extra
bytes per change are negligible, and this is the correctness-first VM tier
(not a perf path). No opcode number changed (ABI static-asserts unaffected);
no bytecode is serialized to disk, so no tape/bundle format bump.

Touched every site that encodes or strides the operand — a missed one
desyncs the bytecode walk silently:
- emit: chunk_emit_u32 / emit_op_u32 / emit_line (compiler.c, chunk.c)
- read: read_u32 + CASE(LINE) + leaf-accessor mini-exec (vm.c)
- verify: chunk_verify special-case + op_verify_operands (chunk.c)
- disasm: dedicated 32-bit branch + removed from op_has_u16 (chunk.c)
- leaf-accessor compile scan (chunk.c)
- JIT: supported-prefix scanner + thunk emitter, 3->5 byte stride (jit.c)

Regression test [120] (generated at test time, like the [114] constant-pool
sibling): two assignments 65536 apart, asserts 'what is x at L' = 111/222,
error line > 65535, and JIT-tier agreement — each fails on the pre-fix VM
(222/222, line 4469). Verifier path (hand-built bytecode, test_vm_run_bytecode)
green under ASan, confirming the chunk_verify stride stays in sync.

Suite 3078/3078 release, 3082/3082 ASan+UBSan (detect_leaks=1, tally 0).
@InauguralPhysicist
InauguralPhysicist merged commit 5b21b04 into main Jul 18, 2026
18 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/op-line-32bit-630 branch July 18, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Source line numbers wrap at 65536: OP_LINE narrows int to uint16_t — 'what is x at L' returns the WRONG VALUE at rc=0

1 participant