Undefined behaviour of uninitialised / unbound memory

To not side-tract the other thread any further, making this in a new topic.

This code triggers undefined behaviour:

struct S {
    var x: Int64
    var y: Int8
}
let p = calloc(1, MemoryLayout<S>.size)!.assumingMemoryBound(to: S.self)
var s: S = p.pointee
print(s)
// work with S
p.pointee = s
...
free(p)

How can it crash or otherwise misbehave? Is there some Xcode diagnostic setting I can enable to catch such instances?

This not really UB, I don't think. The UB argument in your original post derived from the fact that you were reading uninitialized memory returned by malloc. Even that might not be UB: as your type is a trivial aggregate of integers there is some ambiguity about whether the C specification considers copying that memory to be UB, or merely to produce an indeterminate value whose use would trigger UB. (This seems like splitting hairs, but there is some suggestion that it may be valuable to copy around memory whose value was uninitialized so long as you never compute on it. It's possible that Swift actually has a stricter rule here I'm not aware of, but I know the Rust community has been actively discussing this).

However, calloc does not produce uninitialized memory, it produces initialized memory (initialized to zero). Thus there is no read of uninitialized memory here. Given that your types are all trivial, that the sizes all line up, that the lifetimes work, that the types have valid representations in the all-zero-bit-pattern etc., there is no UB caused by the load from the pointer returned by calloc.

Except, you managed to snatch defeat from the jaws of victory by your use of assumingMemoryBound(to:). As noted in that documentation:

Accessing memory through the returned pointer is undefined if the memory has not been bound to T . To bind memory to T , use bindMemory(to:capacity:) instead of this method.

This memory was not bound to S, it was unbound. Thus, assumingMemoryBound(to:) invoked UB. To remove the UB from this program altogether, simply replace the call with bindMemory(to:) and you're safe.

Today, it does not. However, Swift's aliasing rules assume that you do not access memory through bound pointers that do not correspond to the one underlying memory binding status. At this point in time, there is no enforcement mechanism for this and Swift performs fairly minimal optimizations based on that information, so it's quite hard to trigger an error or diagnostic.

2 Likes

If I change from calloc to malloc above does anything change IRT undefined behaviour triggering? I mean the struct contains two integers, integers could have any bit pattern, understandably I'll get some garbage values – consider that to be a very poor quality random number generator – but would that trigger UB?

Similarly, if the type was not trivial:

struct S {
    var x: Int64
    var y: String?
}

and calloc was used to allocate it, would it be UB? I understand that there could be some future hypothetical Swift implementation that doesn't use 0-s as a bit pattern for nil pointer and, but I am talking about currently available swift implementations (and I am happy to change my code later on when non-zero pattern for nil is a reality).

Interestingly, when I changed assumingMemoryBound to bindMemory and checked the resulting asm in the godbolt - the output was exactly the same... So when you say I am not safe with "assumingMemoryBound" are you saying it from the standpoint of a hypothetical potential swift implementation that we don't have yet? This correlates to what you said:

In other words, we are discussing following or not following rules that are currently not enforced, and unlikely to be enforced in the foreseeable future, right?

The answer is "probably", but it depends on what you do in // work with S. As discussed above, the jury is out on whether merely copying uninitialized memory is UB, but computing on it certainly is.

This is a good question. In many cases, yes, certainly. It's also possible that in all cases this triggers UB in Swift. Swift often does not write down exactly what is considered UB, but I can imagine a rule that copying a type that is not bitwise-copyable without using its copy constructor invokes UB. This example does that, and so if that were the rule then it would invoke UB.

It's worth observing the care I'm taking around saying whether the all-zero bit pattern is a valid representation for the type. This is separate from the question of what the representation of the nil pointer is, because it spans a wide range of types. In this instance, String is a complex aggregate, and it's possible that a validly constructed String never has an all-zero representation. (I think it does, in this case, but just an FYI).

No, I'm saying your program invokes UB. The assembly output is irrelevant from this perspective.

In previous UB conversations you've also asked variants of this question: "how could it crash", "what misbehaviour could it trigger". These are the wrong questions. To again steal from John Regehr, this is like you and I playing 1-on-1 basketball in a street court together and you asking me what bad thing can happen to you if you run with the ball. The answer is that no formal punishment system exists, but you broke the rules of the game.

Similarly, Swift has said that calling assumingMemoryBound(to:) on a pointer whose underlying binding is not of the assumed type invokes UB. That's a conversation-ender. Once you've invoked UB, you are not entitled to any particular outcome: the compiler is free to assume you just didn't do it.

In this case, memory bindings may not appear at the assembly level. They are an artefact of the abstract machine that Swift computes against. Swift has said that in this environment, any memory location can be bound to either zero or one types at any given time, and that accessing the memory through a different bound type is against the rules. The Swift compiler is free to assume that you never do that, and make any transformations it likes to your code assuming that you never do that.

I don't think either of those things are quite true. Certainly I can't speculate on what Swift may or may not due in the future, but I think it would be pretty bold to assume that nothing bad can happen today. Swift assume you'll never do what your code does: if you do, any number of fun things might happen.

7 Likes

I believe breaking the rule is nothing unless it is punishable. The punishment in this case could be a crash, or a wrong result in a calculation, or a compile time diagnostic warning, or runtime sanity check trap, or my whole disk being erased, or the moon falling down to earth, or any other fun or scary thing. If nothing of this happens today it doesn't exist (not to me at least).

And in a fragment like this:

struct S { var x: Int64; var y: Int8 }
let p = malloc(MemoryLayout<S>.size)!.assumingMemoryBound(to: S.self)
// Edit: malloc returning nil when we are out of VM with the subsequent 
// crash on unwrap is understandable, let's not consider this case here.
var s: S = p.pointee
// gee, let's do a calculation here and trigger UB!
let calculation = s.x &* 42 &+ Int64(s.y)
print(calculation) // prints some "random" garbage

if you know a way to see this punishment happening today – please let me know.

The punishment is whatever the compiler wants, and it's free to change between compiler versions with no warning. I once read a humorous remark that "gcc could send you a free pizza if you dereference a null pointer on a rainy Tuesday", but of course that's not what any sane compiler would do. I've seen clang wholesale delete functions from the binary if they contain UB (example). Even if it seems like nothing is wrong, it could just be that the way the compiler decided to handle it happens to work in most cases, and you have a latent bug waiting for an edge case or a compiler update.

3 Likes

Yeah, this is the key insight

It's punishable. It just may not be punished. Just like the offside rule in soccer: being offside is against the rules, but sometimes the referee doesn't see it, and other times they don't call it. Doesn't change the fact that it's against the rules.

Ok! We're all consenting adults, and you're free to do whatever you want.

The nature of UB is that most of the time it doesn't punish you now, it punishes you or someone else later. If your code is hobbyist and only for you, then sure, once your code runs then you're golden. But if you want your code to last, avoiding UB is really important. Programs that rely on a specific outcome when they invoke UB are doomed to eventually break.

3 Likes

I understand that. As well as that all programs are doomed to eventually stop working. Being a responsible adult I will leave the current UB in code if for no other reasons than for these two: 1) to be amongst the first who experience the punishment when it actually happens, to see it first hand, then to change the codebase and introduce the relevant unit tests right there and then (but not before), and (2) triggering UB (or even having discussions like these) hopefully could expedite the relevant compiler checks being introduced.

It's important to understand that the compiler is not obligated to inform you that you've invoked UB. The compiler will simply assume you never do.

6 Likes

It seems you’re still not quite understanding the nature of UB. No one is trying to create a compiler that steals your lunch to punish you for UB, but on the flip side no one is going to be “expediting” the addition of compiler checks to tell you not to invoke UB when you do it on purpose. Writing code, like any other activity, has a social contract, and in this context, code that invokes UB is outside that contract.

It’s like asking, “I’m a builder—what is my punishment if my construction work violates the building code by placing the smoke detector in the wrong place?” Nothing, likely. But someone will live in that house you build, and that smoke detector isn’t guaranteed to work right when a fire breaks out. If you don’t care, no one can make you care, but something is seriously wrong with your understanding or your ethics if you advertise that you proudly put your clients’ smoke detectors in the wrong place so that you can be “amongst the first to experience the punishment” of your work burning down to “expedite” improvements to smoke detectors.

4 Likes

By placing the fire detector in the wrong place I'll check if the relevant fire authorities checks are in place and working. By ensuring that I'll help improving the checking system and potentially save many lives. Call it stress testing if you will.

The more accurate analogy would be that currently we don't have smoke detectors at all, only a poster on the wall that "fire kills", and basically I am asking if we have those smoke detectors, if not could we introduce them. And if it helps introducing them I feel it's worth setting a house or two on fire to have them introduced and save much more many lives in the future.

The helpful approach would be to first use the correct unsafe API in your code: bindMemory(to:capacity). Developers rely more on the collective examples of Swift code than they do on documentation. Some small subset of developers who use the unsafe API incorrectly will be punished with code that misbehaves in ways that are nearly impossible to debug. In some cases, the code will happen to work until some other developer recompiles it using a newer implementation of the language. The punishment is random, unfair, and never has a constructive outcome.

Dynamic enforcement of memory state is expensive. It's unlikely that many developers would ever enable it. Static diagnostics work best in cases such as yours that are obviously incorrect by inspection (once you've read the API documentation). It takes time to implement these and adds complexity to the code base. Nonetheless, given unlimited resources, it would be nice to have both varieties of diagnostics, and that was always the intention. If someone feels that these diagnostics are more urgently needed than the many other language improvements developers are waiting for, then they are encouraged to implement those diagnostics.

3 Likes

Thank you Andrew.

Have a look at this attempt to have most checks done at compile time:

struct UnsafeTypedMemory<T> {
    private let value: UnsafeMutablePointer<T>
    private let count: Int
    private let capacity: Optional<Int> // used for unbind
    
    init(_ value: UnsafeMutablePointer<T>, count: Int, capacity: Optional<Int>) {
        self.value = value
        self.count = count
        self.capacity = capacity
    }
    
    func unbind() -> UnsafeRawMemory {
        UnsafeRawMemory(value, capacity: capacity)
    }
    
    func rebind(toCount count: Int) -> UnsafeTypedMemory {
        precondition(count > 0 && count <= self.count, "can't rebind to a bigger count")
        return UnsafeTypedMemory(value, count: count, capacity: capacity)
    }
    
    subscript(_ index: Int) -> T {
        get {
            precondition(index >= 0 && index < count, "index \(index) out of range 0 ..< \(count)")
            return value[index]
        }
        set {
            precondition(index >= 0 && index < count, "index \(index) out of range 0 ..< \(count)")
            value[index] = newValue
        }
    }
}

struct UnsafeRawMemory {
    let value: UnsafeMutableRawPointer
    private let capacity: Int? // could be absent
    
    init(_ value: UnsafeMutableRawPointer, capacity: Int?) {
        self.value = value
        self.capacity = capacity
    }
    func bind<T>(to type: T.Type, count: Int) -> UnsafeTypedMemory<T> {
        precondition(count > 0, "count should be positive")
        if let capacity {
            if count == 1 {
                precondition(MemoryLayout<T>.size <= capacity, "one \(type) element can't fit into \(capacity) bytes")
            } else {
                precondition(count * MemoryLayout<T>.stride <= capacity, "\(count) \(type) elements can't fit into \(capacity) bytes")
            }
        }
        let p = value.bindMemory(to: type, capacity: count)
        return UnsafeTypedMemory(p, count: count, capacity: capacity)
    }
}

func mallocX(_ capacity: Int) -> UnsafeRawMemory {
    UnsafeRawMemory(malloc(capacity)!, capacity: capacity)
}

func freeX(_ v: UnsafeRawMemory) {
    free(v.value)
}

This is a cross between UnsafeRawPointer and UnsafeBufferPointer with the following differences:

  • compared to UnsafePointer there is capacity field that could be checked at runtime (in subscript operations, etc)
  • compared to UnsafeBufferPointer this capacity field is optional, so those runtime checks will only happen if this field is available
  • assumingMemoryBound is not a problem anymore because it doesn't exist.
  • although if needed you can unbind memory back to "raw" state and rebind it again to a new type
  • There's a way to rebind memory to a smaller capacity if needed, but not bigger
  • We can probably add rebind operation to rebind to another type - that's not shown here.

Usage example:

mallocX(20)
    //.bind(to: Int.self, count: 3) // Runtime error: 3 Int elements can't fit into 20 bytes
    .bind(to: Int.self, count: 2)
    .unbind()
    .bind(to: Float.self, count: 2)
    .rebind(toCount: 1)[1] // Runtime error: index 1 out of range 0 ..< 1

This API is done in such a way that it is impossible not easy to misuse it: for example you can't bind memory twice because once bound you are working with "TypedMemory" and bind is only defined on "RawMemory" level, similarly you can't unbind memory that was already unbound as unbind operation is only defined on "TypedMemory" and once unbound you are back to "RawMemory" level. And the infamous assumingMemoryBound no longer exists so no problem with that either.

It would be possible to introduce another layer (another intermediate type) to statically distinguish between initialised vs initialised memory – I didn't do it here because I found the concept of memory initialisation neither very well defined nor particularly useful. As an example you can initialise memory to be all zeroes and then bind it to a type whose elements can't be all zeroes pattern and you'll be screwed as from the perspective of that type the memory is not properly initialised. Or the opposite example: if the bound type in question is trivial (say an Int) – any bit pattern can be treated as properly initialised memory. It would be possible to have an initialiser that accepts a repeating value of a given type and a count, and working with that memory would be fully safe (in this case we'd not want to have rebind operation to rebind to a different type), but we also want to support cases working with existing memory that was already properly formatted (e.g. read from a file) so some amount of unsafely would still be present in this API, hence it's best to keep "Unsafe" in its name.

Yes, that's the basic idea behind binding/rebinding memory. It is similar to the problem of initialized vs. uninitialized memory.

The problem with simply creating wrapper types is that the compiler can see through them and they still access a typed pointer underneath. Since pointers and their wrapper types are copyable, it's still pretty easy to misuse:

let buffer = mallocX(n)
let viewA = buffer.bind(to: A.Self, count: n)
let viewB = buffer.bind(to: B.Self, count: n)
viewA[0] = A()
return viewB[0] // the compiler may reorder this load with the previous store!

Once you take the trouble to create a wrapper type (UnsafeTypedMemory<T>), you may as well use a raw pointer underneath. Then you never need to bother binding memory to a type--until you need to pass a pointer to a C API. When you access memory via a raw pointer, the compiler is not allowed to assume anything about the in-memory type. Then the only thing you need to worry about is bounds-checking and memory lifetime.

Typed pointers (UnsafePointer<T>) should ideally only be used for C interop. They inherit undefined behavior rules for strict aliasing from C pointers. The safest way to interoperate with C is via a closure-taking API. See UnsafeRawBufferPointer.withMemoryRebound(to:body:). The equivalent here would be:
UnsafeTypedMemory<T>.withUnsafePointer<R>(body: (pointer: UnsafePointer<T>, capacity: Int) -> R)
(When calling C, I don't see the advantage of going through UnsafeBufferPointer<T> first).

Here's an early reference implementation of BufferView<T>-- a typed wrapper around a raw pointer:

@glessard has a much more thorough prototype now.

I've been wanting to migrate low-level API's to BufferView<T> because Unsafe[Raw]BufferPointer is (obviously) unsafe, confusing, and problematic. The hold-up is that we don't have compiler support for safe BufferViews that tracks their lifetime.

We could easily introduce an UnsafeBufferView<T> in the meantime. I think that unsafe variant will always be needed as an escape hatch anyway for compatibility with existing protocols. The only objection I foresee to introducing UnsafeBufferView<T> is that it appears somewhat redundant with UnsafeBufferPointer<T>. UnsafeBufferView<T> just makes more sense when you're allocating memory in Swift, or when you're passing around an untyped bag of bytes like Foundation.Data.

2 Likes