ADR-043: An Overflow Aborts, at Every Build. Wrapping and Saturating Get Names

Status: Superseded by ADR-285 — its D1-D3 are D5-D7, D4 is D12, D5 is D29, D5.5 is D31, D6 is D8, D7 is D13 there Related: ADR-044 (every abort points at the Nikaia line — this record is its occasion and not its reason), ADR-021 D5 and ADR-037 D4 (the cache key, which D6 adds nothing to because this is not a switch), ADR-002 D1 (the generated Cargo project D6 writes into), ADR-033 D8 (the precedent this decision deliberately does not follow: a semantic default that cannot be switched off).

1. Context

The specification says nothing about what an arithmetic overflow does. That is a gap and not a contradiction, which is what makes this the smallest version of the change: Part III A.2 already has the category, and overflow was never written into it. Its words are “Errors indicating an inconsistent program state (Index Out of Bounds, Division by Zero, explicit panic())”. An overflow is the same kind of thing as a division by zero, and the neighbouring case was decided long ago.

What the gap cost in the meantime is measurable. The generated Cargo project names one profile, dev, so a user’s program aborts on an i32 overflow; every benchmark in this repository runs --release, where it wraps. So the measurements were taken on wrapping arithmetic and the shipped program aborts — two different programs, and nobody chose that.

2. Decisions

D1 — An overflow aborts, always, at every build.

The same program computes the same thing under either build. Part I 1.2’s rule — what you choose when you build is how, never what — then holds for arithmetic too, and a benchmark measures what is shipped.

The whole of the change is one row: Part III A.2’s list of unrecoverable errors gains the overflow.

D2 — Wrapping and saturating arithmetic get names, not symbols.

let h = h.wrapping_mul(31).wrapping_add(c)       // a hash, wrapping on purpose
let volume = level.saturating_add(increase)      // stops at the maximum
let n = a + b                                    // aborts on overflow

No new operator. Swift’s &+ is out because & is borrowing in this language and a &+ b collides with &a to the eye. And if saturating does not earn a symbol then neither does wrapping: both are rare and both are meant to be visible.

The names are Rust’s, one for one, so the emitted code stays idiomatic and a reader comparing the two languages has nothing to learn.

D3 — The full set, including the three that are always forgotten.

wrapping_add, wrapping_sub, wrapping_mul, wrapping_div, wrapping_neg, wrapping_abs, wrapping_shl, wrapping_shr, and the same with saturating_ wherever Rust has them.

Three are easy to leave out, and leaving them out makes apparently harmless code abort:

D4 — A narrowing conversion aborts too, by a second mechanism.

A 64-bit number put into a 32-bit one may not fit. Same rule: abort, and whoever wants truncation says so. Otherwise the question is answered at the front door and let back in at the back.

D6’s mechanism cannot carry this, and that is not a flaw in either. Rust’s as is defined to truncate: there is no check to switch on, because the language below has no failure case there. Measured — 5000000000 as i32 prints 705032704 under -C overflow-checks=on and under -C debug-assertions=on alike. So nothing about a conversion goes through the project file, and this record carries two mechanisms rather than one that appears to cover both.

So a narrowing conversion is not emitted as a truncation. It is emitted as a checked conversion whose failure is unwrapped, and an unwrapped failure aborts.

The objection that refused helpers for arithmetic does not reach here, for two reasons the arithmetic case does not have. A conversion is rare and the programmer wrote it down on purpose — it is not in every loop. And the checked form is what a Rust programmer would write at that spot anyway, so somebody reading the emitted code finds nothing foreign.

The built-in form covers one half of it, measured rather than assumed. i32::try_from(x) exists for an integer and hands back a failure value. i32::try_from(f64) does not exist — the trait bound i32: TryFrom<f64> is not satisfied — so a conversion out of a floating-point number needs its own test for the range and for “not a number”, which is where a short helper in std earns the place the arithmetic case denied it.

The message needs nothing of its own. The abort names the generated line, and ADR-044’s table translates it to the Nikaia line — the same answer as for every other abort.

D5 — A literal that does not fit its type is a compile error, not an abort.

let x: i32 = 3000000000 is decidable where it is written. It costs nothing to say so and it saves an abort nobody needs.

D5.5 — And so is a division whose divisor is a constant zero.

let q = 1 / n where n is a constant zero is decidable in the same place by the same machinery, and it was refused in the same way: rustc’s unconditional_panic, saying “attempt to divide 1_i32 by zero” about the generated file. D5’s sentence is “decidable where it is written”, and this is; the only reason it is not already covered is that D5 was written about a literal and a literal is not the only constant.

This does not make a division by zero a compile-time question. Part III A.2 keeps it where it is — unrecoverable, at run time, naming the Nikaia line (ADR-044) — and that is what every division by something this compiler cannot evaluate still does. What changes is the one case where the answer is already on the page: a divisor the compiler can prove is zero is said here, and the fold that proves it says nothing about any other.

NK1118, and the fold’s polarity is the whole safeguard: a divisor that does not fold is a divisor nothing is claimed about (Part III, C.4).

D6 — The arithmetic check is forced through the generated project, and not through helper functions.

A checked helper per operation would put the meaning in the code rather than in a setting, and it was considered for exactly that reason. It is refused: the arithmetic is identical either way, but anyone who opens the generated Rust during a benchmark comparison and finds helper calls where + should be will not believe the numbers. For a language that argues from readable Rust, that is a loss with nothing bought.

So the emitted code says a + b, and the project file carries the check. This reaches arithmetic and nothing else: a conversion has no such switch to be carried by, which is D4’s second mechanism.

The check applies to the generated program and not to foreign libraries, and that is not a detail. A hash function in a Rust dependency wraps on purpose and would abort with the check on. So it takes the general setting for the shipping build and an exception for every foreign package — two blocks, and Nikaia writes that file on every build anyway.

Measured, because both halves had to be true at once: with overflow-checks = true for the program and false for package."*", a dependency’s wrapping hash returned 2146775615 and the program’s own a + 1 aborted with attempt to add with overflow, in one run.

A test nails it down: a program that overflows, compiled in the shipping build and run, must abort. Without it the setting disappears unnoticed one day.

The known limit, written down rather than left out: whoever takes the generated Cargo project and builds it with other settings gets different numbers, silently. That is the same class as someone rewriting the generated code by hand.

D7 — Truncation gets a name, and three neighbours get the same treatment.

Keeping the low digits is said rather than assumed, exactly as wrapping is (D2). The name maps onto Rust’s as, which is the operation it is.

Three cases belong to this family and would otherwise each arrive on its own later:

And a test like the arithmetic one: a number that does not fit the smaller type, compiled in the shipping build and run, must abort.

3. Consequences

Part III A.2 gains a row and Part I gains D2’s methods, D4’s rule and D5’s. None of it contradicts a sentence that is there; the area was simply unwritten.

as turns out to be unspecified altogether. The parser has cast_expr and examples/1brc.nika uses it four times (first as i64, self.sum as f64), and no section of Parts I–III says what a cast is or does. D4 cannot be written without that being written first, so the cast is specified here by necessity rather than by plan.

A third case was decided at compile time by rustc already, and that was a problem. let b = a + 1 where both are constants was refused by rustc with “this arithmetic operation will overflow” — a message about the generated file, which is what Part III C.1 forbids. So D5’s compile-time check had to cover the constant-expression case and not only the literal, or the gap would have moved rather than closing. It does now (§4), by folding the expression where an operand’s declaration pins the type — and the pin is what keeps the rule inside C.4, because a literal standing alone still has no type and is still not refused (Part I 2.4).

Looking for that case found one more, one operator over and of the same class: a division by a constant zero, rustc’s unconditional_panic. D5.5 covers it.

This adds nothing to the cache key. The check is not a switch — it is on in every build — so ADR-021 D5’s enumeration and ADR-037 D4 are untouched. It is deliberately unlike ADR-033 D8’s ordering, which can be turned off: that record’s argument is that a semantic default should be offered rather than imposed, and it does not reach here, because turning this one off would change what a program computes.

Not now, and recorded so it does not come back as a new idea. A prover that rules overflows out in advance does not replace this decision: whatever it cannot prove still needs an answer at run time.

4. What is built

All of it. The generated project carries the arithmetic check for the program and turns it off for every dependency; std’s ledger has the wrapping_ and saturating_ names for i32 and i64, the two integer types Part I 2.2 offers; an out-of-range literal is NK1116 in this language’s words.

Including the constant expression §3 left open. NK1116 now folds: a literal, an immutable let whose value folded, + - * / % and a negation, in an i128 so that a sum which cannot fit an i64 is a number the message can name rather than one the fold wrapped. The type it answers to is what stands beside it, or — where nothing does — what an operand’s declaration pinned, which is what makes let b = a + 1 answerable with no annotation on the line.

Every step is a checked one and None claims nothing: a mut local (this checker does not follow assignments), a parameter, a for binding, a cast, an operator outside the five, or a fold that leaves the i128 each stop the whole expression, and an expression that does not fold is never refused.

One residue, and it is not this record’s. A literal that nothing constrains — let b = 3000000000 + 1 — is still rustc’s refusal, because rustc has the inference that defaults it to an i32 and this checker has none. Refusing it here would refuse the program that passes the sum to an i64, which C.4 forbids; it is one entry (#142, the out-of-range literal nothing constrains) and not two.

And NK1118 for D5.5.

D4. A narrowing conversion is emitted as a checked one. Which conversions those are is the checker’s answer and not the emitter’s, because as i32 narrows or widens by what it is given: the checker records a pair of (statement, target type) where every such conversion in that statement narrows, and the emitter looks the pair up — the same shape already used for fallible loops and methods (ADR-025 D7, ADR-028). A statement where one conversion to a type narrows and another widens records nothing, which leaves both as they were.

The list in the checker is written the other way round — which conversions always fit — so a pair nobody thought about is checked rather than truncated in silence (ADR-010 D1’s direction). Between integers the check is try_from; out of an f64 it is nikaia_std::num, which Rust gives no try_from for. That helper goes through i128 rather than comparing against the target’s limits as f64, and the reason is the measurement D7 rests on: i64::MAX as f64 rounds up, so the obvious comparison accepts a value one past what the type holds. f64 as i128 saturates far outside every target’s range, so try_from then answers exactly — and for a type whose width the machine decides, without a constant that would have to differ per machine.

D7. truncating_i32 and truncating_i64 are std entries whose signature the ledger carries, and the emitter lowers them to Rust’s as in parentheses — as sits between the unary and the binary operators in Rust’s precedence while a method call sits above both, so -x.truncating_i32() has to keep meaning -(x as i32). The sources are the three a Nikaia program can narrow from: i64, f64, and the machine-width type len hands back. crates/nikaia/tests/overflow.rs compiles the conversions with -O and no check flag, which is the point: a conversion carries its own answer and takes none from the build.

isize is checked and has no truncating_ name. Nothing in the ledger hands one back, so no program can reach a conversion out of one — but it is in the checked list anyway rather than left to truncate, which is the same fail-closed direction. The day something produces an isize, the name comes with it.

Still reaching rustc rather than this compiler: a sum of constants that cannot fit, which §3 names.

This site is open source. Improve this page.