Three arithmetic situations had no defined behaviour and the two backends disagreed about all three: a divide or remainder by zero, which was a raw SIGFPE with no message and no location; (/ min -1), whose quotient is one past the top of the type; and a float to integer cast whose value does not fit, which LLVM called undefined and would fold to anything. They now signal ArithError with `error`, exactly as a bad index signals BoundsError, and die with a sentence naming the file, the line and the operands only if nothing answered. The guards ride the same --checks flag as the bounds check and are elided with it. No restart is established at the failing operation. The sketch this started from asked for use-value, and the implementation ruled it out: a restart frame is allocated by the restart-case that offers it, on its own stack, so the runtime cannot hold one on a program's behalf and use-value here would mean an alloca and a restart frame at every division in every checked build. That is the cost already refused for indexing, buying a silently different answer. The x86 backend is unchanged and is the next commit.
106 lines
7.2 KiB
Markdown
106 lines
7.2 KiB
Markdown
# Arithmetic that has no answer is a condition
|
|
|
|
Three arithmetic situations had no defined behaviour in Flan, and the two backends disagreed on all three. A divide by
|
|
zero was a raw `SIGFPE` — the process died with no message, no location, and nothing to handle. `INT64_MIN / -1` raised
|
|
`SIGFPE` under `idiv` and was undefined under LLVM. A float-to-integer cast whose value does not fit produced x86's
|
|
fixed "integer indefinite" under one backend and whatever the optimiser liked under the other.
|
|
|
|
All three now signal `ArithError` with `error`, exactly as an out-of-range index signals `BoundsError` and a failed
|
|
allocation signals `StorageExhausted`. A Lisp that dies naming the file and the line beats one that dies with `SIGFPE`,
|
|
and a program that genuinely does not care installs a handler once at startup and never thinks about it again.
|
|
|
|
## The shape
|
|
|
|
```lisp
|
|
(defstruct ArithError [op i32 lhs i64 rhs i64])
|
|
```
|
|
|
|
`op` is a small integer code and not a keyword, because `FileError`'s `op` is already a small integer code
|
|
(`prelude.ml:1380`, built at `check.ml:3096`) and the field has to be filled in from C, where a keyword is not a thing
|
|
that exists. The codes and what `lhs`/`rhs` hold under each are documented on the `defstruct` itself; the short version
|
|
is that a division carries its two operands and a cast carries the destination type's representable range, which is
|
|
the violated condition written as a range. That reuse of two fields for two meanings is `BoundsError`'s precedent
|
|
exactly — `low` and `high` are one index for an `at` and two ends for a `slice`, so that a handler writes one clause
|
|
and not two — and `flan_slice_promise_error` already packs a violated condition into them as `(0, n, 0)`.
|
|
|
|
There is no location field, because `BoundsError` has none either: the location is an argument to the runtime helper
|
|
and is used only in the message printed when nothing answered.
|
|
|
|
## No restart is established at the failing operation
|
|
|
|
The brief that started this work sketched `use-value` everywhere and `saturate` where clamping is meaningful. Reading
|
|
the existing implementation changed that, and the reason is mechanical rather than a matter of taste.
|
|
`runtime/flan_rt.c:70-86` says it: a restart frame is allocated **by the `restart-case` that offers it, on its own
|
|
stack**, and a transfer carries that frame's address. The runtime therefore cannot host a restart on a program's
|
|
behalf; a `use-value` on division would have to be an `alloca` plus a `flan_restart_push`/`pop` pair emitted at every
|
|
division site in every checked build.
|
|
|
|
That is the identical cost `prelude.ml:70-76` and `flan_rt.c:483-490` already refuse for indexing, in prose, on the
|
|
record: a site restart on every operation, buying a silently different answer. Division is if anything the weaker
|
|
case — an `at` at least has an element to hand back. So `ArithError` follows `BoundsError`: it signals, a handler may
|
|
inspect it and transfer out through a restart the program already established (a frame loop's `continue`), and with
|
|
nothing answering it falls through to a message naming the file and the line.
|
|
|
|
`saturate` on the **cast** arm alone is the one place a site restart might still earn its frame, because casts are rare
|
|
and clamping is a canonical answer rather than an arbitrary one. It is not built here and is follow-up shaped.
|
|
|
|
## The guards ride `--checks`
|
|
|
|
The same flag as the bounds check, elided with it. The divide guard is a branch *before* the instruction and not a
|
|
handler after it, because `SIGFPE` cannot be caught and resumed. Once the zero test is being paid for, the
|
|
`INT64_MIN / -1` test is nearly free on the same path.
|
|
|
|
Unsigned division gets the zero test only; there is no overflow case. Float division is **not** guarded at all:
|
|
IEEE `x / 0.0` is `inf`, which is defined and wanted — `rand-f32` in the prelude divides by a float constant.
|
|
|
|
## Order of work
|
|
|
|
1. `lib/prelude.ml`, `runtime/flan_rt.c`, `lib/emit.ml`, tests. Committed first and separately.
|
|
2. `lib/x86.ml`'s `prim` `Div`/`Rem`/`Cast` arms last, rebased onto the dev-loop tip, kept mechanical — this is item 3
|
|
of `HANDOFF-x86-rt.md`'s "What remains".
|
|
|
|
## What landed
|
|
|
|
**`lib/prelude.ml`** — `(defstruct ArithError [op i32 lhs i64 rhs i64])`, immediately after `BoundsError`, with the
|
|
codes and the per-code meaning of `lhs`/`rhs` written on it.
|
|
|
|
**`runtime/flan_rt.c`** — `flan_arith_error`, built out of the same three pieces `flan_bounds_error` is: fill a
|
|
`flan_arith_cond` on this frame, `flan_signal`, try `flan_break_hook`, and fall through to a sentence and `rt_die()` if
|
|
neither answered. `flan_arith_fail` is the sentence, and there is one per code rather than one shared "overflow",
|
|
because the reader who reaches `(/ min -1)` has probably never had to think about that case.
|
|
|
|
**`lib/emit.ml`** — `check_div` and `check_cast`, next to `check_at` and `check_slice` and built on the same
|
|
`signal_block`, so an answered failure leaves through the innermost pad and runs the defers. `check_div` is called from
|
|
`prim`'s `Div`/`Rem` arm and `check_cast` from `cast`'s `Float -> Int` arm.
|
|
|
|
Three things in those two functions are worth knowing:
|
|
|
|
- **Integer division only.** IEEE `x / 0.0` is an infinity and is a defined answer somebody may want — the prelude's
|
|
own `rand-f32` divides by a float constant — so guarding a float division would be refusing a result the language
|
|
already promises.
|
|
- **One branch, not two.** The zero test and the `INT_MIN / -1` test are `or`-ed into a single compare-and-branch, and
|
|
*which* of them fired is decided by a `select` that is dead on the fall-through path. The guard is also dropped
|
|
outright when the divisor is a literal that cannot trigger it, which is nearly every division anyone writes.
|
|
- **The cast test is exact and catches NaN.** Both bounds are powers of two and therefore exact in a double, an `f32`
|
|
source is `fpext`-ed first so there is one set of bounds rather than two, and the comparisons are *ordered*, which is
|
|
what makes a NaN fail both halves instead of passing both.
|
|
|
|
**`test/programs/arith.flan`** — the unhandled half, one case per argument in `bounds.flan`'s shape. Case 0 is the one
|
|
that must not die and is four shapes rather than one: a dynamic divisor, a literal one the guard drops, unsigned
|
|
division, and a float division by zero.
|
|
|
|
**`test/programs/arith-condition.flan`** — the answered half, in `bounds-condition.flan`'s shape: a `handler-bind`
|
|
clause that reads the condition and takes a frame loop's `continue`. Five codes, four frames finishing, eight
|
|
abandoned, and twelve defers run.
|
|
|
|
**`test/test_acceptance.ml`** — both programs at `-O0` and `-O2`, the answered one also as a dev build, and the
|
|
`--checks`-off case asserted on the IR rather than by running an unchecked program, because an unchecked divide by zero
|
|
has no defined behaviour to assert on — it is the SIGFPE this change exists to replace.
|
|
|
|
## The tests the brief asked for, and the one substitution
|
|
|
|
Unhandled-with-a-location, a `handler-bind` that inspects the condition, and `--checks` off are all there. The
|
|
`use-value` restart case is **not**, because no `use-value` restart is established — see above. What stands in its
|
|
place is the same thing that stands in for it in `bounds-condition.flan`: a handler that transfers out through a
|
|
restart the program already had.
|