From 42ce769e63260e19b600ef5d798228240a6dd756 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Fri, 17 Jul 2026 22:07:46 -0500 Subject: [PATCH] =?UTF-8?q?fix(vm):=20widen=20OP=5FLINE=20operand=20to=203?= =?UTF-8?q?2-bit=20=E2=80=94=20line=20numbers=20wrapped=20at=2065536=20(#6?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/chunk.c | 38 ++++++++++++++++++++++++++++----- src/compiler.c | 9 +++++++- src/jit.c | 14 +++++++----- src/vm.c | 14 ++++++++---- src/vm.h | 3 ++- tests/run_all_tests.sh | 48 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/chunk.c b/src/chunk.c index caf77714..fbca4ee7 100644 --- a/src/chunk.c +++ b/src/chunk.c @@ -137,6 +137,15 @@ void chunk_emit_u16(EigsChunk *chunk, uint16_t val, int line) { chunk_emit(chunk, (uint8_t)((val >> 8) & 0xFF), line); } +/* #630: little-endian 32-bit operand — currently only OP_LINE, whose line + * number legitimately exceeds 65535 in generated programs. Read by read_u32. */ +void chunk_emit_u32(EigsChunk *chunk, uint32_t val, int line) { + chunk_emit(chunk, (uint8_t)(val & 0xFF), line); + chunk_emit(chunk, (uint8_t)((val >> 8) & 0xFF), line); + chunk_emit(chunk, (uint8_t)((val >> 16) & 0xFF), line); + chunk_emit(chunk, (uint8_t)((val >> 24) & 0xFF), line); +} + /* Emit a jump instruction with a placeholder 16-bit offset. * Returns the offset of the placeholder for later patching. */ int chunk_emit_jump(EigsChunk *chunk, uint8_t op, int line) { @@ -369,12 +378,13 @@ static int op_has_u16(uint8_t op) { case OP_TRY_BEGIN: case OP_LOOP_STALL_CHECK: case OP_LOOP_CAP_CHECK: case OP_OBSERVE_ASSIGN: case OP_OBSERVE_ASSIGN_LOCAL: case OP_IMPORT: case OP_MATCH: - case OP_LINE: case OP_REPORT_VALUE_SLOT: case OP_REPORT_VALUE_NAME: case OP_TRAJECTORY_SLOT: case OP_TRAJECTORY_NAME: return 1; case OP_INTERROGATE: case OP_PREDICATE: return 1; /* kind:8 but padded to 16 for uniformity */ + /* OP_LINE has a 32-bit operand (#630) — handled separately by the + * disassembler and verifier, never through the u16 path. */ default: return 0; } @@ -389,7 +399,15 @@ void chunk_disassemble(EigsChunk *chunk, const char *label) { uint8_t op = chunk->code[i]; fprintf(stderr, "%04d [L%d] %-20s", i, line, op_name(op)); i++; - if (op_has_u16(op) && i + 1 < chunk->code_len) { + if (op == OP_LINE && i + 3 < chunk->code_len) { + /* #630: 32-bit operand. */ + uint32_t arg = (uint32_t)chunk->code[i] | + ((uint32_t)chunk->code[i + 1] << 8) | + ((uint32_t)chunk->code[i + 2] << 16) | + ((uint32_t)chunk->code[i + 3] << 24); + fprintf(stderr, " %u", arg); + i += 4; + } else if (op_has_u16(op) && i + 1 < chunk->code_len) { uint16_t arg = chunk->code[i] | (chunk->code[i + 1] << 8); fprintf(stderr, " %d", arg); if (op == OP_CONST && arg < (uint16_t)chunk->const_count) { @@ -462,8 +480,10 @@ static int op_verify_operands(uint8_t op, VerifyRole roles[3]) { case OP_REPORT_SLOT: case OP_OBSERVE_VALUE_SLOT: case OP_REPORT_VALUE_SLOT: case OP_INTERROGATE: case OP_PREDICATE: - case OP_MATCH: case OP_LINE: case OP_DESTRUCTURE_UNPACK: + case OP_MATCH: case OP_DESTRUCTURE_UNPACK: roles[0] = VR_RAW; return 1; + /* OP_LINE's operand is 32-bit (#630); chunk_verify walks it specially, + * so it never reaches this per-operand (u16-strided) role machinery. */ case OP_LOCAL_DOT_GET: case OP_LOCAL_DOT_SET: roles[0] = VR_RAW; roles[1] = VR_NAME; return 2; /* slot, name */ case OP_LOCAL_IDX_GET: @@ -498,6 +518,14 @@ int chunk_verify(EigsChunk *chunk) { uint8_t op = code[i]; if (op >= OP_COUNT) { ok = 0; break; } is_start[i] = 1; + /* #630: OP_LINE has a single 32-bit operand — outside the u16-strided + * role machinery below. No index to validate; just skip 4 bytes. */ + if (op == OP_LINE) { + int end = i + 1 + 4; + if (end > n) { ok = 0; break; } + i = end; + continue; + } VerifyRole roles[3]; int nops = op_verify_operands(op, roles); int end = i + 1 + 2 * nops; @@ -558,8 +586,8 @@ void chunk_scan_leaf_accessor(EigsChunk *c) { uint8_t op = *ip++; switch (op) { case OP_LINE: - if (ip + 2 > end) return; - ip += 2; + if (ip + 4 > end) return; /* #630: 32-bit operand */ + ip += 4; break; case OP_CONST: { if (ip + 2 > end) return; diff --git a/src/compiler.c b/src/compiler.c index 3ec8adf7..87bf62df 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -353,6 +353,13 @@ static void emit_op_u16(Compiler *c, uint8_t op, uint16_t arg, int line) { adjust_stack(c, op_stack_effect(op)); } +/* #630: 32-bit operand form (OP_LINE only). */ +static void emit_op_u32(Compiler *c, uint8_t op, uint32_t arg, int line) { + chunk_emit(c->chunk, op, line); + chunk_emit_u32(c->chunk, arg, line); + adjust_stack(c, op_stack_effect(op)); +} + static void emit_op_u16_u16(Compiler *c, uint8_t op, uint16_t arg1, uint16_t arg2, int line) { chunk_emit(c->chunk, op, line); chunk_emit_u16(c->chunk, arg1, line); @@ -442,7 +449,7 @@ static int capture_loop_start(Compiler *c) { * basic-block boundary so we never *skip* a stamp the runtime needs. */ static void emit_line(Compiler *c, int line) { if (c->last_line == line) return; - emit_op_u16(c, OP_LINE, (uint16_t)line, line); + emit_op_u32(c, OP_LINE, (uint32_t)line, line); /* #630: 32-bit — was (uint16_t), wrapped past line 65535 */ c->last_line = line; } diff --git a/src/jit.c b/src/jit.c index 2efd5aae..35f953e8 100644 --- a/src/jit.c +++ b/src/jit.c @@ -840,8 +840,9 @@ static int jit_supported_prefix(const struct EigsChunk *chunk, * call, no bail, no sp/env interaction. */ i += 1; ops++; non_line_ops++; } else if (op == OP_LINE) { - if (i + 3 > chunk->code_len) { *stop_op = op; *stop_offset = i; break; } - i += 3; ops++; + /* #630: [op][line:32] = 5 bytes. */ + if (i + 5 > chunk->code_len) { *stop_op = op; *stop_offset = i; break; } + i += 5; ops++; } else { *stop_op = op; *stop_offset = i; break; @@ -3835,8 +3836,11 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk, } i += 1; } else { /* OP_LINE */ - uint16_t line = (uint16_t)(chunk->code[i + 1] | - ((uint16_t)chunk->code[i + 2] << 8)); + /* #630: 32-bit operand. */ + uint32_t line = (uint32_t)chunk->code[i + 1] | + ((uint32_t)chunk->code[i + 2] << 8) | + ((uint32_t)chunk->code[i + 3] << 16) | + ((uint32_t)chunk->code[i + 4] << 24); w = emit_movl_imm_at_disp32_rbx(w, g_layout.off_current_line, (int32_t)line); /* Also stamp g_trace_current_line (the line prev_record_assign @@ -3846,7 +3850,7 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk, * the line where the thunk took over. %rax is scratch between ops. */ w = emit_movabs_rax(w, (uint64_t)(uintptr_t)&g_trace_current_line); w = emit_movl_imm_at_rax(w, (int32_t)line); - i += 3; + i += 5; /* #630: [op][line:32] */ } /* Update last_imm in lockstep with the scanner's last_push_immediate. * Consumed by OP_POP to decide between the `dec %ecx` peephole and diff --git a/src/vm.c b/src/vm.c index d7823fe7..8baf9623 100644 --- a/src/vm.c +++ b/src/vm.c @@ -496,6 +496,12 @@ static inline uint16_t read_u16(uint8_t *ip) { return ip[0] | (ip[1] << 8); } +/* #630: little-endian 32-bit operand read (OP_LINE). */ +static inline uint32_t read_u32(uint8_t *ip) { + return (uint32_t)ip[0] | ((uint32_t)ip[1] << 8) | + ((uint32_t)ip[2] << 16) | ((uint32_t)ip[3] << 24); +} + /* is_truthy declared in eigenscript.h */ /* Iterator state: stored as a list [iterable, index] */ @@ -1232,7 +1238,7 @@ static int vm_leaf_accessor_exec(EigsChunk *c, int argc) { for (;;) { switch (*ip++) { case OP_LINE: - ip += 2; + ip += 4; /* #630: 32-bit operand */ break; case OP_CONST: { uint16_t idx = read_u16(ip); ip += 2; @@ -5008,9 +5014,9 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } CASE(LINE): { - uint16_t line = read_u16(ip); ip += 2; - current_line = line; - g_vm.current_line = line; + uint32_t line = read_u32(ip); ip += 4; /* #630: 32-bit operand */ + current_line = (int)line; + g_vm.current_line = (int)line; /* History stamping needs the current line whether or not a tape * is open — a plain global store is far cheaper than the call * (17.8M calls per 500k DMG-shaped steps before this change). diff --git a/src/vm.h b/src/vm.h index e2d98cfd..83ed1626 100644 --- a/src/vm.h +++ b/src/vm.h @@ -140,7 +140,7 @@ typedef enum { OP_MATCH, /* [case_count:16] pattern match dispatch */ OP_LISTCOMP_BEGIN, /* push empty list accumulator */ OP_LISTCOMP_APPEND, /* append TOS to accumulator */ - OP_LINE, /* [line:16] update current line number */ + OP_LINE, /* [line:32] update current line number (#630: was 16-bit, wrapped past line 65535) */ OP_WIDE, /* next operand is 32-bit */ OP_DISPATCH, /* pop arg, key, table; call table[key](arg) inline */ @@ -544,6 +544,7 @@ void chunk_decref(EigsChunk *chunk); int chunk_add_constant(EigsChunk *chunk, Value *val); void chunk_emit(EigsChunk *chunk, uint8_t byte, int line); void chunk_emit_u16(EigsChunk *chunk, uint16_t val, int line); +void chunk_emit_u32(EigsChunk *chunk, uint32_t val, int line); int chunk_emit_jump(EigsChunk *chunk, uint8_t op, int line); void chunk_patch_jump(EigsChunk *chunk, int offset); int chunk_add_function(EigsChunk *chunk, EigsChunk *fn); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 20adb89f..4d56b746 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1082,6 +1082,54 @@ else fi rm -f "$CPG_FILE" +# [120] OP_LINE 32-bit operand (#630). The line operand was u16, so every +# line past 65535 wrapped (line % 65536) and two assignments exactly 65536 +# lines apart collapsed onto one stamp — 'what is x at L' then returned the +# wrong value at rc=0, and errors/traces past 65535 misreported the line. +# Generated at test time (a 70k-line file is not worth committing). Two +# assignments 65536 apart: x=111 at line A, x=222 at line A+65536. Both +# tiers must agree, so the JIT tier runs with thresholds forced low. +echo "[120] OP_LINE 32-bit line operand (#630)" +LW_FILE=$(mktemp /tmp/eigs_linewrap_XXXX.eigs) +python3 -c " +A = 4464 +B = A + 65536 +L = ['pad is 1'] * B +L[0] = 'x is 0'; L[A-1] = 'x is 111'; L[B-1] = 'x is 222' +body = ['r1 is what is x at %d' % A, 'print of r1', + 'r2 is what is x at %d' % B, 'print of r2', + 'boom is undefined_xyz_at_%d' % B] +print('\n'.join(L + body))" > "$LW_FILE" +TOTAL=$((TOTAL + 3)) +# Interpreter tier +LW_INT=$(EIGS_JIT_OFF=1 ./eigenscript "$LW_FILE" &1) +# JIT tier (thresholds forced low so any hot region compiles) +LW_JIT=$(EIGS_JIT_ENTRY_THRESHOLD=1 EIGS_JIT_ITER_THRESHOLD=10 EIGS_JIT_OSR_THRESHOLD=50 \ + ./eigenscript "$LW_FILE" &1) +# 'what is x at 4464' must be 111 (not 222, the value that wrapped onto it). +LW_R1=$(printf '%s\n' "$LW_INT" | sed -n '1p') +LW_R2=$(printf '%s\n' "$LW_INT" | sed -n '2p') +# The error is on a line past 65535; a wrapped operand would report line % +# 65536 (a small number). The invariant is simply: reported line > 65535. +LW_ERRLINE=$(printf '%s\n' "$LW_INT" | grep -oE "Error line [0-9]+" | head -1 | grep -oE "[0-9]+") +if [ "$LW_R1" = "111" ] && [ "$LW_R2" = "222" ]; then + PASS=$((PASS + 1)); echo " PASS: 'what is x at L' correct past line 65535 (111, 222)" +else + FAIL=$((FAIL + 1)); echo " FAIL: temporal query wrapped (r1='$LW_R1' r2='$LW_R2', want 111/222)" +fi +if [ -n "$LW_ERRLINE" ] && [ "$LW_ERRLINE" -gt 65535 ]; then + PASS=$((PASS + 1)); echo " PASS: error line reported as $LW_ERRLINE (> 65535, not wrapped)" +else + FAIL=$((FAIL + 1)); echo " FAIL: error line wrapped ('$LW_ERRLINE', want > 65535)" +fi +if [ "$(printf '%s\n' "$LW_JIT" | sed -n '1,2p' | tr '\n' ',')" = "111,222," ]; then + PASS=$((PASS + 1)); echo " PASS: JIT tier agrees with interpreter past line 65535" +else + FAIL=$((FAIL + 1)); echo " FAIL: JIT/interpreter divergence past line 65535" + printf '%s\n' "$LW_JIT" | head -3 +fi +rm -f "$LW_FILE" + # [116] Silent-tolerance audit batch-2 (#497/#498/#499/#501/#502/#511/#512). # Builtins that used to return a silent null/0/""/wrong-order on invalid # input now raise a catchable error from the closed error-kind set: matmul