Counterexamples in Type Systems: programs that crash, segfault or explode (2021)
counterexamples.org
counterexamples.org
- Breaking Badfny: https://www.reddit.com/r/Coq/comments/x4d31y/breaking_badfny...
- Falso (Coq): https://github.com/clarus/falso
- Coq critical bugs (some of these mention potential ways to prove false): https://github.com/coq/coq/blob/master/dev/doc/critical-bugs
(You can also get the theorem prover itself to hang or crash; if you work with theorem provers often, you'll even do this unintentionally, many times!)
For example, in Agda, there are two ways to create coinductive types. The most liberal and useful one uses a technique called "Sized Types". Sized Types are types where you annotate each type with a compile type upperbound "size" which allows compiler to prove a given coinductive computation on this object will terminate. You can image a conductive list "List T i" where "i" is a "Size Type". Ultimately, the compiler implements this using a mix of lazy evaluation and bookkeeping upperbounds using natural numbers.
Originally, this was part of the "safe" subset of the language. Meaning, you can write Agda code using Sized Types, and Agda compiler claims the subset of the language you use is sound. This means you cannot prove inconsistencies. Here comes this "bug" which showed otherwise: https://github.com/agda/agda/issues/1201 Since 2015 Agda developers have been working on ways to mitigate this problem. Finding no such solution, they eventually decided in May 2021 to remove Sized Types from the "safe" subset of the language: https://github.com/agda/agda/pull/5354
Edit: I found an explanation https://ionathan.ch/2021/08/04/sized-types.html
Proving something false is a grave sin for a prover. On the other hand, failing to prove something true is not only forgivable, but inevitable as per the incompleteness theorem. While ideally this failure would presented in terms of a nice error message, crashes are preferable to unsoundness. Crashes can even be deliberately induced as a defensive measure: https://hal.science/hal-04096390/document
Essentially, you fix the bug, re-run your hol or whatever, and hope your theorem is still true.
Obviously this only applies to bugs in the implementation as opposed to an unsound logic.
Of course, Agda is somewhat unusual because they are kind of making up the (very strong) logical theory as they go along instead of having a fixed core logic they could dump proofs in for double-checking, like Coq. On the flip side, Agda actually makes for a halfway usable programming language—unlike Coq.
Sometimes it is mentioned; a Rust example and a Java example were fixed years ago. So it seems... a little unfair? Obviously all software has bugs; compilers are no exception. Examples where the type system itself is unsound, and can't be fixed without a redesign, are IMO the only interesting ones.
A type system that allows undefined behavior is equivalent to a logic system that allows proving a falsehood, and ex falso quodlibet – from a falsehood you can prove anything.
> according to the article, a type system only deserves that name if it prevents use-after-free errors, memory corruption etc. etc.
Soundness is a technical term.
Things can get pretty weird if you allow memory corruption. Some languages say that certain things are Undefined Behavior. Imagine if a logician said the following:
- Here is my sound and complete logic
- By the way, some valid formulas will give you Undefined Behavior and I don’t care what happens in those cases
That would be pretty wild.
UB in C allows for a lot more than just memory corruption though (c.f.: nasal demons). Also, what about alternatives to UB, such as requiring a compiler error and/or runtime fatal error?
I mean, you are hiding a lot of possibilities in "etc. etc.". So is the article, when it says "and the like" in the sentence you're referring to. The kinds of things that belong in this list are described as "generally some sort of safety property." That's a big big list of things. It's not a particularly strict or narrow definition of type system, IMO.
Take C. What does it mean when C's type system says a variable "x" has type "int"? Practically, it means the compiler knows how many bytes (let's say 4) to reserve on the stack for x. But what if the program, during the course of execution, tries to write an 8-byte piece of data to x? The language is broken if that can occur because the compiler was not justified in reserving only 4 bytes for x. So, part and parcel of C's type system is a global safety property: It promises, among many other things, that at runtime no assignment will ever occur to an int-typed variable unless the assignment payload is 4 bytes.
So this is exactly what the article is talking about. Local rules (e.g., "int x = y" is well-typed if y has type int) working together to guarantee a global safety property (no type mismatches can occur at runtime).
The failure of a type system to do this properly is a failure at being a type system. And indeed, since C has undefined behavior, it's type system falls apart immediately. It's just not even interesting to consider enumerating the ways it can be broken.
The type systems considered by this article are ostensibly supposed to not be broken the way C's is. That's what makes the counterexamples of this article so interesting.
For example, I would consider Java to be a strongly-typed language, but it has quite a few unsoundness issues that can come up in regular, non-contrived code. Scala's type system is stronger and more full-featured, but it's not clear to me that it's significantly more sound. Rust, perhaps, has a (strong) type system with fewer soundness issues than both Java and Scala, but some issues do exist.
C and C++ have comparatively weak type systems (though C++'s is certainly stronger than C's), and their type systems have quite a few soundness issues, many of which that can be exposed via simple one-liners.
Also consider that soundness issues are not all created equal. I would much rather see a ClassCastException at runtime in a Java or Scala program, than see memory corruption (with possible security or data-integrity consequences) in a "valid" C/C++ program when I misuse memory designated as one type, as another. Certainly the best case would be a compiler that wouldn't allow it in the first place, but the Java/Scala example is nowhere near as bad as the C/C++ example.
Or another way to look at it: if I tell you you're holding an Integer, and when you try to use it in addition, it explodes, was I really justified in telling you you were holding an Integer?
It makes no claims on what happens when you overflow signed integers.
Unsigned integer wrapping is specified by section 6.2.5, paragraph 9 of the C standard.
There isn't more or less sound. There's really only sound or not.
If you require perfect, 100% soundness in all circumstances, then nothing is sound.
(Probably. We have things that we thought were sound, and later proved were not, so if you start with the list of things now that have no known holes, that's the upper limit of things that are possibly sound, modulo future holes.)
If you seriously try for perfection, you can sometimes get pretty close. But if you insist on perfection or nothing, you usually get nothing.
Soundness in logic requires both valid form, and reasoning from true premises.
Indeed, it's impossible to prove absolutely any fact about a real physical system. You prove things about abstract mathematical objects. Such a proof may be useful in practice when the assumptions you made for the proof are satisfied to a sufficient degree in a sufficient number of cases. Whether this is the case or not is ultimately a matter of judgement (and data, for which you have to use judgement in collecting/interpreting it, and data, for which...)
By the way, the paper you're using to write proofs in mathematical logic is a physical system.
(The endless appeal to practicality and physicality is both tiring and irrelevant. Circles as a shape don't exist in the real world because you can't use physical objects to create a circle; at some point, perhaps at the level of atoms, the perfect smoothness of the ideal circle breaks down into discrete atoms. So circles don't exist. Does it bother me? No. Does it bother you? Evidently it does.)
I responded to you because it seemed (no snark intended) that "more or less sound" things existing bothers you. The person who mentioned "more or less sound things" was referring to those things that exist in practice. The local discussion was (I thought) about type systems that exist in practice in the sense that a real practical C compiler has one. I think OP was justified in talking about this here, even if the article does not, because the theory exists at least in part to inform practice.
Also, if anyone has read the book, are there any interesting Rust counterexamples?
Many interesting quirks in type systems, surprising interference between separately innocuous type system features, but don't expect too many explosions.
Indeed, many of the tricks are years old (e.g., 2006) and are fixed in current versions of the language (or even are research paper involving experimental systems that were never released, e.g. Privacy violation in C# doesn't even use a syntax that released C# compilers use).
Mutable matching in Ocaml can be reproduced with ocaml 4.13, still, and cause a segfault.
"How to design good software" is a huge question, but "what are some mistakes you've seen with tool X" seems like a great way to catalogue common errors that people make while learning.
I'd love for a major framework/tool to poll its users and create an index of common design counterexamples. e.g. "common design mistakes with serializers, models, caching, etc".
+1 if that resource included ways to refactor away from the bad design. So if the counterexample is "Querying the database directly from the controller functions", there could be a list of ways to refactor a codebase away from that design in pieces, and what to consider at each step (since some problems aren't worth the effort of fixing).
People who don't like it should avoid mutation.
I.e. the following requirement does not really compute: "I want to mutate inside a multi-case pattern, yet the pattern matching mechanism must itself refer to an immutable, private snapshot of the entire object, or behave as if that were the case."
The requirement for "soundness" there really doesn't make sense; the programmer has chosen to value mutation over soundness.
That would almost seem like a downstream compiler problem. If the pattern matching expander emits straightforward code, similar to what you would write by hand, then it would have to be the compiler doing the wrong thing there, like doing type inference without noticing that some location whose type was inferred was replaced by a differently typed object.
If pattern matching itself generates type hints for the compiler, then the fault could lie there.
As of 2023, anyone whose language claims to guarantee behavior of all programs is either kidding themselves or will never have a user base to speak of for their language.
To my money (for c++ in particular), the ledge of soundness is so razor thin that I might as well assume it's not there.
In fact, for any extensional type theory, we have the interesting result that type inhabitation is undecideable. Further, when looking at intersection types, there's also a theory saying they're undecideable, and many more edge cases which I can't remember. So the result is that we have a lot of "patching" in compilers or runtimes that try to fill some of these holes, but that's to be expected.
> It's intended as a resource for researchers, designers and implementors of static type systems, as well as programmers interested in how type systems fit together (or don't). [emphasis added]
Change the objective, and dynamic type systems would be appropriate.
Since there is no difference in Python and other untyped languages between a potential typechecking stage and runtime the definition is meaningless for them.
> Python's type system: Dynamic languages like Python do local type checks to establish global safety properties. However, the type checks are not done purely on the program's source code before running it, but examine the actual runtime inputs as well.
(declaim (optimize (safety 0)))The dangerous game is this:
1. Prove some things about the program graph.
2. Assume that your proof is correct, and translate the program accordingly (possibly with aggressive optimizations based on the proofs).
3. Remove all the proof artifacts from the program, such as types, and run the compiled code with no further safety net.*
The entire Counterexamples project speaks to the truth in Donald Knuths' famous quote:
"Beware of bugs in the above code; I have only proved it correct, not tried it."
--
* Even if there is a safety net, the selling point of the languages is that it's unnecessary. If the type system is supposed to catch something, but it's not caught, and results in a run-time error (safely caught and everything) that still demonstrates unsoundness. It's not behaving as advertized/hyped.
real 'this never happens' energy
"Nobody knew why" could mean bad code, communication breakdown or a host of things. You can make code puzzling in any language.
Try selling that to your boss who runs a mission-critical app.
I tested my Python app on a list with ten elements and it works fine. But in production I unexpectedly received a list with a million elements! It crashes! Or more likely, it repeatedly receives a short list but still runs out of memory due to a memory leak I hadn't considered.
Python doesn't prevent a bad software engineer from writing bad code. Try to sell that.
How do I know this? Because our big Haskell app died this way on live customer data and became a fiasco for my employer. We dumped Haskell and never looked back.
Then, I need to understand and modify the evaluation behavior of a deeply-layered external software stack, including its crazy type-level magic, all before a looming deadline.
Seriously, how is this good for my sanity or career?
0/10 would not recommend.
> This is intentionally a fairly narrow definition. Some examples of things that are not "type systems":
> Python's type system: Dynamic languages like Python do local type checks to establish global safety properties. However, the type checks are not done purely on the program's source code before running it, but examine the actual runtime inputs as well.
It does not say "python is strongly typed but [...]", not even "python is strongly typed". The words "strong", "strongly", and "strength" do not even appear. TFA says that Python being dynamic means it is not type-checked at compilation time -- that's practically the same as saying the opposite of "python is strongly typed" for most people's intended meaning of "strongly typed".
Strong typing is a weak concept (pun intended). The most useful meaning is, roughly, no or little automatic type coercion (so a numeric tower might still be "strong", but coercing a string "1" into the number 1 is "weak"; `"1" + 1` => `"2"`). Anyone who uses "strong typing" as synonymous with "static typing" is making a very silly mistake.
Some people know how to manage memory and others don't. If you know how to manage memory, even C is a safe language. If you don't, not even the most formally-verified garbage-collected language can save you.
So the power of Rust comes from the the cooperation of the developer with the compiler and development environment willing to work through their severe instruction.
Developers in general unwilling to learn how to manage memory walk away from Rust based devteams.
Memory management is harder than just remembering to free. It requires a deep understanding of ownership, lifetimes, and threading. That is hard at scale unless it is built into the language.
This is correct, technically, but you can achieve really high assurances of safety. "safe" is not a binary, but a spectrum.
The rest of the comment is patently false. It's actually close to the opposite of reality. The stricter the type system, the smaller the risk of unexpected behavior. Very very smart people who "know how to manage memory" use C and introduce memory errors very often. It's actually only in small, ungeneralizable programs where weaker type systems don't matter.
And yeah, any bug on the interface or its implementation will lead to unsafe memory usage.
But anyway, no, if you make an interface where it's impossible to get memory corruption, you won't get memory corruption. It doesn't matter if the most inapt programmer on the world uses it. And the idea that people can manually manage memory on programs that are hundreds of megabytes large and created by many developers (or a single one at many different times) is about as wrong as the first part.
>Some people know how to manage memory and others don't. If you know how to manage memory, even C is a safe language. If you don't, not even the most formally-verified garbage-collected language can save you.
Who knows how to manage memory then?
Windows engineers? Chromium engineers? Linux engineers?
all of them have history of failing at this, stop with this mindset.
The conversation about language "safety" always leads to a bunch of goalpost-moving. Is a language with a safe type system still safe if its runtime is buggy? What if the OS/kernel has a bug? What if the CPU has a bug? How about anomalies in I/O hardware? Keep going down the rabbit hole, and you'll eventually reach the conclusion that all software is extremely fragile and could explode into a million pieces if a single cosmic ray goes the wrong way. Thinking about safety in an absolute sense isn't productive.
As an aside, your CS buddies told you a bunch of nonsense.