"Utmost safe" functions

In the thread about unsafe functions, tera and Michel Fortin laid out an idea for a closely-related feature in which functions could be proven to never do anything unsafe — that is, like unsafe but with no ability to escape the analysis and assert that an unsafe thing is actually safe. There was a lively discussion of that idea in that thread, but I think the time has come to consider them separately, so I've created this thread to continue that discussion.

I was originally intending to move the posts about "utmost safe" over to this thread, but I ultimately decided that that was more harmful than not. If you're interested in this topic, please read the original thread up to where I split them apart; this thread takes off from that point.

2 Likes

Just to clarify: my suggestion was to differentiate functions assumed safe according to some imperfect heuristic of the importer from those functions where a human vouched for its safety. It never was about being provably safe with no escape hatch. The way I see it, it's an orthogonal subject from @tera's "utmost safe" concept.

Whether it's useful to have an "audited safe" or not of depends on how conservative the importer is when labeling functions as safe and unsafe, and what kind of breakage we want to happen when someone discovers something was imported too permissively in regard to memory safety and it has to be corrected. The "assumed safe" concept is a bit like implicitly unwrapped optionals for imported functions and types sitting between optional and non-optional.

... and now I feel like I already derailed this thread since this has nothing to do with "utmost safe".

2 Likes

Ah, sorry for the misunderstanding.

1 Like

The main feature of the proposed "utmost safe" safety level – is guaranteed and compiler checked memory safety (I think we could also discuss other types of safety and the corresponding colourisations in this thread).

No matter how good developer is there are always possibilities of bugs:

/*safe*/ func foo() {
    ESCAPE_HATCH {
        someSafeCall()
        if condition() {
            someUnsafeCall()
        }
    }
}

Here we've audited the code and came to conclusion that condition() will always evaluate to false. However we may have overlooked something and it does return true sometimes, or it indeed always returns false now, but a few months later due to refactoring in seemingly unrelated code it starts returning true, triggering unsafe code execution and potentially worst sequences it could bring – loss or corruption of user data.

With the bikeshed utmost safe colour this won't happen:

utmostSafe func foo() {
    ESCAPE_HATCH {
        someSafeCall()
        if condition() {
            someUnsafeCall()
        }
    }
}

There would be a guarantee that even when condition() starts returning true – unsafe code would never be executed, the app would trap instead (on a precondition). The relevant precondition calls would be generated by the compiler for utmostSafe functions for the "leaf" functions:

/*safe*/ func bar_safe() {
    if otherCondition() {
        ESCAPE_HATCH_LEAF {
            malloc() // example unsafe leaf function
        }
    }
}

// autogenerated utmost safe version:

utmost_safe func bar_utmostSafe() {
    if otherCondition() {
        precondition(false)
        // malloc() // example leaf function
    }
}

Possible implementation:

func ESCAPE_HATCH_LEAF(_ trustedCode: @trusted () -> Void) {
    if trustedCodeExecutionAllowed {
        trustedCode()
    } else {
        fatalError("not allowed")
    }
}

with the only difference to what "trustedCodeExecutionAllowed" evaluated: it would be effectively set to true in the unsafe and /*safe*/ functions and to false in the utmostSafe functions.

Here's a sketch of an algorithm how to do the transformation from a safe function to an utmost safe function. It could be done manually now or with a compiler support in the future:

  • we have a safe (trusted) function, let's look in its body and all things it's doing
  • if it's a "utmost safe" thing - leave it as is
  • if it's a "trusted" thing change it to the "utmost safe" thing if that version is available
  • if "utmost safe" version is not available and it is not possible to generate it (e.g. it's a C function) embed this thing in the above ESCAPE_HATCH_LEAF block.
  • as an optimization a several "trusted" calls in a row could be embedded in a single ESCAPE_HATCH_LEAF block.
  • once the body of the function is transferred to be "utmost safe" the function itself (or it's version) could be re-labeled as "utmostSafe" concluding the transformation process.

Posts relevant to "utmost safety" from the other thread: 91, 92, 93, 95, 96, 97, 98, 99, 100

1 Like

I'm curious as to the thinking here...

If it is possible to know at compile time that someUnsafeCall() is not "utmost safe" and will therefore definitely trap if executed inside the "utmost safe" foo() function, then what is the rationale for it being a run-time error instead of a compile-time error?

Also, does it not imply that if condition() { someUnsafeCall() } is supposed to be dead-code? And if the programmer is so sure that it is then what would be their rationale for leaving it in?

These would be compile time errors (I'll be using the relevant prefixes for functions to make it obvious what each):

/*safe*/ func foo_trusted() {
    bar_trusted()
    baz_unsafe() // 🛑
    ESCAPE_HATCH1 {
        baz_unsafe()
    }
}
utmostSafe func foo_utmostSafe() {
    bar_trusted // 🛑
    baz_unsafe() // 🛑
    ESCAPE_HATCH2 {
        bar_trusted()
        baz_unsafe() // 🛑
    }
}

And these would be runtime errors:

/*safe*/ func foo_trusted() {
    bar_trusted()
    ESCAPE_HATCH1 {
        baz_unsafe() // 🟪 if triggers unsafe code
    }
}
utmostSafe func foo_utmostSafe() {
    bar_utmostSafe() // can't trigger unsafe code
    ESCAPE_HATCH2 {
        bar_trusted() // 🟪 if triggers unsafe code
    }
}

// just in case using different ESCAPE_HATCH1 and ESCAPE_HATCH2 here, they might be different

I don't think that should be collapsed to a no-op if you mean that. When "condition()" (despite what user thinks about it) finally evaluates to "true" - the code will trap, alerting the user, so they can fix their code:

utmostSafe func foo_utmostSafe() {
    var condition = true
    if x == y {
        condition = false
    } else if x != y {
        condition = false
    }
    if condition {
        // can still happen 😉
        ESCAPE_HATCH {
            bar_trusted() // 🟪 if triggers unsafe code
        }
    }
}

I think I see what you're getting at. With regards to the run-time trap examples, do you mean that it is possible for baz_unsafe() to actually act safely when called and thus not trap, but if it does try to do anything unsafe then it will trap?

Specifically, the feature seems to be that you can prove that (absent a miscompile) calling certain declarations cannot possibly violate memory safety.

What situation do you imagine where it would be valuable to enforce this? And specifically, what situation do you imagine where it would be valuable to enforce this for subsets of a module on an opt-in basis?

I can imagine use cases where you’re working with untrusted code and you want to make sure it doesn’t violate memory safety to exceed its intended capabilities, but that sounds more like you’d want to ban unsafe effects and statements when compiling the entire module of untrusted code, not just when compiling specific marked subsets of it. This would have the advantage that the untrusted code could still use APIs from other modules that are marked safe but have unsafe implementations (like Array and String, for instance). Sure, that means you could potentially exploit bugs in those APIs to violate memory safety, but the compiler could have exploitable memory safety bugs too, so this is going to be a best-effort thing in any case. A design that allows the use of any trusted code that’s intended to be safe, rather than blocking off huge chunks of API surface because there’s a remote chance it could have a safety bug, seems a lot more useful.

That’s what I’m trying to get at here. I can see how this idea would work, but I can’t see why it would be useful enough to actually bother building.

4 Likes

It’s important to be aware that at the level of the compiler and standard library in question, there is not a meaningful distinction to be made between “miscompile” and “standard library bug”. We can turn some arbitrary stdlib operation into a compiler primitive, now it’s not “unsafe”, but it’s not less likely to have a bug (it would probably be more likely to have a bug, for various reasons). Similarly, we could turn various compiler primitives to be defined in the standard library and nothing would change, but now they would be “unsafe”.

3 Likes

I thought that's obvious. By prohibiting unsafe code we are prohibiting the whole class of bugs, bugs that could otherwise surface as crashes and/or data corruption and/or data loss. By reducing the amount of unsafe code we are reducing the number of bugs. Ideally there should be no unsafe code at all; in reality we could at least aim to reduce the amount of unsafe code, so having 90% of safe code in the app is better than not.

Re: the whole module vs part of the module - I believe the above answers the question why making part of the module safer still makes sense even if we don't make the whole module safe. And in some cases we may make the whole module (or the app) safe doing the algorithmic work, and a separate "non safe" UI frontend app. The two apps will talk to each other to form the whole product (and that communication might be an "Achilles' heel" but even that communication could be made "safe", at least in theory (or at least it could be small)).

Consider the "finite" Turing machine – that can fail only when it runs out of its (finite) tape, and if we are not triggering those "out of memory" conditions it doesn't fail (and can't fail even in theory †, the infamous "Halting Problem" aside). Swift being Turing complete, could (in theory) do equally well in regards to safety.

Re: compiler bugs - yes, that's another source of bugs (we can treat those as "hardware faults" with an ability to fix the hardware by installing another version of Xcode). And if for the sake of example I mark a function "@noLock" and call it in realtime code and that function does in fact take a lock (due to a compiler bug) I'll experience that as an audio glitch, then I'll drill into the issue, find the offender, file the bug report and hopefully the bug will be fixed later on. We are mitigating the scope of possible compiler bugs to a great extent by moving large chunks of what's typically in the compiler (in other languages) to the standard library.

Yes, there are other sources of failures we can't fix, hardware failures for example. Even Turing machine would crash and burn on a hardware failure †. We just do what we can do within constraints of "what's possible". I believe it's better to make something safer instead of not making anything safer because we can't do everything safe.

Sorry, you can’t do that, because that uses unsafe pointers internally. I feel like the fact that even assert isn’t allowed is pretty damning evidence that this is less useful than you hope.

4 Likes

Correct.

Well, put fatalError() there, or Builtin.int_trap(), etc.

Hope you don't mind this side track being here.


Fifty shades of safety

Many things could cause an abnormal app termination and from a user point of view they all "crashes", although there are quite a few different flavours of those: there are traps that we or compiler put deliberately (e.g. in order to preserve safety or precondition invariants), there are crashes (like memory access errors) and there are several sub flavours of either. Here's one giant unsorted list of what we colloquially call "crashes" and possible mitigations:

  1. Memory safety violations

    Possible mitigations:

    • prohibit by having unsafe / safe / utmostsafe "colorisation"
  2. Integer arithmetic trapping on overflow, etc

    Possible mitigations:

    • remove traps (as in C. Does it still have divide by zero traps? Don't remember)
    • reduce integer range to exclude a certain bit pattern (e.g. UInt.max or Int.min) and use it for NaN similar to how floating point ops are doing it
    • make operations throwing
      Notes: to reduce "try" clutter we'd need to remove the "try" keyword.
    • make operations returning optionals
  3. fatalError() / abort() / etc

    Possible mitigations:

    • make it a throwing function instead.
      Notes: if uncaught throwing would propagate all way to the top main function, and if we allow having "throwing" main that would still cause app termination. Alternatively we disallow the top main being throwing and would be forced to catch errors at that level. What do we do (other than showing some error alert) is a further interesting question.
  4. Precondition traps

    Possible mitigations:

    • make precondition a throwing function instead
  5. Array access trapping on index being out of bounds

    Possible mitigations:

    • make it throwing
      Notes: to reduce "try" clutter we'd need to remove the "try" keyword.
    • make it return optional
      Notes: this works only with getter.. what about setter? We can make setter returning an optional result (e.g. the previous value) but can we force users to always check the returned value?
  6. Dictionary trapping on duplicate keys

    Possible mitigations:

    • make it throwing
  7. Explicit optional unwrapping trapping on nil

    Possible mitigations:

    • make it throwing
    • remove it and users will always do "if / guard let" checking
  8. Implicit optional unwrapping trapping on nil

    Possible mitigations:

    • make it throwing
    • remove it and users will reach out for explicit optional unwrapping
  9. Stack overflow crashes

    Possible mitigations:

    • make all function calls (including getters / setters / didSet / subscripts / etc) throwing. Check stack overflow and throw accordingly.
      Notes: to reduce "try" clutter we'd need to remove the "try" keyword.
    • make stack dynamic, although that's kicking the can down the road and you'd still need to do something else like 1 when heap overflows.
  10. Memory allocation failures

    Possible mitigations:

    • make all memory allocations returning optional, like good old malloc did.
      Notes: this includes class instantiation. Hmmm, what about instantiation of a struct that has reference fields? Should it also return optional? What about copy on write? Many questions here.
    • make all memory allocations throwing
      Notes: as above. Effectively all operations will be throwing, in which case to reduce "try" clutter we'd need to remove the "try" keyword.
  11. Crashes during accessing memory that's not available due to overcommitment

    Possible mitigations:

    • prohibit memory overcommitment
  12. Crashes accessing unmapped memory

    Notes: a good example needed here. Can this happen in "utmost safe" mode?

  13. Crashes writing to readonly memory

    Notes: a good example needed here

  14. Other crashes during memory access (e.g. parity errors or disk I/O failure when accessing a paged out memory)

    Possible mitigations: ???

  15. Hangs due to deadlocks. Not a crash, but good to have it here.

    Possible mitigations:

    • make the locking operation throwing instead of taking a lock that would otherwise cause a deadlock.
  16. Other hangs (infinite loop, etc). Not a crash, but good to have it here.

    Notes: Very hard problem to solve, impossible in general case. Equivalent to the halting problem. Possible mitigations could be specifying a maximum execution time and throwing when it is reached.

  17. Realtime safety violations

    Possible mitigations:

    • colourisation. We have @noLocks and @noAllocations for that.
    • guaranteeing worst case time complexity relates to the "other hangs" issue before.

I might have forgotten something, additions are welcome.


Is it possible to make a "guaranteed crash free" language (language mode) or a platform? I believe it is possible, just hard.

1 Like

Maybe not completely, but better is still better.

Most of Swift's contemporaries are crash-freer. e.g. Python, or pretty much anything on the JVM, are much more resilient in large part because they supported unchecked exceptions and they use those for errors that Swift chooses to crash on, like invalid memory accesses or [some] arithmetic errors.

I'd be hesitant to write a web server in Swift, for example, because the slightest error anywhere could make the whole thing crash. A Java server, in comparison, can just catch any exceptions in its thread pool executor, log the backtrace, and soldier on. Some stuff might be left in a bad state, but nothing that wouldn't have been anyway from crashing, and at least the other requests can be served without interruption.

It's always frustrated me a little that Swift is both widely proclaimed to be "safe" yet is super eager to crash. "safe" is being used in a very specific sense only, to mean more like "minimises undefined behaviour". The result is not what a lay-person would call safe. It's also not what I, as a developer, would call safe since my app crashing and losing user data is in no sense safe to me or my users.

A lot of this discussion - and the preceding thread - seem focused on labelling code that has un- or poorly-defined behaviour. But that just raises the question of what a developer is supposed to do about such code. Perhaps it'd be more effective, in the end, to look at ways to improve actual safety, e.g. function & type contracts in the ABI (so you can make something like assert(index < count) both an explicit prerequisite and something the caller has to ensure to the compiler's satisfaction), or crash-free arithmetic (by throwing exceptions instead).

I'd like to see "utmost safety" fall out naturally from the actual language and compiler, not through promises by programmers.

2 Likes

I've seen this assertion made in other language communities that were implementing safety features, and it strikes me there are two cultures of safety. One is the first one you describe, where you bail out immediately because you don't know what undefined state the system is in, and is used in very strict systems like power stations. The other is used in less strict software like webapps, which is all about bailing out, logging the crash, and continuing.

The ideal is something like erlang, which I've never used but I'm told would launch many lightweight processes that would crash quickly like Swift but could then by logged by a central executor, which would keep running just fine. This system was known to be extremely reliable, in fact it was built for that.

Perhaps we just need to enable building such software in Swift too, ie making it easy to add concurrency so that individual functions crash quickly but a web server or other Swift central executor wouldn't (maybe this is all in place already, not something I've tried myself, that's all).

1 Like

But I very much doubt such software is run without redundancies. So if one crashes there's probably several other independent copies that will carry on. Not so applicable to client software (I guess I can run three copies of Lightroom simultaneously, but not on the same photo library).

I haven't really used Erlang either, but from what I understand it's not all that dissimilar to e.g. the JVM, in terms of resilience. It doesn't necessarily use actually distinct processes (in the OS sense), but usually "lightweight processes" which are all managed in a single address space by its runtime environment. Each actor gets its own such "process" (I think?), and the compiler & runtime work to ensure there's no shared memory, just message passing. So it's not that far off Swift's Actors - which have shared immutable memory as an optimisation, but alas can access non-actors' mutable memory. It's especially like Swift's Distributed Actors.

Erlang employs a "supervisor" tree model - distantly like Swift Tasks can have associated subtasks - where nodes are responsible for their children and are notified when those children misbehave (e.g. "crash") or end gracefully. Swift doesn't really have a direct analog - in-process actors are managed differently and less rigorously - although Distributed Actors have ways to monitor each other (but their overhead of being in real separate processes makes them extremely expensive in comparison to Erlang's actors).

Possibly @ktoso, @drexin, or @yim_lee from Apple's Distributed Actors team can correct me if I got any of that wrong.

But going back to the JVM for comparison, functionally it's pretty similar. When a 'process' (actor) in Erlang crashes, it's essentially like an unchecked exception that is forwarded to its supervisor - in the JVM that might be your thread pool executor or whatever custom middleware you install, in Erlang (and Swift's Distributed Actors) it's just some other actor. The key difference is that a "crash" in Erlang, like the JVM, is usually just an exception. It doesn't unceremoniously kill the entire OS process. It can be caught and handled gracefully within the program's code. That's the biggest difference with Swift, I think.

But I don't have any real idea how Erlang linguistically or compilationally tries to avoid unsafe or otherwise broken code. It is dynamically-typed, so you can write things like 1+"2" and it'll apparently happily compile that and just throw an error at runtime (making it a lot like Python, actually)… so I can't imagine it's actually all that good about preventing errors. It seems to focus on just handling them acceptably at runtime.

Yes, redundant systems kick in, usually on completely separate hardware.

The key axes here are how likely an error is to corrupt memory or cause other unrecoverable problems and how well such corruption is encapsulated by the process model. Highly reliable systems like power stations crash immediately and use separate hardware after that, because the probability of memory or other corruption is high and they use separate hardware for complete encapsulation. Web servers assume the probability of memory corruption is low, so not much encapsulation is used.

That's why erlang is the ideal, bail out of memory corruption but encapsulate it in lightweight processes. It is the model Swift should be following, where possible.

Yep, all systems do, including Swift, the question is just what model of encapsulation to use and how safe that is.

I think Swift can do better than that. The only type of memory 'corruption' you guard against with process isolation, that comes to mind, is where you access unmapped addresses (i.e. segfaults). Whether directly or indirectly through e.g. stack corruption. I posit that most 'corruption' issues can only be caught from within application software via bounds checking, stack canaries, and the like.

Keep in mind that crashing is more likely to corrupt something important, in a well-designed program, than throwing an exception. Crashing can leave database & network connections mid-state, data on disk in a transient, invalid state, etc.

Exceptions follow a well-defined propagation path that unwinds the stack and lets clean-up code run. In a wildly unsafe language, like badly-written C, then sure one could argue that truly nothing is trustworthy after detecting a programmer error, so throw up your hands and just crash. But in a more robust language like Swift, where it's actually pretty hard to really screw with random code up the stack, I'm reckon it's a safer bet that you can trust your unwinding.

I think it's interesting that the debate over whether arithmetic errors (e.g. overflow) should throw or crash seemed to come down to language ergonomics, not which approach was actually more likely to cause harm at runtime. People - understandably - weren't thrilled with the idea of doing let a = try 1 + 2. But I wonder if Swift can do better - e.g. through flow analysis determine that 1 + 2 cannot fail, so the try is unnecessary. (well, constant folding in this trivial case, but in more realistic cases it'd be things like:

guard (0..<str.count).contains(index) else { return nil }
return str[index] // No need to try, it's deterministic at compile-time
                  // that this is safe.  Assuming no race conditions, that
                  // is.  But that's a separate problem.

…which, even better, can streamline to:

try? str[index]

…if str subscripting threw an exception on out-of-bounds, rather than just crashing. Like a generalised version of first and last.