From be56ee3ada51ae24d6ff2960e57af83e9b122f82 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Thu, 9 Jul 2026 02:54:31 -0500 Subject: [PATCH] change: raise instead of silent success on closed-channel send, missing load_file path, and missing expression (#505, #490, #494) Silent-tolerance audit batch-2d. Three paths that returned a silent wrong value with rc=0 now raise a catchable error: - send to a closed channel raised nothing and dropped the value (a producer racing a consumer's close lost messages). Now raises a catchable `value` error; the enqueue path unlocks before returning and the closed path unlocks -> decref -> rt_error (never rt_error under the channel mutex). recv on a closed empty channel still returns null. (#505) - load_file of a missing/unreadable path printed to stderr and returned a silent null; now raises a catchable `io` error, matching import's severity and the function's own parse/compile failure paths. (#490) - the parser folded a missing expression after is/of/unary or as a throw/print argument (x is, print of, throw of, eval of "x is ") into a silent null and continued. parse_primary now raises "expected expression" on a statement terminator; bare `return` (the one legitimate empty-expression position) checks the terminator itself and still yields null. (#494) Tests: batch-2d cases + eval-catchable parse in test_raise_on_bad_input ([116], count 43->48), new examples/errors/missing_expression.eigs ([90]). Docs: BUILTINS.md (send/load_file), CHANGELOG. Release + ASan/leak suites 2683/2683, leak-clean. Closes #505 Closes #490 Closes #494 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++++ docs/BUILTINS.md | 4 +-- examples/errors/missing_expression.eigs | 9 ++++++ src/builtins.c | 13 ++++++-- src/parser.c | 26 +++++++++++++-- tests/run_all_tests.sh | 9 ++++-- tests/test_raise_on_bad_input.eigs | 42 +++++++++++++++++++++++++ 7 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 examples/errors/missing_expression.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index b8bcab0..8469f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,25 @@ All notable changes to EigenScript are documented here. the HTTP header/body parsers keep their lenient behavior (they ignore the strict-parse flag) (#495). + Batch 2d — channel/IO/parser silent successes (#505, #490, #494): + - `send` to a **closed** channel raises a catchable `value` error instead + of silently dropping the value with `rc=0`. A producer that races a + consumer's `close_channel` used to lose messages with no signal; now it + fails loud. `recv` on a closed empty channel still returns `null` + (EOF-like — that half is intended) (#505). + - `load_file` of a missing/unreadable path raises a catchable `io` error + instead of printing to stderr and returning a silent `null` with `rc=0`. + This matches `import`'s severity and the same function's own parse/compile + failure paths; a typo'd path is no longer a soft no-op (#490). + - **Parser: a missing expression is a parse error, not a silent `null`.** + An expression required after `is` / `of` / a unary operator, or as a + `throw` / `print` argument, that runs into the end of the line + (`x is`, `print of`, `throw of`, `eval of "x is "`) used to bind/emit + `null` and continue with `rc=0` — a truncated or generated program looked + like a soft no-op. It now raises `Parse error line N: expected expression` + (catchable `parse` from `eval`/`import`/`load_file`). Bare `return` + (the one legitimate empty-expression position) still yields `null` (#494). + ### Fixed - **Circular `import` / `load_file` no longer crashes (#496).** A mutual or self-referential `import` (a→b→a) or `load_file` used to recurse through diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 840bbc5..e333fcc 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -232,7 +232,7 @@ Boolean keywords that check the most recently observed value: | Name | Signature | Description | |------|-----------|-------------| -| `load_file` | `load_file of "path.eigs"` | Load and execute EigenScript file | +| `load_file` | `load_file of "path.eigs"` | Load and execute EigenScript file. A missing/unreadable path raises a catchable `io` error (matching `import`); a parse/compile failure in the file raises `parse`. | | `file_exists` | `file_exists of "path"` | 1 if file exists, 0 otherwise | | `read_text` | `read_text of "path"` | Read file contents as string ("" on failure, 10 MB cap) | | `read_bytes` | `read_bytes of "path"` | Read a file's raw bytes as a list of integers 0–255 (`null` on failure, 10 MB cap). Trace-recorded, so replay is deterministic | @@ -562,7 +562,7 @@ Requires full build. Transformer model inference and training. | `spawn` | `spawn of fn` or `spawn of [fn, arg1, ...]` | Spawn a thread running `fn`. Bare-fn form passes no args; list form passes `arg1...` positionally. Missing trailing params bind to `null`; extra args are ignored. Args are shared by reference (unlike channel sends, which copy) — see thread-safety note below. Returns a thread handle dict. | | `thread_join` | `thread_join of handle` | Block until thread completes. Returns the thread function's return value. | | `channel` | `channel of null` | Create a bounded FIFO channel (capacity 64). Returns a channel handle dict. | -| `send` | `send of [channel, value]` | Send a value to the channel. Blocks if full. | +| `send` | `send of [channel, value]` | Send a value to the channel. Blocks if full. Sending to a **closed** channel raises a catchable `value` error (rather than silently dropping the value); `recv` on a closed empty channel returns `null` (EOF-like). | | `recv` | `recv of channel` | Receive a value from the channel. **Blocks** until a value is available or the channel is closed. | | `try_recv` | `try_recv of channel` | Non-blocking receive. Returns the value if available, `null` if the channel is empty. | | `recv_timeout` | `recv_timeout of [channel, ms]` | Bounded-wait receive. Returns the value if one arrives before `ms` milliseconds elapse, else `null`. A close while waiting also returns `null`. Fractional `ms` is honored (ns precision on Linux); negative `ms` degenerates to a `try_recv`. | diff --git a/examples/errors/missing_expression.eigs b/examples/errors/missing_expression.eigs new file mode 100644 index 0000000..abae801 --- /dev/null +++ b/examples/errors/missing_expression.eigs @@ -0,0 +1,9 @@ +# ERROR DEMO — an expression is required but the line ended. +# `x is` with a dropped right-hand side used to bind null and run with +# rc=0 (same for `print of`, `throw of`, `eval of "x is "`). A truncated +# or generated program looked like a soft no-op. Now a parse error (#494). +# The one legitimate empty-expression position, bare `return`, still yields +# null and is exercised by the normal suite. +# expect-error: expected expression +x is +print of "unreachable" diff --git a/src/builtins.c b/src/builtins.c index b1ce256..adf3524 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -2623,7 +2623,10 @@ Value* builtin_load_file(Value *arg) { } if (!source) { - fprintf(stderr, "load_file: cannot read '%s'\n", arg->data.str); + /* #490: match import's severity — a missing path is a catchable io + * error, not a stderr-warn + silent null (rc=0). Callers that ignore + * the return otherwise run on half-initialized state. */ + rt_error(EK_IO, 0, "load_file: cannot read '%s'", arg->data.str); return make_null(); } @@ -4485,10 +4488,16 @@ Value* builtin_send(Value *arg) { ch->tail = (ch->tail + 1) % CHANNEL_BUF_SIZE; ch->count++; pthread_cond_signal(&ch->not_empty); + pthread_mutex_unlock(&ch->mutex); } else { + /* #505: sending to a closed channel drops the value. Failing open + * loses messages with rc=0 (producer races a consumer's close). Raise + * instead — recv on a closed empty channel still returns null (EOF). */ + pthread_mutex_unlock(&ch->mutex); val_decref(val); + rt_error(EK_VALUE, 0, "send: channel is closed"); + return make_null(); } - pthread_mutex_unlock(&ch->mutex); return make_null(); } diff --git a/src/parser.c b/src/parser.c index cd8acb2..bf8a790 100644 --- a/src/parser.c +++ b/src/parser.c @@ -963,7 +963,18 @@ static ASTNode* parse_primary(Parser *p) { } ASTNode *n = make_node(AST_NULL, p_cur(p)->line); - if (t->type != TOK_EOF && t->type != TOK_NEWLINE && t->type != TOK_DEDENT) { + if (t->type == TOK_EOF || t->type == TOK_NEWLINE || t->type == TOK_DEDENT) { + /* #494: an expression was required but the statement ended. Previously + * this "empty expression" silently became null, so truncated input + * (`x is`, `print of`, `throw of`, `eval of "x is "`) ran with rc=0 and + * wrong nulls — the silent-tolerance class. The one legitimate empty + * position, bare `return`, checks the terminator itself before calling + * parse_expression and never reaches here. Don't advance: consuming the + * NEWLINE/DEDENT would desync statement framing. */ + fprintf(stderr, "Parse error line %d: expected expression\n", t->line); + eigs_record_first_error(t->line, "expected expression"); + g_parse_errors++; + } else { fprintf(stderr, "Parse error line %d: unexpected %s in expression", t->line, tok_type_name(t->type)); if (t->str_val) fprintf(stderr, " ('%s')", t->str_val); @@ -1534,7 +1545,18 @@ static ASTNode* parse_statement_inner(Parser *p) { if (t->type == TOK_RETURN) { p_advance(p); - ASTNode *expr = parse_expression(p); + /* Bare `return` yields null. This is the only legitimate empty- + * expression position (#494): check the terminator here rather than + * letting parse_expression bottom out in parse_primary, which now + * raises "expected expression". */ + Token *rt = p_cur(p); + ASTNode *expr; + if (rt->type == TOK_NEWLINE || rt->type == TOK_EOF || + rt->type == TOK_DEDENT) { + expr = make_node(AST_NULL, rt->line); + } else { + expr = parse_expression(p); + } p_end_statement(p); ASTNode *n = make_node(AST_RETURN, p_cur(p)->line); n->data.ret.expr = expr; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index cf6e833..c6d5204 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1035,9 +1035,12 @@ rm -f "$CPG_FILE" # Batch-2c adds #495: json_decode rejects truncated / partial / trailing- # garbage JSON (was a silent partial value; also made a genuine `null` # indistinguishable from a parse failure). -echo "[116] Silent-Tolerance Batch-2: bad input raises (14 issues)" +# Batch-2d adds #505 (send to a closed channel raises value, was a silent +# drop), #490 (load_file of a missing path raises io, was stderr + null), and +# #494 (eval of a truncated expression raises a catchable parse error). +echo "[116] Silent-Tolerance Batch-2: bad input raises (17 issues)" check_eigs_suite "invalid input raises instead of silent null/0/empty" \ - test_raise_on_bad_input.eigs "ALL_RAISE_TESTS_DONE" 43 + test_raise_on_bad_input.eigs "ALL_RAISE_TESTS_DONE" 48 # [117] for-in snapshots the iteration length at loop entry (#491). Mutating # the sequence in the body is well-defined: appending no longer loops forever @@ -2626,7 +2629,7 @@ echo "" # [90] Error examples — examples/errors/*.eigs must exit nonzero and # print their declared '# expect-error:' message. -echo "[90] Error Examples (10 checks)" +echo "[90] Error Examples (14 checks)" ERR_OUTPUT=$(bash "$TESTS_DIR/test_error_examples.sh" 2>&1) ERR_PASS=$(echo "$ERR_OUTPUT" | grep -c " PASS:" || true) ERR_FAIL=$(echo "$ERR_OUTPUT" | grep -c " FAIL:" || true) diff --git a/tests/test_raise_on_bad_input.eigs b/tests/test_raise_on_bad_input.eigs index bfe725b..71a4cf4 100644 --- a/tests/test_raise_on_bad_input.eigs +++ b/tests/test_raise_on_bad_input.eigs @@ -279,5 +279,47 @@ assert_true of [(type of jd_ok_null) == "none", "json_decode of a bare null stil jd_ok_obj is json_decode of "{\"a\": 1, \"b\": [2, 3]}" assert_true of [jd_ok_obj.a == 1 and (len of jd_ok_obj.b) == 2, "json_decode of a valid nested object still works"] +# ---- #505 send to a closed channel raises (was a silent drop, rc=0) ---- +send_closed is 0 +send_closed_k is "" +sc_ch is channel of null +close_channel of sc_ch +try: + send of [sc_ch, 99] +catch e: + send_closed is 1 + send_closed_k is e.kind +assert_true of [send_closed == 1 and send_closed_k == "value", "send to closed channel raises value"] + +# happy path: send/recv on an open channel still works, and recv on a +# closed-but-empty channel still returns null (EOF-like — that half is kept) +sc_ok is channel of null +send of [sc_ok, 7] +assert_eq of [recv of sc_ok, 7, "send/recv on an open channel still works"] +close_channel of sc_ok +assert_true of [(type of (try_recv of sc_ok)) == "none", "try_recv on a closed empty channel still returns null"] + +# ---- #490 load_file of a missing path raises io (was stderr + silent null) ---- +lf_missing is 0 +lf_missing_k is "" +try: + ig is load_file of "/no/such/file_zzz.eigs" +catch e: + lf_missing is 1 + lf_missing_k is e.kind +assert_true of [lf_missing == 1 and lf_missing_k == "io", "load_file of a missing path raises io"] + +# ---- #494 eval of a truncated expression is a catchable parse error ---- +# (was a silent null — the iLambdaAi generate-then-eval path could emit an +# incomplete assignment that never failed the generator's smoke test) +eval_trunc is 0 +eval_trunc_k is "" +try: + ig is eval of "x is " +catch e: + eval_trunc is 1 + eval_trunc_k is e.kind +assert_true of [eval_trunc == 1 and eval_trunc_k == "parse", "eval of a truncated expression raises parse"] + print of "ALL_RAISE_TESTS_DONE" test_summary of null