From 622b10836c8240a7f8d263801254e57ce177efdb Mon Sep 17 00:00:00 2001 From: x Date: Wed, 30 Sep 2026 20:40:16 -0500 Subject: [PATCH 1/3] A multi-line binary operator's runtime error reports the operator's line (#1425) Owner decision on #1425, option (a): a runtime error raised by a binary operation whose operands span lines reports the physical line of the OPERATOR in e.line, the `Error line N` header, the excerpt and caret, and the traceback. Temporal filing (#1381, the first line) is unchanged. x is 0 z is 1 / ( 0) Before: `Error line 3: division by zero`, no excerpt (the `/` column does not exist on line 3). After: `Error line 2`, excerpt of line 2, caret under `/`. Mechanism: the VM line when an op runs is the last OP_LINE executed in the frame (a call's return restores the caller's line since #1424/#1434). The compiler now tracks the min/max line its right operand REQUESTED from emit_line (compile_operand_then_op). Only when that operand requested a line other than the operator's does it re-stamp the operator's line before the op. Single-line code therefore compiles to identical bytecode (244 test programs and 78 lib modules: 0 differing dumps vs ea9ee90). Used by AST_BINOP and the compound-assignment ops on a field and an index (`x += ...` on a name already desugars to AST_BINOP). An f-string's lexer-synthesized `+` keeps the old stamping (binop.synth); an operator written inside an interpolation follows the rule. Tests: tests/test_operator_line.eigs (22 rows: / % + - * & <, a chain whose second + faults, a multi-line call operand on each side, an operator on the line after its left operand, += on a name/field/index, an operator inside an f-string interpolation, hot JIT callee and OSR loop rows, and #1381 filing) plus two uncaught fixtures pinning header, excerpt, caret and traceback, all enrolled in [0g] under the interpreter, the JIT and forced OSR. Base ea9ee90: 16 of 22 rows fail; both fixtures report the operand's line. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KC99CmwatKssQYggkBCgwF --- changes/changed/1425-operator-line.md | 8 ++ docs/SPEC.md | 25 ++++ src/compiler.c | 55 +++++++-- src/eigenscript.h | 2 +- src/parser.c | 1 + tests/operator_line_fn.eigs | 16 +++ tests/operator_line_module.eigs | 10 ++ tests/run_all_tests.sh | 44 ++++--- tests/test_doc_examples.py | 2 +- tests/test_operator_line.eigs | 161 ++++++++++++++++++++++++++ 10 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 changes/changed/1425-operator-line.md create mode 100644 tests/operator_line_fn.eigs create mode 100644 tests/operator_line_module.eigs create mode 100644 tests/test_operator_line.eigs diff --git a/changes/changed/1425-operator-line.md b/changes/changed/1425-operator-line.md new file mode 100644 index 00000000..f68b6b8e --- /dev/null +++ b/changes/changed/1425-operator-line.md @@ -0,0 +1,8 @@ +- A runtime error raised by a binary operator whose operands span lines reports the OPERATOR's line (#1425, + decided there). `z is 1 / (` on line 2 with its operand `0)` on line 3 used to report `Error line 3` (the line + the right operand ended on) with no excerpt; it now reports line 2 in `e.line`, the `Error line N` header, the + excerpt and caret, and the traceback. This covers every binary operator, comparisons and the operator of a + compound assignment (`+=`), including one written inside an f-string interpolation, in the interpreter, the JIT + and OSR. The compiler re-stamps the operator's line only when the right operand stamped another line, so + single-line code compiles to the same bytecode. A recorded tape gains an `L` record before such an operator. + Temporal filing (a statement's first line, #1381) is unchanged. diff --git a/docs/SPEC.md b/docs/SPEC.md index dc41a2cb..b9a6c7e2 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1064,6 +1064,31 @@ catch e: 5 ``` +When the faulting sub-expression is a binary operator (`+`, `-`, `*`, `/`, +`%`, a comparison, a bitwise operator, or the operator of a compound +assignment such as `+=`) whose operands span lines, the reported line is the +**operator's** line, not the line its right operand ends on (#1425). The `/` +below is on line 3; its operand ends on line 4. The excerpt and caret point at +the operator too, and so does the traceback: + +```eigenscript +x is 0 +try: + z is 1 / ( + x) +catch e: + print of e.line +``` +```output +3 +``` + +This rule covers binary operators only. Temporal filing is unchanged (the +statement's first line). Other constructs that span lines (call arguments, +indexing, and the concatenation an f-string is lowered to) are not decided by +it; an operator written inside an f-string interpolation is a binary operator +and follows it. + ```eigenscript try: throw of "custom failure" diff --git a/src/compiler.c b/src/compiler.c index 0ad875ba..b6b674a0 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -10,6 +10,7 @@ #include #include #include +#include #if !EIGENSCRIPT_FREESTANDING /* NB: EIGENSCRIPT_FREESTANDING is always DEFINED (eigenscript.h defaults it to * 0), so this must test its VALUE. `#ifndef` here silently excluded both @@ -115,6 +116,9 @@ typedef struct Compiler { * stamp the line". Reset at every basic-block boundary * (patch_jump targets, emit_loop, loop_start capture, * after CALL/DISPATCH/RETURN, fn entry). */ + int req_lo, req_hi; /* #1425: min/max line passed to emit_line (requested, + * deduped or not) since compile_operand_then_op opened + * its window; meaningful only inside that window. */ int dispatch_rebound; /* #459, root-computed and copied to fn * compilers: the unit binds `dispatch` * somewhere (any scope) or references @@ -530,6 +534,8 @@ static int capture_loop_start(Compiler *c) { * parent's line). Emit only when the line changes; reset at every * basic-block boundary so we never *skip* a stamp the runtime needs. */ static void emit_line(Compiler *c, int line) { + if (line < c->req_lo) c->req_lo = line; /* #1425: see compile_operand_then_op */ + if (line > c->req_hi) c->req_hi = line; if (c->last_line == line) return; emit_op_u32(c, OP_LINE, (uint32_t)line, line); /* #630: 32-bit — was (uint16_t), wrapped past line 65535 */ c->last_line = line; @@ -545,6 +551,27 @@ static void restamp_line(Compiler *c, int line) { emit_line(c, line); /* emit_line's own dedup is exactly the test */ } +/* #1425: a runtime error raised by a binary operator reports the OPERATOR's + * line (`op_line`), not the line its right operand ended on. Compiles `rhs`, + * then emits `opc` (0 = none). The VM's line when `opc` runs is the last + * OP_LINE executed in this frame (a call's return restores the caller's line, + * #1424/#1434), and every stamp `rhs` can execute here is one it requested + * from emit_line. So when `rhs` requested only `op_line`, the VM is already + * there and nothing is emitted: single-line code compiles byte-identically. + * Otherwise `op_line` is re-stamped before the op (deduped, so an operand that + * itself ended on `op_line` adds nothing). */ +static void compile_operand_then_op(Compiler *c, ASTNode *rhs, uint8_t opc, int op_line) { + int lo = c->req_lo, hi = c->req_hi; + c->req_lo = INT_MAX; c->req_hi = INT_MIN; + compile_node(c, rhs); + int spans = c->req_lo != op_line || c->req_hi != op_line; + if (c->req_lo > lo) c->req_lo = lo; + if (c->req_hi < hi) c->req_hi = hi; + if (!opc) return; + if (spans) emit_line(c, op_line); + emit(c, opc, op_line); +} + /* ---- Constant helpers ---- */ /* u16 operand ceiling: constant indices are encoded as 16-bit operands @@ -2160,11 +2187,16 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { patch_jump(c, jump); break; } - /* Normal binary op */ + /* Normal binary op. An f-string's `+` (lexer-synthesized) keeps + * the old stamping: #1425 decided written operators only. */ compile_node(c, node->data.binop.left); - compile_node(c, node->data.binop.right); uint8_t opc = binop_to_opcode(op); - if (opc) emit(c, opc, node->line); + if (node->data.binop.synth) { + compile_node(c, node->data.binop.right); + if (opc) emit(c, opc, node->line); + } else { + compile_operand_then_op(c, node->data.binop.right, opc, node->line); + } break; } @@ -2944,9 +2976,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { /* Compound: target index → DUP2 → INDEX_GET → expr → BINOP → INDEX_SET */ emit(c, OP_DUP2, node->line); emit(c, OP_INDEX_GET, node->line); - compile_node(c, node->data.index_assign.expr); - uint8_t op = binop_to_opcode(node->data.index_assign.compound_op); - emit(c, op, node->line); + compile_operand_then_op(c, node->data.index_assign.expr, + binop_to_opcode(node->data.index_assign.compound_op), node->line); } else { compile_node(c, node->data.index_assign.expr); } @@ -3017,8 +3048,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { if (cop[0]) emit_op_u16_u16_u16(c, OP_LOCAL_IDX_DOT_GET, (uint16_t)slot, (uint16_t)iv, (uint16_t)name_idx, node->line); - compile_node(c, node->data.dot_assign.expr); - if (cop[0]) emit(c, binop_to_opcode(cop), node->line); + compile_operand_then_op(c, node->data.dot_assign.expr, + cop[0] ? binop_to_opcode(cop) : 0, node->line); emit_op_u16_u16_u16(c, OP_LOCAL_IDX_DOT_SET, (uint16_t)slot, (uint16_t)iv, (uint16_t)name_idx, node->line); break; @@ -3036,8 +3067,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { int idx = add_string_constant(c, node->data.dot_assign.key); if (cop[0]) emit_op_u16_u16(c, OP_LOCAL_DOT_GET, (uint16_t)slot, (uint16_t)idx, node->line); - compile_node(c, node->data.dot_assign.expr); - if (cop[0]) emit(c, binop_to_opcode(cop), node->line); + compile_operand_then_op(c, node->data.dot_assign.expr, + cop[0] ? binop_to_opcode(cop) : 0, node->line); emit_op_u16_u16(c, OP_LOCAL_DOT_SET, (uint16_t)slot, (uint16_t)idx, node->line); break; } @@ -3048,8 +3079,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { /* target → DUP → DOT_GET → expr → BINOP → DOT_SET */ emit(c, OP_DUP, node->line); emit_op_u16(c, OP_DOT_GET, (uint16_t)idx, node->line); - compile_node(c, node->data.dot_assign.expr); - emit(c, binop_to_opcode(cop), node->line); + compile_operand_then_op(c, node->data.dot_assign.expr, + binop_to_opcode(cop), node->line); } else { compile_node(c, node->data.dot_assign.expr); } diff --git a/src/eigenscript.h b/src/eigenscript.h index facb26a8..3c7343d1 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -213,7 +213,7 @@ struct ASTNode { double num; char *str; struct { char *name; } ident; - struct { char op[4]; ASTNode *left; ASTNode *right; } binop; + struct { char op[4]; ASTNode *left; ASTNode *right; uint8_t synth; } binop; /* synth: f-string `+` (#1425) */ struct { char op[4]; ASTNode *operand; } unary; struct { char *name; ASTNode *expr; int local_only; } assign; struct { ASTNode *left; ASTNode *right; } relation; diff --git a/src/parser.c b/src/parser.c index bf587d08..8b5099de 100644 --- a/src/parser.c +++ b/src/parser.c @@ -1153,6 +1153,7 @@ static ASTNode* parse_addition(Parser *p) { ASTNode *right = parse_multiply(p); ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "%s", op); + n->data.binop.synth = op_tok->synth; /* f-string `+` (#1425) */ n->data.binop.left = left; n->data.binop.right = right; left = n; diff --git a/tests/operator_line_fn.eigs b/tests/operator_line_fn.eigs new file mode 100644 index 00000000..89348998 --- /dev/null +++ b/tests/operator_line_fn.eigs @@ -0,0 +1,16 @@ +# #1425 uncaught, inside a function frame, hot: `chain` is JIT-compiled after +# 6001 calls from a module loop; on the last call the SECOND `+` (line 7) +# raises. Pre-fix: "Error line 8", the excerpt of line 8, "at chain (line 8)". +# LINE-NUMBER SENSITIVE (the [0g] runner section greps these numbers). +define chain(a, b, c) as: + r is (a + + b + + c) + return r +k is 6001 +loop while k > 0: + k is k - 1 + s is k + if k == 0: + s is "s" + w is chain of [k, k, s] diff --git a/tests/operator_line_module.eigs b/tests/operator_line_module.eigs new file mode 100644 index 00000000..63a3419d --- /dev/null +++ b/tests/operator_line_module.eigs @@ -0,0 +1,10 @@ +# #1425 uncaught, module frame, hot: a 6001-iteration module loop (past the +# 5000 OSR threshold); the `/` on line 8 has its right operand on line 9 and +# raises on the last iteration. Pre-fix: "Error line 9" and no excerpt. +# LINE-NUMBER SENSITIVE (the [0g] runner section greps these numbers). +k is 6001 +d is 1 +loop while k > 0: + r is 1 / ( + k - d) + k is k - 1 diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index aecb3ab1..c80949d2 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -692,30 +692,38 @@ check_binary_fingerprint # #1434: line_after_callback_halt's callback prints its own swallowed error # first (#1426), with the same frame lines, so a fixture's frame lines are # matched only from its header line onward. +# #1425: test_operator_line.eigs and operator_line_*.eigs pin a multi-line +# binary operator's error to the OPERATOR's line (pre-fix: the right operand's +# last line); the operator_line rows also pin the excerpt and caret lines. +# A fixture's expected lines are separated by ';' (an excerpt line has '|'). for LAR_TIER in "JIT" "EIGS_JIT_OFF=1" "EIGS_JIT_OSR_THRESHOLD=1"; do LAR_ENV="EIGS_JIT_STATS=1"; [ "$LAR_TIER" = JIT ] || LAR_ENV="$LAR_ENV $LAR_TIER" - LAR_OUT=$($EIGS_TMO env $LAR_ENV ./eigenscript ../tests/test_line_after_return.eigs &1); LAR_RC=$? - TOTAL=$((TOTAL + 1)) - LAR_JIT=$(lar_jit_witness "$LAR_TIER" "$LAR_OUT") - if [ "$LAR_JIT" != ok ]; then - FAIL=$((FAIL + 1)); echo " FAIL: e.line after a return ($LAR_TIER): compiled nothing, so it measured the interpreter" - elif rc_ok "$LAR_RC" "$LAR_OUT" && grep -q "All tests passed" <<< "$LAR_OUT"; then - PASS=$((PASS + 1)); echo " PASS: e.line after a return ($LAR_TIER)" - else - FAIL=$((FAIL + 1)); echo " FAIL: e.line after a return ($LAR_TIER, rc=$LAR_RC)" - printf '%s\n' "$LAR_OUT" | eigs_failure_output - fi - for LAR_FX in "line_after_return_module.eigs:Error line 10:| at (line 10)" \ - "line_after_return_fn.eigs:Error line 11:| at outer (line 11)| at (line 15)" \ - "line_after_return_null.eigs:Error line 13:| at hot_null (line 13)| at (line 20)" \ - "line_after_callback_halt.eigs:Error line 9:| at hot_sb (line 9)| at (line 15)"; do + for LAR_PROG in test_line_after_return.eigs test_operator_line.eigs; do + LAR_OUT=$($EIGS_TMO env $LAR_ENV ./eigenscript "../tests/$LAR_PROG" &1); LAR_RC=$? + TOTAL=$((TOTAL + 1)) + LAR_JIT=$(lar_jit_witness "$LAR_TIER" "$LAR_OUT") + if [ "$LAR_JIT" != ok ]; then + FAIL=$((FAIL + 1)); echo " FAIL: e.line, $LAR_PROG ($LAR_TIER): compiled nothing, so it measured the interpreter" + elif rc_ok "$LAR_RC" "$LAR_OUT" && grep -q "All tests passed" <<< "$LAR_OUT"; then + PASS=$((PASS + 1)); echo " PASS: e.line, $LAR_PROG ($LAR_TIER)" + else + FAIL=$((FAIL + 1)); echo " FAIL: e.line, $LAR_PROG ($LAR_TIER, rc=$LAR_RC)" + printf '%s\n' "$LAR_OUT" | eigs_failure_output + fi + done + for LAR_FX in "line_after_return_module.eigs:Error line 10:; at (line 10)" \ + "line_after_return_fn.eigs:Error line 11:; at outer (line 11); at (line 15)" \ + "line_after_return_null.eigs:Error line 13:; at hot_null (line 13); at (line 20)" \ + "line_after_callback_halt.eigs:Error line 9:; at hot_sb (line 9); at (line 15)" \ + "operator_line_module.eigs:Error line 8:; 8 | r is 1 / (; | ^; at (line 8)" \ + "operator_line_fn.eigs:Error line 7:; 7 | b +; | ^; at chain (line 7); at (line 16)"; do LAR_FILE=${LAR_FX%%:*}; LAR_WANT=${LAR_FX#*:} LAR_OUT=$($EIGS_TMO env $LAR_ENV ./eigenscript "../tests/$LAR_FILE" &1); LAR_RC=$? LAR_MISS="" - LAR_REST="$LAR_WANT|" - LAR_TAIL=$(printf '%s\n' "$LAR_OUT" | sed 's/: .*/:/' | awk -v h="${LAR_WANT%%|*}" 'f || $0 == h { f = 1; print }') + LAR_REST="$LAR_WANT;" + LAR_TAIL=$(printf '%s\n' "$LAR_OUT" | sed 's/: .*/:/' | awk -v h="${LAR_WANT%%;*}" 'f || $0 == h { f = 1; print }') while [ -n "$LAR_REST" ]; do - LAR_LINE=${LAR_REST%%|*}; LAR_REST=${LAR_REST#*|} + LAR_LINE=${LAR_REST%%;*}; LAR_REST=${LAR_REST#*;} grep -qxF -- "$LAR_LINE" <<< "$LAR_TAIL" || LAR_MISS="$LAR_MISS [$LAR_LINE]" done TOTAL=$((TOTAL + 1)) diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index 917db73d..da6e2fde 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -326,7 +326,7 @@ def is_asan_build(): # file fences paired fragment nocheck value-comments "README.md": ( 8, 3, 5, 0, 0), "docs/llms.txt": ( 9, 6, 2, 1, 0), - "docs/SPEC.md": ( 83, 79, 1, 3, 0), + "docs/SPEC.md": ( 84, 80, 1, 3, 0), "docs/COMPARISON.md": ( 19, 18, 1, 0, 0), "docs/CONCURRENCY.md": ( 7, 7, 0, 0, 0), "docs/STDLIB.md": ( 15, 7, 8, 0, 0), diff --git a/tests/test_operator_line.eigs b/tests/test_operator_line.eigs new file mode 100644 index 00000000..b1736e1a --- /dev/null +++ b/tests/test_operator_line.eigs @@ -0,0 +1,161 @@ +# #1425: a runtime error raised by a binary operator whose operands span lines +# reports the OPERATOR's physical line (e.line here; the uncaught header, +# excerpt, caret and traceback in operator_line_*.eigs). Pre-fix each row +# reported the line its right operand ENDED on. Temporal filing (#1381, the +# statement's first line) is unchanged: the last rows pin it. +# The runner ([0g]) runs this file under the default JIT, EIGS_JIT_OFF=1 and +# forced OSR; the hot rows run past the JIT entry and OSR thresholds. +# LINE-NUMBER SENSITIVE: every expected line is a literal (the line of the +# operator in the row above it). Append new rows only after the last row. +load_file of "lib/test.eigs" +define zero(n) as: + return 0 +define hot_div(k) as: + r is 1 / ( + k) + return r +define hot_chain(k, s) as: + r is (k + + k + + s) + return r +x is 0 +s is "s" +o is {"v": 1} +lst is [1] +got is 0 + +try: + z is 1 / ( + x) +catch e: + got is e.line +assert_eq of [got, 29, "/ with a multi-line right operand"] +try: + z is 7 % ( + x) +catch e: + got is e.line +assert_eq of [got, 35, "% with a multi-line right operand"] +try: + z is 1 + ( + s) +catch e: + got is e.line +assert_eq of [got, 41, "+ type error"] +try: + z is 1 < ( + s) +catch e: + got is e.line +assert_eq of [got, 47, "comparison type error"] +try: + z is (1 + + 2 + + s) +catch e: + got is e.line +assert_eq of [got, 54, "chain a + b + c: the SECOND + faults"] +try: + z is 1 / (zero of [ + 4]) +catch e: + got is e.line +assert_eq of [got, 60, "right operand is a multi-line call"] +try: + z is (zero of [ + 4]) / x +catch e: + got is e.line +assert_eq of [got, 67, "left operand is a multi-line call, operator after it"] +try: + z is (1 + / x) +catch e: + got is e.line +assert_eq of [got, 73, "operator on the line after its left operand"] +try: + x += ( + s) +catch e: + got is e.line +assert_eq of [got, 78, "compound += on a name"] +try: + o.v += ( + s) +catch e: + got is e.line +assert_eq of [got, 84, "compound += on a field"] +try: + lst[0] += ( + s) +catch e: + got is e.line +assert_eq of [got, 90, "compound += on an index"] + +# Hot rows: the operator runs in a JIT thunk and in an OSR-compiled loop. +k is 6001 +got is 0 +try: + loop while k > 0: + r is 1 / ( + k - 1) + k is k - 1 +catch e: + got is e.line +assert_eq of [got, 101, "OSR loop: multi-line /"] +assert_eq of [k, 1, "OSR loop raised on its last iteration"] +k is 6001 +got is 0 +try: + loop while k > 0: + r is hot_div of (k - 1) + k is k - 1 +catch e: + got is e.line +assert_eq of [got, 14, "compiled callee: multi-line /"] +assert_eq of [k, 1, "compiled callee raised on its last call"] +k is 6001 +got is 0 +try: + loop while k > 0: + k is k - 1 + if k == 0: + r is hot_chain of [k, s] + else: + r is hot_chain of [k, k] +catch e: + got is e.line +assert_eq of [got, 19, "compiled callee: the second + of a chain"] + +# #1381 unchanged: a multi-line statement is FILED under its first line. +y is 1 +y is (2 + + 3) +assert_eq of [what is y at 133, 5, "filing: first line of a multi-line +"] +assert_eq of [what is y at 132, 1, "filing: the line before is the old value"] +try: + z is 1 - ( + s) +catch e: + got is e.line +assert_eq of [got, 138, "- type error"] +try: + z is 2 * ( + s) +catch e: + got is e.line +assert_eq of [got, 144, "* type error"] +try: + z is 1 & ( + s) +catch e: + got is e.line +assert_eq of [got, 150, "bitwise & type error"] +try: + z is f"{1 / ( + x)}" +catch e: + got is e.line +assert_eq of [got, 156, "an operator written inside an f-string interpolation"] +test_summary of null From 5b25a8927e842cc4f6c7e81608c6ebb3f489a417 Mon Sep 17 00:00:00 2001 From: x Date: Wed, 30 Sep 2026 20:56:05 -0500 Subject: [PATCH 2/3] docs: TRACE.md and llms.txt state the #1425 operator-line rule TRACE.md: the L record a multi-line binary operator adds (or moves) on the tape, and that single-line code and f-string concatenation emit nothing extra. llms.txt: the error-line sentence names the operator's line for a binary operator whose operands span lines. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KC99CmwatKssQYggkBCgwF --- docs/TRACE.md | 8 ++++++++ docs/llms.txt | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/TRACE.md b/docs/TRACE.md index 638b6840..85d70a03 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -85,6 +85,14 @@ EIGS_REPLAY=u.tape /eigenscript prog.eigs # prints new.out The only output that follows the replaying binary is output that reads line identity: `what is x at N`, `e.line` and error headers. +A binary operator whose right operand stamped a different line re-stamps the +operator's own line just before the operation (#1425), so that a runtime error +there reports the operator's line. On the tape an `L ` record +comes before such an operator. When the operator is on its statement's first +line, that record replaces the assignment's own re-stamp after the value, so +only its position moves. Single-line expressions emit nothing extra, and a `+` +that an f-string is lowered to is not re-stamped. + ### Value serialization `N` records are written with full fidelity so they can be parsed back diff --git a/docs/llms.txt b/docs/llms.txt index 934b8c99..66dfe79a 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -178,7 +178,8 @@ sqrt(2) = 1.414213562373095 `what is x at 12` / `state_at of 12` (past-line state). Bare `x at 12` is a parse error. A statement spanning several lines is filed under its FIRST line, while a runtime error inside it (`e.line`, the error header) reports - the physical line of the faulting sub-expression. Wrap hot loops in `unobserved:` to skip the observer's entropy + the physical line of the faulting sub-expression (for a binary operator + whose operands span lines, the operator's line). Wrap hot loops in `unobserved:` to skip the observer's entropy walk (a scalar's value-window sample still lands, so numeric verdicts do not change; `why`/`how` and non-numeric bindings' verdicts can). From d8e886552921090c5b24a510fc078f5e789d49f0 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Thu, 1 Oct 2026 01:58:53 -0500 Subject: [PATCH 3/3] fix: scope binary operator lines to the faulting operation (#1425) Save the executed line around a multiline binary instruction and restore it on success in the VM, JIT and OSR. Runtime state preserves short-circuit attribution; one-line code emits no new bytecode. Append and verify adjacent scope opcodes, disallow jump/handler entries into them, and restore worker trace lines without reading the main thread cache. Revert dedicated compound field/index restamping to ea9ee90 behavior. Compound field/index forms are left for a separate owner decision; plain name += remains a binary operation. Document the exact binary-only scope. Validation receipts against ea9ee90: - Bytecode: tests=244/244 lib=78/78 identical; differing=0. - Parent controls: base/fixed 20/20, head 5b25a89 3/20 in each of 3 tiers. - Final parent regression: 35/35 in each tier; eight per-expression native stamp witnesses in each compiled tier. - Targeted sections 0g/42a/42f/42f2: 304/304, 0 failed/skipped; chunks=5 bearing=5. - make test-changed BASE=ea9ee90: 1398/1398, 0 failed/skipped; chunks=38 bearing=37; full=no. - ASan targeted plus 42h, detect_leaks=1: 326/326, 0 failed/skipped; chunks=6 bearing=6; no leak report. - precheck: 18 passed, 0 failed/skipped; JIT smoke: all cases passed. - Strict differential: 99 raises, 0 silent/misattributed; 31 pins, 0 broken; valid-input 255/255 unchanged in all 3 modes; no-gfx rows skipped. - Tape diff: 246 programs x 3 tiers=738 pairs; 691 identical after pinning, 35 changed, 12 boundary-nondeterministic; exact records retained. - Strict replay: 738 recordings x 3 tiers=2214 combinations; 2115 matched, 99 documented boundary refusals, 0 stable divergences, 0 unresolved. - Critic matrix: 312 executions; 99 identical cases, 57 intended binary diagnostic changes. - Plants: head fails 17/20 parent controls per tier; missing verifier constraints fail 4/29 checks; stale MT trace save fails 1/22 checks (2000 zero-line records), restored 22/22. - jit_diff: OK (246 programs x {jit, osr} vs the interpreter; OBS examined=591 denied=12; 31 arms adjudicated; 3 ledgered), unchanged. - replay_diff: OK (246 programs record+replay; 11 at the documented boundary; 0 nondeterministic; 0 ledgered). Full suites were intentionally not run on this machine per the builder brief. Tape differences include known thread scheduling/transition deduplication, checkout paths in import warnings, and untaped mktemp paths (filed #1464). The complete changed-record inventory and native witness receipts are retained under /tmp/es-1425c-evidence; RESULT.md documents caveats. Co-Authored-By: GPT-6.1 Sol --- README.md | 5 + changes/changed/1425-operator-line.md | 16 +- docs/COMPARISON.md | 6 + docs/SPEC.md | 10 +- docs/TRACE.md | 17 +- docs/llms.txt | 10 +- src/chunk.c | 40 +++-- src/compiler.c | 39 ++--- src/jit.c | 33 +++- src/jit.h | 2 + src/vm.c | 29 +++- src/vm.h | 7 + tests/run_all_tests.sh | 16 +- tests/test_opcode_abi.c | 4 + tests/test_operator_line.eigs | 4 +- tests/test_operator_line_parent.eigs | 234 ++++++++++++++++++++++++++ tests/test_trace_mt.sh | 7 +- tests/trace_mt_single.eigs | 3 +- 18 files changed, 415 insertions(+), 67 deletions(-) create mode 100644 tests/test_operator_line_parent.eigs diff --git a/README.md b/README.md index ab275b27..625f246b 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,11 @@ scope are safe: anything stored into a binding or a container that outlives the window is promoted to the heap at the store (#873) — the arena reclaims only the unstored intermediates. +Runtime errors from multiline binary operations report the operator's physical +line. Successful binary operations restore the previous line attribution; +compound field/index assignments retain their prior behavior. See +[the error-line rule](docs/SPEC.md#error-handling) for the binary-only scope. + ## Standard Library Pure EigenScript libraries under `lib/`: diff --git a/changes/changed/1425-operator-line.md b/changes/changed/1425-operator-line.md index f68b6b8e..22d149ca 100644 --- a/changes/changed/1425-operator-line.md +++ b/changes/changed/1425-operator-line.md @@ -1,8 +1,8 @@ -- A runtime error raised by a binary operator whose operands span lines reports the OPERATOR's line (#1425, - decided there). `z is 1 / (` on line 2 with its operand `0)` on line 3 used to report `Error line 3` (the line - the right operand ended on) with no excerpt; it now reports line 2 in `e.line`, the `Error line N` header, the - excerpt and caret, and the traceback. This covers every binary operator, comparisons and the operator of a - compound assignment (`+=`), including one written inside an f-string interpolation, in the interpreter, the JIT - and OSR. The compiler re-stamps the operator's line only when the right operand stamped another line, so - single-line code compiles to the same bytecode. A recorded tape gains an `L` record before such an operator. - Temporal filing (a statement's first line, #1381) is unchanged. +- A runtime error raised by a written binary operator whose operands span lines reports the operator's + physical line in `e.line`, the error header, excerpt/caret and traceback (#1425). This includes plain-name + compound assignment (`x += rhs`) and written operators inside f-string interpolations, in the interpreter, + JIT and OSR. After success the compiler's temporary operator stamp restores the previous runtime line, + preserving attribution for an enclosing unary operation, index or call, including conditional operands. + Single-line code compiles to identical bytecode. Interpreted execution adds a tape line record before + the operation and a restoring line record on success. Temporal filing remains the statement's first line. Dedicated compound + field/index assignments retain their prior attribution pending a separate owner decision. diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 6c996d8f..db9454fa 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -269,6 +269,12 @@ Not Found ## Error handling +A runtime error raised by a multiline binary operation reports the operator's +physical line. Plain-name compound assignments follow their desugared binary +operation; dedicated compound field/index assignments keep their prior line +attribution. A successful binary operation restores the previous runtime line, +so an enclosing unary, index or call error keeps its prior attribution. + Python: ```python diff --git a/docs/SPEC.md b/docs/SPEC.md index b9a6c7e2..27c88644 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1065,8 +1065,8 @@ catch e: ``` When the faulting sub-expression is a binary operator (`+`, `-`, `*`, `/`, -`%`, a comparison, a bitwise operator, or the operator of a compound -assignment such as `+=`) whose operands span lines, the reported line is the +`%`, a comparison, a bitwise operator, or a plain-name compound +assignment such as `x += rhs`) whose operands span lines, the reported line is the **operator's** line, not the line its right operand ends on (#1425). The `/` below is on line 3; its operand ends on line 4. The excerpt and caret point at the operator too, and so does the traceback: @@ -1083,7 +1083,11 @@ catch e: 3 ``` -This rule covers binary operators only. Temporal filing is unchanged (the +A successful binary operation restores the line attribution that preceded its +operator stamp, so an enclosing unary operation, index or call keeps its prior +attribution. Compound field/index assignments (`obj.key += rhs`, `xs[i] += rhs`) +keep their prior attribution; their location policy awaits a separate owner +decision. This rule covers binary operators only. Temporal filing is unchanged (the statement's first line). Other constructs that span lines (call arguments, indexing, and the concatenation an f-string is lowered to) are not decided by it; an operator written inside an f-string interpolation is a binary operator diff --git a/docs/TRACE.md b/docs/TRACE.md index 85d70a03..13dc38f8 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -85,13 +85,16 @@ EIGS_REPLAY=u.tape /eigenscript prog.eigs # prints new.out The only output that follows the replaying binary is output that reads line identity: `what is x at N`, `e.line` and error headers. -A binary operator whose right operand stamped a different line re-stamps the -operator's own line just before the operation (#1425), so that a runtime error -there reports the operator's line. On the tape an `L ` record -comes before such an operator. When the operator is on its statement's first -line, that record replaces the assignment's own re-stamp after the value, so -only its position moves. Single-line expressions emit nothing extra, and a `+` -that an f-string is lowered to is not re-stamped. +A written binary operator whose right operand stamped a different line temporarily +stamps the operator's own line just before the operation (#1425), so a runtime +error there reports the operator's line. Under interpreted execution, the tape +gains an `L ` record before the operation and, on success, an +`L ` record that +restores the line that was active after the operand. Assignment filing then +stamps the statement's first line as usual. Single-line expressions emit +nothing extra. Synthesized f-string concatenation and dedicated compound +field/index assignments keep their previous stamping; a written binary +operator inside their operands still follows the binary rule. ### Value serialization diff --git a/docs/llms.txt b/docs/llms.txt index 66dfe79a..6232b7a7 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -177,9 +177,13 @@ sqrt(2) = 1.414213562373095 - Temporal reads: `prev of x` (value before the last assignment), `what is x at 12` / `state_at of 12` (past-line state). Bare `x at 12` is a parse error. A statement spanning several lines is filed under its FIRST - line, while a runtime error inside it (`e.line`, the error header) reports - the physical line of the faulting sub-expression (for a binary operator - whose operands span lines, the operator's line). Wrap hot loops in `unobserved:` to skip the observer's entropy + line. A runtime error raised by a binary operator whose operands span lines + reports the operator's physical line in `e.line` and the error header, + including plain `x += rhs`. + A successful binary operation restores the previous line; enclosing unary, + index and call errors keep their prior attribution. Compound field/index + assignments keep their prior attribution pending a separate owner decision. + Wrap hot loops in `unobserved:` to skip the observer's entropy walk (a scalar's value-window sample still lands, so numeric verdicts do not change; `why`/`how` and non-numeric bindings' verdicts can). diff --git a/src/chunk.c b/src/chunk.c index b73002fb..a9932c67 100644 --- a/src/chunk.c +++ b/src/chunk.c @@ -358,7 +358,7 @@ const char *op_name(uint8_t op) { N(OP_LOOP_STALL_CHECK) N(OP_LOOP_CAP_CHECK) N(OP_IMPORT) N(OP_MATCH) N(OP_LISTCOMP_BEGIN) N(OP_LISTCOMP_APPEND) - N(OP_LINE) N(OP_WIDE) N(OP_DISPATCH) + N(OP_LINE) N(OP_BINARY_LINE) N(OP_BINARY_LINE_END) N(OP_WIDE) N(OP_DISPATCH) N(OP_LOCAL_DOT_GET) N(OP_LOCAL_DOT_SET) N(OP_LOCAL_IDX_GET) N(OP_LOCAL_IDX_DOT_GET) N(OP_LOCAL_IDX_DOT_SET) N(OP_INTERROGATE_NAMED) N(OP_INTERROGATE_NAMED_AT) @@ -401,7 +401,7 @@ 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 == OP_LINE && i + 3 < chunk->code_len) { + if ((op == OP_LINE || op == OP_BINARY_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) | @@ -528,7 +528,7 @@ static int op_verify_operands(uint8_t op8, VerifyRole roles[3]) { * disassembler both special-case it BEFORE consulting this u16-strided * table, so this arm is unreachable — listed for the exhaustiveness * gate, not for behavior. */ - case OP_LINE: + case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END: return 0; case OP_COUNT: /* sentinel — callers bounds-check first */ return 0; @@ -715,7 +715,7 @@ static StackEffect op_verify_stack_effect(uint8_t op8, int operand0) { /* Stack-neutral. BREAK/CONTINUE set a flag nothing reads — the compiler * lowers both to jumps — so they are pure no-ops here too. */ - case OP_LINE: case OP_WIDE: + case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END: case OP_WIDE: case OP_TRY_END: case OP_BREAK: case OP_CONTINUE: case OP_LOOP_ENV_FRESH: case OP_LOOP_ENV_END: case OP_LOOP_ENV_CLEAR: case OP_UNOBSERVED_BEGIN: case OP_UNOBSERVED_END: @@ -749,11 +749,23 @@ static int chunk_verify_impl(EigsChunk *chunk, char *why, size_t whyn) { while (i < n) { uint8_t op = code[i]; if (op >= OP_COUNT) { ok = 0; break; } - is_start[i] = 1; + if (!is_start[i]) is_start[i] = 1; last_op = op; + /* #1425: one scalar save slot is sufficient only for an adjacent, + * non-calling binary scope. Its interior is a valid fall-through + * boundary (2), but not a legal jump/handler entry (1). */ + if (op == OP_BINARY_LINE) { + if (i + 7 > n || code[i + 6] != OP_BINARY_LINE_END) { ok = 0; break; } + uint8_t bin = code[i + 5]; + if (!((bin >= OP_ADD && bin <= OP_SHR) || + (bin >= OP_EQ && bin <= OP_GE))) { ok = 0; break; } + is_start[i + 5] = is_start[i + 6] = 2; + } else if (op == OP_BINARY_LINE_END && is_start[i] != 2) { + ok = 0; break; + } /* #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) { + if (op == OP_LINE || op == OP_BINARY_LINE) { int end = i + 1 + 4; if (end > n) { ok = 0; break; } i = end; @@ -792,7 +804,7 @@ static int chunk_verify_impl(EigsChunk *chunk, char *why, size_t whyn) { /* Pass 2: every jump must land on an in-range instruction boundary. */ for (int t = 0; ok && t < ntargets; t++) { int tgt = targets[t]; - if (tgt < 0 || tgt >= n || !is_start[tgt]) ok = 0; + if (tgt < 0 || tgt >= n || is_start[tgt] != 1) ok = 0; } /* Pass 3: execution must not be able to run off the end. Pass 2 pins every @@ -862,7 +874,7 @@ static int chunk_verify_impl(EigsChunk *chunk, char *why, size_t whyn) { int h = height[off]; uint8_t op = code[off]; int end, operand0 = 0, target = -1; - if (op == OP_LINE) { + if (op == OP_LINE || op == OP_BINARY_LINE) { end = off + 1 + 4; } else { VerifyRole roles[3]; @@ -956,7 +968,7 @@ void chunk_arm_temporal(const EigsChunk *chunk) { int n = chunk->code_len, i = 0; while (i < n) { uint8_t op = code[i]; - if (op == OP_LINE) { i += 1 + 4; continue; } + if (op == OP_LINE || op == OP_BINARY_LINE) { i += 1 + 4; continue; } VerifyRole roles[3]; int nops = op_verify_operands(op, roles); if (op == OP_INTERROGATE_NAMED || op == OP_INTERROGATE_NAMED_AT || @@ -1009,7 +1021,9 @@ void chunk_scan_leaf_accessor(EigsChunk *c) { while (ip < end) { uint8_t op = *ip++; switch (op) { - case OP_LINE: + case OP_BINARY_LINE_END: + break; + case OP_LINE: case OP_BINARY_LINE: if (ip + 4 > end) return; /* #630: 32-bit operand */ ip += 4; break; @@ -1358,7 +1372,7 @@ int chunk_has_reader_opcode(const EigsChunk *chunk) { static int chunk_step_ip(const EigsChunk *chunk, int i) { uint8_t op = chunk->code[i]; i++; - if (op == OP_LINE) { + if (op == OP_LINE || op == OP_BINARY_LINE) { i += 4; /* #630: 32-bit operand */ } else if (op < OP_COUNT) { VerifyRole roles[3]; @@ -1369,7 +1383,7 @@ static int chunk_step_ip(const EigsChunk *chunk, int i) { /* Next instruction offset at or after `i` that is not OP_LINE. */ static int chunk_skip_lines(const EigsChunk *chunk, int i) { - while (i < chunk->code_len && chunk->code[i] == OP_LINE) + while (i < chunk->code_len && (chunk->code[i] == OP_LINE || chunk->code[i] == OP_BINARY_LINE || chunk->code[i] == OP_BINARY_LINE_END)) i = chunk_step_ip(chunk, i); return i; } @@ -1418,7 +1432,7 @@ int chunk_scan_static_loads(const EigsChunk *chunk, uint8_t op = chunk->code[i]; int nops = 0; VerifyRole roles[3]; - if (op != OP_LINE && op < OP_COUNT) nops = op_verify_operands(op, roles); + if (op != OP_LINE && op != OP_BINARY_LINE && op < OP_COUNT) nops = op_verify_operands(op, roles); if (op == OP_IMPORT) { if (nops != 1 || i + 3 > chunk->code_len) return 1; diff --git a/src/compiler.c b/src/compiler.c index b6b674a0..f78d11e4 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -395,7 +395,7 @@ static int op_stack_effect(uint8_t op8) { case OP_UNOBSERVED_BEGIN: case OP_UNOBSERVED_END: return 0; /* Line: no stack change */ - case OP_LINE: + case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END: return 0; /* Break/continue: no stack change (compiler emits as jumps) */ case OP_BREAK: case OP_CONTINUE: @@ -551,15 +551,13 @@ static void restamp_line(Compiler *c, int line) { emit_line(c, line); /* emit_line's own dedup is exactly the test */ } -/* #1425: a runtime error raised by a binary operator reports the OPERATOR's - * line (`op_line`), not the line its right operand ended on. Compiles `rhs`, - * then emits `opc` (0 = none). The VM's line when `opc` runs is the last - * OP_LINE executed in this frame (a call's return restores the caller's line, - * #1424/#1434), and every stamp `rhs` can execute here is one it requested - * from emit_line. So when `rhs` requested only `op_line`, the VM is already - * there and nothing is emitted: single-line code compiles byte-identically. - * Otherwise `op_line` is re-stamped before the op (deduped, so an operand that - * itself ended on `op_line` adds nothing). */ +/* #1425: scope the operator line to the binary instruction. Saving the + * runtime line, rather than guessing from last_line, preserves whichever + * short-circuit/conditional operand path executed. Calls also invalidate + * last_line. Only a differing stamp needs the scope; one-line bytecode stays + * identical. The markers surround one non-calling instruction, so scopes + * cannot nest or suspend. An error unwinds before BINARY_LINE_END and keeps + * the operator line for its diagnostic; success restores both line caches. */ static void compile_operand_then_op(Compiler *c, ASTNode *rhs, uint8_t opc, int op_line) { int lo = c->req_lo, hi = c->req_hi; c->req_lo = INT_MAX; c->req_hi = INT_MIN; @@ -568,8 +566,10 @@ static void compile_operand_then_op(Compiler *c, ASTNode *rhs, uint8_t opc, int if (c->req_lo > lo) c->req_lo = lo; if (c->req_hi < hi) c->req_hi = hi; if (!opc) return; - if (spans) emit_line(c, op_line); + int scope = spans && c->last_line != op_line; + if (scope) emit_op_u32(c, OP_BINARY_LINE, (uint32_t)op_line, op_line); emit(c, opc, op_line); + if (scope) emit(c, OP_BINARY_LINE_END, op_line); } /* ---- Constant helpers ---- */ @@ -2976,8 +2976,9 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { /* Compound: target index → DUP2 → INDEX_GET → expr → BINOP → INDEX_SET */ emit(c, OP_DUP2, node->line); emit(c, OP_INDEX_GET, node->line); - compile_operand_then_op(c, node->data.index_assign.expr, - binop_to_opcode(node->data.index_assign.compound_op), node->line); + compile_node(c, node->data.index_assign.expr); + uint8_t op = binop_to_opcode(node->data.index_assign.compound_op); + emit(c, op, node->line); } else { compile_node(c, node->data.index_assign.expr); } @@ -3048,8 +3049,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { if (cop[0]) emit_op_u16_u16_u16(c, OP_LOCAL_IDX_DOT_GET, (uint16_t)slot, (uint16_t)iv, (uint16_t)name_idx, node->line); - compile_operand_then_op(c, node->data.dot_assign.expr, - cop[0] ? binop_to_opcode(cop) : 0, node->line); + compile_node(c, node->data.dot_assign.expr); + if (cop[0]) emit(c, binop_to_opcode(cop), node->line); emit_op_u16_u16_u16(c, OP_LOCAL_IDX_DOT_SET, (uint16_t)slot, (uint16_t)iv, (uint16_t)name_idx, node->line); break; @@ -3067,8 +3068,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { int idx = add_string_constant(c, node->data.dot_assign.key); if (cop[0]) emit_op_u16_u16(c, OP_LOCAL_DOT_GET, (uint16_t)slot, (uint16_t)idx, node->line); - compile_operand_then_op(c, node->data.dot_assign.expr, - cop[0] ? binop_to_opcode(cop) : 0, node->line); + compile_node(c, node->data.dot_assign.expr); + if (cop[0]) emit(c, binop_to_opcode(cop), node->line); emit_op_u16_u16(c, OP_LOCAL_DOT_SET, (uint16_t)slot, (uint16_t)idx, node->line); break; } @@ -3079,8 +3080,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { /* target → DUP → DOT_GET → expr → BINOP → DOT_SET */ emit(c, OP_DUP, node->line); emit_op_u16(c, OP_DOT_GET, (uint16_t)idx, node->line); - compile_operand_then_op(c, node->data.dot_assign.expr, - binop_to_opcode(cop), node->line); + compile_node(c, node->data.dot_assign.expr); + emit(c, binop_to_opcode(cop), node->line); } else { compile_node(c, node->data.dot_assign.expr); } diff --git a/src/jit.c b/src/jit.c index bb87e664..853235d8 100644 --- a/src/jit.c +++ b/src/jit.c @@ -926,7 +926,9 @@ static int jit_supported_prefix(const struct EigsChunk *chunk, * inline as 9-byte FS-prefixed inc/dec dword [disp32] — no * call, no bail, no sp/env interaction. */ i += 1; ops++; non_line_ops++; - } else if (op == OP_LINE) { + } else if (op == OP_BINARY_LINE_END) { + i += 1; ops++; + } else if (op == OP_LINE || op == OP_BINARY_LINE) { /* #630: [op][line:32] = 5 bytes. */ if (i + 5 > chunk->code_len) { *stop_op = op; *stop_offset = i; break; } i += 5; ops++; @@ -1109,6 +1111,17 @@ static uint8_t *emit_mov_disp32_rbx_to_eax(uint8_t *w, int32_t disp) { return emit_u32(w, (uint32_t)disp); } +/* #1425: 32-bit stores for the scoped line caches. %eax/%rdx are scratch. */ +static uint8_t *emit_mov_eax_to_disp32_rbx(uint8_t *w, int32_t disp) { + *w++ = 0x89; *w++ = 0x83; + return emit_u32(w, (uint32_t)disp); +} + +static uint8_t *emit_movabs_rdx_line(uint8_t *w) { + *w++ = 0x48; *w++ = 0xBA; + return emit_u64(w, (uint64_t)(uintptr_t)&g_trace_current_line); +} + /* dec %eax (2 bytes) */ static uint8_t *emit_dec_eax(uint8_t *w) { *w++ = 0xFF; *w++ = 0xC8; return w; } @@ -4082,7 +4095,21 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk, } } i += 1; - } else { /* OP_LINE */ + } else if (op == OP_BINARY_LINE_END) { + w = emit_mov_disp32_rbx_to_eax(w, g_layout.off_binary_prev_line); + w = emit_mov_eax_to_disp32_rbx(w, g_layout.off_current_line); + w = emit_mov_disp32_rbx_to_eax(w, g_layout.off_binary_prev_trace_line); + w = emit_movabs_rdx_line(w); + *w++ = 0x89; *w++ = 0x02; /* mov %eax,(%rdx) */ + i += 1; + } else { /* OP_LINE / OP_BINARY_LINE */ + if (op == OP_BINARY_LINE) { + w = emit_mov_disp32_rbx_to_eax(w, g_layout.off_current_line); + w = emit_mov_eax_to_disp32_rbx(w, g_layout.off_binary_prev_line); + w = emit_movabs_rdx_line(w); + *w++ = 0x8B; *w++ = 0x02; /* mov (%rdx),%eax */ + w = emit_mov_eax_to_disp32_rbx(w, g_layout.off_binary_prev_trace_line); + } /* #630: 32-bit operand. */ uint32_t line = (uint32_t)chunk->code[i + 1] | ((uint32_t)chunk->code[i + 2] << 8) | @@ -4117,7 +4144,7 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk, last_imm = (v && v->type == VAL_NUM); break; } - case OP_LINE: + case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END: case OP_JUMP_IF_FALSE_PEEK: case OP_JUMP_IF_TRUE_PEEK: /* Pass-through. LINE touches no stack; the PEEK ops read the * condition but leave the operand on the stack (they don't push), diff --git a/src/jit.h b/src/jit.h index 29ae17f3..e3eeb682 100644 --- a/src/jit.h +++ b/src/jit.h @@ -140,6 +140,8 @@ typedef struct { int off_frame_count; int off_frames; int off_current_line; + int off_binary_prev_line; + int off_binary_prev_trace_line; int off_callframe_ip; int off_callframe_fn_env; int sizeof_callframe; diff --git a/src/vm.c b/src/vm.c index a5c3789a..ef66630e 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1813,7 +1813,9 @@ static int vm_leaf_accessor_exec(EigsChunk *c, int argc) { uint8_t *ip = c->code; for (;;) { switch (*ip++) { - case OP_LINE: + case OP_BINARY_LINE_END: + break; + case OP_LINE: case OP_BINARY_LINE: ip += 4; /* #630: 32-bit operand */ break; case OP_CONST: { @@ -2751,6 +2753,8 @@ void eigs_jit_get_layout(EigsJitLayout *out) { out->off_frame_count = (int)offsetof(VM, frame_count); out->off_frames = (int)offsetof(VM, frames); out->off_current_line = (int)offsetof(VM, current_line); + out->off_binary_prev_line = (int)offsetof(VM, binary_prev_line); + out->off_binary_prev_trace_line = (int)offsetof(VM, binary_prev_trace_line); out->off_callframe_ip = (int)offsetof(CallFrame, ip); out->off_callframe_fn_env= (int)offsetof(CallFrame, fn_env); out->sizeof_callframe = (int)sizeof(CallFrame); @@ -3049,6 +3053,8 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume, [OP_IMPORT] = &&lbl_IMPORT, [OP_MATCH] = &&lbl_MATCH, [OP_LISTCOMP_BEGIN] = &&lbl_LISTCOMP_BEGIN, [OP_LISTCOMP_APPEND] = &&lbl_LISTCOMP_APPEND, + [OP_BINARY_LINE] = &&lbl_BINARY_LINE, + [OP_BINARY_LINE_END] = &&lbl_BINARY_LINE_END, [OP_LINE] = &&lbl_LINE, [OP_WIDE] = &&lbl_WIDE, [OP_DISPATCH] = &&lbl_DISPATCH, [OP_LOCAL_DOT_GET] = &&lbl_LOCAL_DOT_GET, @@ -6202,7 +6208,26 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume, DISPATCH(); } - CASE(LINE): { + CASE(BINARY_LINE): { + g_vm.binary_prev_line = current_line; + g_vm.binary_prev_trace_line = g_vm_multithreaded + ? current_line : trace_current_line_load(); + /* Same stamp/tape behavior as LINE; only its lifetime differs. */ + goto vm_stamp_line; + } + + CASE(BINARY_LINE_END): { + current_line = g_vm.binary_prev_line; + g_vm.current_line = current_line; + if (!g_vm_multithreaded) + trace_current_line_store(g_vm.binary_prev_trace_line); + if (__builtin_expect(g_trace_enabled, 0)) + trace_line(g_vm.binary_prev_trace_line); + DISPATCH(); + } + + CASE(LINE): + vm_stamp_line: { uint32_t line = read_u32(ip); ip += 4; /* #630: 32-bit operand */ current_line = (int)line; g_vm.current_line = (int)line; diff --git a/src/vm.h b/src/vm.h index 21994769..726c676b 100644 --- a/src/vm.h +++ b/src/vm.h @@ -225,6 +225,9 @@ typedef enum { * iteration but the last is unaddressable. Ordinals are * injective and edit-stable. Appended, not mid-list. */ + OP_BINARY_LINE, /*obs:WRITES*/ /* [line:32] save line caches and stamp one binary op */ + OP_BINARY_LINE_END, /*obs:WRITES*/ /* restore line caches after a successful binary op */ + OP_COUNT /* sentinel — number of opcodes */ } OpCode; @@ -541,6 +544,10 @@ typedef struct VM { CallFrame frames[VM_FRAMES_MAX]; int frame_count; int current_line; + /* #1425: scratch for adjacent BINARY_LINE / binop / BINARY_LINE_END. + * No call, jump or suspension lies inside a compiler-emitted scope. */ + int binary_prev_line; + int binary_prev_trace_line; /* Back-pointer to the owning EigsThread, set in vm_init. Lets the * JIT reach EigsThread fields (e.g. unobserved_depth) via a single * `mov off_vm_owner(%rbx), %rax` instead of a TLS read at every diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index c80949d2..24d459e8 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -697,13 +697,23 @@ check_binary_fingerprint # last line); the operator_line rows also pin the excerpt and caret lines. # A fixture's expected lines are separated by ';' (an excerpt line has '|'). for LAR_TIER in "JIT" "EIGS_JIT_OFF=1" "EIGS_JIT_OSR_THRESHOLD=1"; do - LAR_ENV="EIGS_JIT_STATS=1"; [ "$LAR_TIER" = JIT ] || LAR_ENV="$LAR_ENV $LAR_TIER" - for LAR_PROG in test_line_after_return.eigs test_operator_line.eigs; do + LAR_ENV="EIGS_JIT_STATS=1 EIGS_JIT_HOT=1"; [ "$LAR_TIER" = JIT ] || LAR_ENV="$LAR_ENV $LAR_TIER" + for LAR_PROG in test_line_after_return.eigs test_operator_line.eigs test_operator_line_parent.eigs; do LAR_OUT=$($EIGS_TMO env $LAR_ENV ./eigenscript "../tests/$LAR_PROG" &1); LAR_RC=$? TOTAL=$((TOTAL + 1)) LAR_JIT=$(lar_jit_witness "$LAR_TIER" "$LAR_OUT") + if [ "$LAR_PROG" = test_operator_line_parent.eigs ] && [ "$LAR_TIER" != EIGS_JIT_OFF=1 ] && [ "$(uname -m)" = x86_64 ]; then + # Witness each parent callee and each independent OSR loop. + for LAR_PARENT in unary index call; do + grep -Eq "^parent_${LAR_PARENT} +[0-9]+ +yes " <<< "$LAR_OUT" || LAR_JIT=missing-parent-$LAR_PARENT + grep -Eq "^osr_parent_${LAR_PARENT} .* +yes +[1-9][0-9]* +[0-9]+ " <<< "$LAR_OUT" || LAR_JIT=missing-osr-$LAR_PARENT + done + grep -Eq "^parent_branch +[0-9]+ +yes " <<< "$LAR_OUT" || LAR_JIT=missing-parent-branch + grep -Eq "^osr_parent_branch .* +yes +[1-9][0-9]* +[0-9]+ " <<< "$LAR_OUT" || LAR_JIT=missing-osr-branch + fi if [ "$LAR_JIT" != ok ]; then - FAIL=$((FAIL + 1)); echo " FAIL: e.line, $LAR_PROG ($LAR_TIER): compiled nothing, so it measured the interpreter" + FAIL=$((FAIL + 1)); echo " FAIL: e.line, $LAR_PROG ($LAR_TIER): compiled witness $LAR_JIT" + printf '%s\n' "$LAR_OUT" | eigs_failure_output elif rc_ok "$LAR_RC" "$LAR_OUT" && grep -q "All tests passed" <<< "$LAR_OUT"; then PASS=$((PASS + 1)); echo " PASS: e.line, $LAR_PROG ($LAR_TIER)" else diff --git a/tests/test_opcode_abi.c b/tests/test_opcode_abi.c index ae63961a..7cd0406c 100644 --- a/tests/test_opcode_abi.c +++ b/tests/test_opcode_abi.c @@ -47,3 +47,7 @@ _Static_assert(EIGS_BYTECODE_ABI == 1, int main(void) { return 0; } + +/* #1425: appended scope markers, also assembled by the parent-line test. */ +ABI_ASSERT(OP_BINARY_LINE, 94); +ABI_ASSERT(OP_BINARY_LINE_END, 95); diff --git a/tests/test_operator_line.eigs b/tests/test_operator_line.eigs index b1736e1a..d1a1d09e 100644 --- a/tests/test_operator_line.eigs +++ b/tests/test_operator_line.eigs @@ -85,13 +85,13 @@ try: s) catch e: got is e.line -assert_eq of [got, 84, "compound += on a field"] +assert_eq of [got, 85, "compound += on a field: unchanged base line"] try: lst[0] += ( s) catch e: got is e.line -assert_eq of [got, 90, "compound += on an index"] +assert_eq of [got, 91, "compound += on an index: unchanged base line"] # Hot rows: the operator runs in a JIT thunk and in an OSR-compiled loop. k is 6001 diff --git a/tests/test_operator_line_parent.eigs b/tests/test_operator_line_parent.eigs new file mode 100644 index 00000000..ab5fcc35 --- /dev/null +++ b/tests/test_operator_line_parent.eigs @@ -0,0 +1,234 @@ +# #1425: a successful binary op must restore its operand's runtime line. +# LINE-NUMBER SENSITIVE: expected lines pin ea9ee90, outside the binary-only decision. +load_file of "lib/test.eigs" +define parent_unary(k) as: + return -(k + + k) # @hot_unary + +define parent_index(k, items) as: + local result is items[k + + k] # @hot_index + return result + +define identity(n) as: + return n + +define parent_call(k) as: + return k of (1 + + 2) # @hot_call + +define osr_parent_unary(items) as: + local k is 6001 + loop while k > 0: + local v is k + if k == 1: + v is "s" + local r is -(v + + v) # @osr_unary + k is k - 1 + return r + +define osr_parent_index(items) as: + local k is 6001 + loop while k > 0: + local v is 0 + if k == 1: + v is "s" + local r is items[v + + v] # @osr_index + k is k - 1 + return r + +define osr_parent_call(items) as: + local k is 6001 + loop while k > 0: + local v is identity + if k == 1: + v is 0 + local r is v of (1 + + 2) # @osr_call + k is k - 1 + return r + +got is 0 +try: + r is -("a" + + "b") # @cold_unary +catch e: + got is e.line +assert_eq of [got, 56, "successful + then unary fails"] +try: + r is [1]["a" + + "b"] # @cold_index +catch e: + got is e.line +assert_eq of [got, 62, "successful + then index fails"] +try: + r is 0 of (1 + + 2) # @cold_call +catch e: + got is e.line +assert_eq of [got, 68, "successful + then call fails"] + +# Each callee is warmed before the failing parent executes. +k is 80 +loop while k > 0: + r is parent_unary of k + r is parent_index of [0, ([1])] + r is parent_call of identity + k is k - 1 +try: + r is parent_unary of "s" +catch e: + got is e.line +assert_eq of [got, 6, "compiled successful + then unary fails"] +try: + r is parent_index of ["s", ([1])] +catch e: + got is e.line +assert_eq of [got, 10, "compiled successful + then index fails"] +try: + r is parent_call of 0 +catch e: + got is e.line +assert_eq of [got, 18, "compiled successful + then call fails"] + +# Each OSR parent has its own chunk; witness each loop independently. +try: + r is osr_parent_unary of ([1]) +catch e: + got is e.line +assert_eq of [got, 27, "OSR successful + then unary fails"] +try: + r is osr_parent_index of ([1]) +catch e: + got is e.line +assert_eq of [got, 38, "OSR successful + then index fails"] +try: + r is osr_parent_call of ([1]) +catch e: + got is e.line +assert_eq of [got, 49, "OSR successful + then call fails"] + +# Runtime restoration matters at a short-circuit join: the last emitted +# line is not necessarily the last executed one. +try: + r is -("a" + ("b" or # @skipped_rhs + "c")) +catch e: + got is e.line +assert_eq of [got, 116, "restore the executed short-circuit arm"] +try: + r is -("a" + (0 or + "c")) # @taken_rhs +catch e: + got is e.line +assert_eq of [got, 123, "restore the taken short-circuit arm"] + +# Dedicated compound field/index nodes retain their pre-#1425 behavior. +o is {"v": 1} +lst is [1] +try: + o.v += ( + "s") # @field +catch e: + got is e.line +assert_eq of [got, 133, "compound field += keeps base attribution"] +try: + lst[0] += ( + "s") # @index +catch e: + got is e.line +assert_eq of [got, 139, "compound index += keeps base attribution"] +try: + lst[ + 0] += ( + "s") # @multiline_target +catch e: + got is e.line +assert_eq of [got, 146, "compound index target spans lines: base attribution"] +define compound_local_field(obj) as: + obj.v += ( + "s") # @local_field + +define compound_local_index_field(items) as: + items[0].v += ( + "s") # @local_index_field + +try: + r is compound_local_field of ({"v": 1}) +catch e: + got is e.line +assert_eq of [got, 152, "fused local field compound keeps base line"] +try: + r is compound_local_index_field of ([{"v": 1}]) +catch e: + got is e.line +assert_eq of [got, 156, "fused local index-field compound keeps base line"] +# The runtime line at a short-circuit join must survive native execution too. +define parent_branch(k, flag) as: + local result is -(k + (flag or # @branch_skip + k)) # @branch_take + return result + +define osr_parent_branch(mode) as: + local k is 6001 + loop while k > 0: + local callee is identity + if k == 1: + callee is 0 + local flag is 0 + if mode: + flag is 1 + local result is callee of (1 + (flag or # @osr_branch_skip + 1)) # @osr_branch_take + k is k - 1 + return result + +k is 80 +loop while k > 0: + r is parent_branch of [k, 0] + r is parent_branch of [k, k] + k is k - 1 +try: + r is parent_branch of ["s", "s"] +catch e: + got is e.line +assert_eq of [got, 170, "compiled skipped short-circuit operand restores its runtime line"] +try: + r is parent_branch of ["s", 0] +catch e: + got is e.line +assert_eq of [got, 171, "compiled taken short-circuit operand restores its runtime line"] +try: + r is osr_parent_branch of 1 +catch e: + got is e.line +assert_eq of [got, 183, "OSR skipped short-circuit operand restores its runtime line"] +try: + r is osr_parent_branch of 0 +catch e: + got is e.line +assert_eq of [got, 184, "OSR taken short-circuit operand restores its runtime line"] + +# New opcode numbers are appended and pinned in test_opcode_abi.c. +# A descriptor must use an adjacent stamp / binary / restore scope: no +# call or suspension can overwrite the scalar scratch, nor may a jump enter it. +SCOPE is 94 +END_SCOPE is 95 +scoped is sandbox_run of [[1, [0,0,0, 0,1,0, SCOPE,7,0,0,0, 4,END_SCOPE,40], [2,3]], 1000] +assert_eq of [scoped["ok"], 1, "adjacent binary line scope verifies"] +assert_eq of [scoped["result"], 5, "adjacent scope computes normally"] +restored is sandbox_run of [[1, [68,9,0,0,0, 0,0,0, 0,1,0, SCOPE,7,0,0,0, 4,END_SCOPE,14,40], ["a","b"]], 1000] +assert_eq of [restored["error"]["line"], 9, "assembled successful binary restores the parent's line"] +for code in [ + [0,0,0, 0,1,0, END_SCOPE,4,40], + [0,0,0, 0,1,0, SCOPE,7,0,0,0, 4,40], + [0,0,0, 0,1,0, SCOPE,7,0,0,0, 14,END_SCOPE,40], + [0,0,0, 0,1,0, SCOPE,7,0,0,0, 39,1,0,END_SCOPE,40], + [0,0,0, 0,1,0, 29,5,0, SCOPE,7,0,0,0, 4,END_SCOPE,40], + [0,0,0, 0,1,0, 29,6,0, SCOPE,7,0,0,0, 4,END_SCOPE,40]]: + bad is sandbox_run of [[1, code, [2,3]], 1000] + assert_eq of [bad["ok"], 0, "malformed or jumped-into binary line scope is refused"] + assert_eq of [bad["error"]["message"], "invalid chunk descriptor", "scope is refused by verification before execution"] +test_summary of null diff --git a/tests/test_trace_mt.sh b/tests/test_trace_mt.sh index a28e6f78..d1f7b290 100755 --- a/tests/test_trace_mt.sh +++ b/tests/test_trace_mt.sh @@ -261,11 +261,12 @@ stape="$TMPDIR/single.tape" EIGS_TRACE="$stape" "$EIGS" "$TESTS_DIR/trace_mt_single.eigs" >"$TMPDIR/single.out" 2>"$TMPDIR/single.err" src=$? read -r slines swell smal snrec socfg sexamined <<< "$(parse_tape "$stape")" -if [ "$src" -eq 0 ] && [ "$smal" -eq 0 ] && [ "$snrec" -eq 6000 ] && [ "$sexamined" -gt 0 ]; then - ok "single-worker: N == 6000 malformed == 0 (unchanged)" +szero=$(grep -cx 'L 0' "$stape" || true) +if [ "$src" -eq 0 ] && [ "$smal" -eq 0 ] && [ "$snrec" -eq 6000 ] && [ "$sexamined" -gt 0 ] && [ "$szero" -eq 0 ]; then + ok "single-worker: N == 6000 malformed == 0, scoped binary restores a positive worker line" else fail "single-worker: N == 6000 malformed == 0" \ - "rc=$src N=$snrec malformed=$smal examined=$sexamined" + "rc=$src N=$snrec malformed=$smal examined=$sexamined zero-lines=$szero" fi echo "=== long-scope-name (S record keeps its full name and newline, #1157) ===" diff --git a/tests/trace_mt_single.eigs b/tests/trace_mt_single.eigs index da16fd19..b1212a4d 100644 --- a/tests/trace_mt_single.eigs +++ b/tests/trace_mt_single.eigs @@ -6,7 +6,8 @@ define worker(id) as: local r is random of [] local t is monotonic_ns of [] local e is env_get of "HOME" - acc is acc + r + acc is acc + ( + r) i is i + 1 return acc h1 is spawn of [worker, 1]