Hacker News new | ask | show | jobs
by rkangel 3139 days ago
Note that the panic you get by calling unwrap() where you shouldn't isn't a crash. It's a controlled program exit due to an unexpected condition. While yes, the panic will cause your program to stop, it will do it in a clean deterministic way (with a backtrace).

Actual crashes (due to segfaults) can happen a long way from the bug that actually caused the issue, can happen intermittently and generally be a nightmare to debug.

6 comments

I think the use of the term "crash" varies. While it's true for e.g. a C program that there are better or worse crashes, in most other modern langs with some more safety guarantees, the term "crash" is normally used for controlled program termination due to unhandled exceptions.

So coming from that type of language (where you can't segfault) I'd definitely call a panic a "crash" simply because it's the analog of an unhandled exception, which I always called a crash.

So this terminology probably varies between ecosystems

For yet another different, but related, use of the word, check out 'crash-only software': https://lwn.net/Articles/191059/ or https://en.wikipedia.org/wiki/Crash-only_software

It's a concept implemented in eg Erlang. Crash in their sense means basically, 'kill -9'.

It's interesting that the phrase "unhandled exception" has crept so pervasively into our terminology, since "exception" has connotations (e.g. first-class values representing errors, which we can construct, pass around and "throw") and "unhandled" implies that they could be "handled".

Haskell is a great example of how handling errors can be harmful, since it violates confluence, even though throwing errors is fine!

Confluence is the property that evaluation order doesn't change the meaning of a program, e.g. we can do:

    (1 + 2) * (3 + 4)
    3       * (3 + 4)
    3       * 7
    21
Or:

    (1 + 2) * (3 + 4)
    (1 + 2) * 7
    3       * 7
    21
We could inline some function calls if we like, thanks to referential transparency; we can even evaluate "under a lambda" (i.e. evaluate the body of a function before calling it, or evaluating the branches of an `if/then/else` before picking one); regardless of which way we evaluate, if we reach an answer (i.e. don't get stuck in a loop) then it will be the same answer:

    (1 + 2) * (3 + 4)
    (1 + 2) * (if 3 == 0 then 4 else pred 3 + inc 4)
    (1 + 2) * (if 3 == 0 then 4 else pred 3 + 5)
    (1 + 2) * (if 3 == 0 then 4 else 2      + 5)
    (1 + 2) * (if 3 == 0 then 4 else 7)
    (1 + 2) * (if False  then 4 else 7)
    if (1 + 2) == 0 then 0 else (if False then 4 else 7) + (pred (1 + 2) * (if False then 4 else 7))
    if (1 + 2) == 0 then 0 else 7                        + (pred (1 + 2) * (if False then 4 else 7))
    if 3       == 0 then 0 else 7                        + (pred (1 + 2) * (if False then 4 else 7))
    if 3       == 0 then 0 else 7                        + (pred (1 + 2) * 7)
    if False        then 0 else 7                        + (pred (1 + 2) * 7)
    if False        then 0 else 7                        + (pred 3       * 7)
    7                                                    + (pred 3       * 7)
    7                                                    + (2            * 7)
    7                                                    + 14
    21
Exception handlers break this, since we can write expressions like:

    try (head [42, Exception1, Exception2])
    catch Exception1 -> 1
          Exception2 -> 2

We can evaluate this one way:

    try (head [42, throw Exception1, throw Exception2])
    catch Exception1 -> 1
          Exception2 -> 2

    try 42
    catch Exception1 -> 1
          Exception2 -> 2

    42
Or another way:

    try (head [42, throw Exception1, throw Exception2])
    catch Exception1 -> 1
          Exception2 -> 2

    try throw Exception1
    catch Exception1 -> 1
          Exception2 -> 2

    1
Or another way:

    try (head [42, throw Exception1, throw Exception2])
    catch Exception1 -> 1
          Exception2 -> 2

    try throw Exception2
    catch Exception1 -> 1
          Exception2 -> 2

    2
This gives 3 different answers. Note that the throwing itself doesn't cause this problem, because we treat an "unhandled exception" as not getting an answer (equivalent to an infinite loop).

When I first grokked this it was quite enlightening: adding features to a language can make it less useful. It's not that certain features (like throwing or catching exceptions) are "good" or "bad", but that we must think of languages as a whole, and knowing that some things aren't possible (like observing evaluation order) can be just as useful as allowing more things. This contrasts strongly with the tendency of languages to accumulate features over time, especially when the major justification is often "we should have it because they do" :)

There's also a nice discussion on errors vs exceptions at https://wiki.haskell.org/Error_vs._Exception

The issue here seems to have little to do with exception handlers; see that

    head [0, throw E]
also produces such an "indeterminate" answer depending on order of evaluation, as long as you define things as you have here.

The solution in a lazy language like Haskell is to be specific about when things get forced, which outside of explicit overrides only happens in response to actual usage of that expression, eg. when the value is printed. At this point you're naturally forced to introduce either sequentialization or explicit parallelism; in the former there is no issue and in the latter you still need to explicitly sequence the results, of which the exceptions are a relevant part.

In a strict language like Rust, of course, you never aimed to have this property anyway.

> The issue here seems to have little to do with exception handlers; see that

> head [0, throw E]

> also produces such an "indeterminate" answer depending on order of evaluation, as long as you define things as you have here.

I disagree; note that I said:

> we treat an "unhandled exception" as not getting an answer (equivalent to an infinite loop)

More formally, throwing an exception/error results in _|_ (bottom) and all _|_ results are equivalent i.e. we can't tell "which error" we got. In particular, we can't tell the difference between _|_ due to an error being thrown, and _|_ due to an infinite loop.

This is important in a general recursive language, since we can't know (in general) whether evaluating some value (with whatever strategy) will eventually produce a result or not. Consider the following, where omega = (\x -> x x)(\x -> x x):

    head [0, omega]
Under a call-by-name strategy this will reduce to 0, under call-by-value it will loop forever, i.e. giving _|_. Should this count as breaking confluence?

Total languages like Agda and Coq would say this breaks confluence, due to general recursion being a side-effect, and hence it should be encoded as such rather than allowed willy-nilly.

Turing-complete languages like Haskell would say that such encoding is cumbersome, and hence that their notion of confluence should be weaker; specifically, evaluating an expression using any evaluation strategy to get a value other than _|_ will produce the same value.

It just so happens that lazy evaluation has the property that if some (perhaps unknown) strategy can reduce an expression to normal form, then non-strict evaluation can also reduce it to normal form. In other words, lazy evaluation avoids all 'avoidable' infinite loops, but it still gets stuck in 'unavoidable' ones. That's nice, but isn't important for confluence.

You're perfectly right that introducing sequencing like `seq` throws a spanner in the works :)

> throwing an exception/error results in _|_ (bottom) and all _|_ results are equivalent

My point is that they're not when you have `catch`, and that this distinction adds nothing that ⊥ doesn't already add with regards to order of evaluation.

I'll have to think about this some more, since I don't quite understand.

Let's say we have the following Haskell definitions:

    error msg = undefined  -- "throwing an error"
    undefined = undefined  -- infinite loop
In this setup, there's no way for us to tell, under any evaluation strategy, whether or not an expression evaluates to _|_. Any code we might write to "check" for this would have to run "after" an infinite loop, which is impossible. Hence it's impossible to write an expression which, under any evaluation strategy, normalises to two distinct non-_|_ values: either it always produces the same value, or it sometimes produces one value and sometimes _|_ (depending on the strategy), or it always produces _|_ regardless of strategy.

If we can distinguish between "different _|_s", e.g. catching some as exception values, then we can write an expression which reduces to different non-_|_ values depending on the evaluation strategy, and hence we lose confluence (the weaker form; we already lost the stronger Coq/Agda form by having _|_ in the first place).

This is fundamentally different to the value-or-_|_ uncertainty, since that's unobservable from within the language.

IMO it is good to call this a crash in the sense of fail-fast. Then you do not get into the "but my program doesn't run anymore after that, what do you mean that's not a crash" discussion. But it is not undefined behavior. It crashes _all the time_ in that situation, which makes life so much easier than UB.
Perhaps it's an "emergency landing".
In systems contexts "crash" means sigsegv or sigill (but not e.g. an intentional abort)

In non-systems contexts it can just mean "premature termination". Rust, having both kinds of programmers, uses .... both :)

So a Rust panic is a crash, but so is a Rust segfault, depending on who you talk to.

Generally I try to explicitly say "panic" and "segfault".

Thank you for correction. My native language is not English, but I meant a controlled exit.

It requires a bit of thinking how your library is structured when you need to avoid unwrap(). But in the end the result just works and I like how Rust forces a certain design to be safer.

You could say the same about NullPointerExceptions, and while they're better than segfaults - in practice they're not that much better.
I think you're hitting an interesting point here. In my experience, there are two kinds of NPEs:

1) On a method call somewhere in a stack with no null checks: here it's not very useful to have an NPE, since it should work (eg. there's no null checks, so why should it fail?).

2) `Objects.requireNonNull(o)` (or an explicit through). This indicates that a pre-condition is that the object should not be null. These tell that the fault is in the preceding call stack, therefore you don't even need to understand what the underlying one is to know where the error is coming from.

Although all types are nullable in Java, I don't expect _everything_ to be null-checked. Nullable types in Rust stand out, therefore should be matched or checked. If I have a panic due to a naked `unwrap()`, either there's a documented pre-condition and the system isn't recoverable (hence the crash), or the error handling is missing and I know what to do.

Hence `unwrap()` is similar to (2), which are useful panics.

It's really all about the practice. If you make liberal use of `panic!` (or things that may panic, like `unwrap()`, then you basically get into situation 1.

You're definitely right that Rust is better than Java in that it makes things that may crash (like the naked unwrap()) obvious and "yucky", rather than "usual business". But that doesn't mean that a panic is not a crash - it still is. The language itself is better - but that doesn't mean you can't write code that crashes :)

When this controlled program exit happens, does your monitoring system wake your operations staff up? If so, it's a crash.
Whether it's a crash or not is independent of whether you even haven a monitoring system, an operations staff, or even if you run the program as a service or not.

Heck, your monitoring system could still notice the program exiting and call your operations stuff in Rust's case too.

And a user with sudo rights killing -9 a program you run might or might not send anything to your monitoring system -- but that wont be a crash either.

I think the important point the parent is trying to make is between a crash and a controlled exit, that is whether you get a stacktrace, things can be called to cleanup, etc.

Merely calling all the cases just "crash" would lose that distinction. It's like calling all vehicles "vehicles". Sure, it's accurate, but I want to know if it was a car, a motorcycle, a truck or whatever.

In Rust a panic brings down a single thread (unless you have it set to panic=abort, which is the case in firefox but not elsewhere).

Panics can also be "caught" like exceptions, though this is not something you're supposed to use to implement exception handling. The idea is that if you want to make your application robust you can catch these panics near the top and try to recover.

Often panics bringing down a single thread will bring down other threads that try to read messages from it and have declared that they will panic if that is not possible. (.recv().unwrap() is a common idiom).

So it depends on how you use it.

And "monitoring system" is assuming the context of a server side application.

Yes, but you also immediately know where it happened, what happened, and how to fix it.

That’s a massive improvement over alternatives