Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`. |
Expand Down
9 changes: 9 additions & 0 deletions examples/errors/missing_expression.eigs
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 11 additions & 2 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}

Expand Down
26 changes: 24 additions & 2 deletions src/parser.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions tests/test_raise_on_bad_input.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading