Skip to content
Open
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions accepted/2025/memory-safety/conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@

# Unsafe coding conventions

Outside of the language destails, there's a question of recommendations and conventions in how to use the feature, particularly the scope of unsafe blocks.
Comment thread
agocke marked this conversation as resolved.
Resolved

We can split the annotations into two categories:
* Unsafe member annotations
* Unsafe blocks

Member declarations are simpler because the contract has a relatively clear dichotomy: the caller and callee. Both sides participate in a contract. The contract is structured around the aforementioned memory guarantees: an unsafe method may cause memory access violations unless all preconnditions are satisfied. The caller promises to fulfill all preconditions and the callee promises to fully enumerate all necessary preconditions. Assuming both parties discharge their obligations, the property holds and no access violations should occur. Commensurate notions of blame follow -- if a violation does occur, at least one party is at fault. If the caller did not satisfy all preconditions, they are at fault. If the callee did not fully specify all preconditions, they are at fault. In either case, the property can be repaired by identifying the cause of the failure and addressing it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Member declarations are simpler because the contract has a relatively clear dichotomy: the caller and callee. Both sides participate in a contract. The contract is structured around the aforementioned memory guarantees: an unsafe method may cause memory access violations unless all preconnditions are satisfied. The caller promises to fulfill all preconditions and the callee promises to fully enumerate all necessary preconditions. Assuming both parties discharge their obligations, the property holds and no access violations should occur. Commensurate notions of blame follow -- if a violation does occur, at least one party is at fault. If the caller did not satisfy all preconditions, they are at fault. If the callee did not fully specify all preconditions, they are at fault. In either case, the property can be repaired by identifying the cause of the failure and addressing it.
Member declarations are simpler because the contract has a relatively clear dichotomy: the caller and callee. Both sides participate in a contract. The contract is structured around the aforementioned memory guarantees: an unsafe method may cause memory error unless all preconnditions are satisfied. The caller promises to fulfill all preconditions and the callee promises to fully enumerate all necessary preconditions. Assuming both parties discharge their obligations, the property holds and no access violations should occur. Commensurate notions of blame follow -- if a violation does occur, at least one party is at fault. If the caller did not satisfy all preconditions, they are at fault. If the callee did not fully specify all preconditions, they are at fault. In either case, the property can be repaired by identifying the cause of the failure and addressing it.

access violation is not the only failure mode. I would say "memory safety problem" or "memory safety error".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"memory errors"?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, "preconnditions" -> "preconditions"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My phrasing is not ideal here. I don't want to deliver the message that we will actually produce an error of any kind -- arbitrary behavior or corruption may occur. What I want to say is that the memory access rules that are guaranteed by the safety rules will not hold.


Unsafe blocks are more complicated. The are only used within functions, so when writing an unsafe block there is no natural boundary to apply. In fact, there are two contradictory sensible rules that might be used to decide the size of an unsafe block. On the one hand, there is value in making the block as small as possible -- containing precisely the call to the unsafe method and nothing else. On the other hand, the reason why that particular call might be safe may lie in preceding code that ensures required preconditions are true. You may want to enlarge the block to encompass both the unsafe call and the required preconditions.
Comment thread
agocke marked this conversation as resolved.
Resolved

Take [this example](https://github.com/dotnet/runtime/blob/b19edc085666acaf215ff60edce24cada71e2f93/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/MemoryMarshal.cs#L290C51-L290C70) from the .NET core libraries. The `Unsafe.As` method is an unsafe method that can only be used correctly if it is guaranteed that the input type has a legal conversion to the target type. In this case, we call an internal method that guarantees the input is a compatible array. Therefore, the call to `Unsafe.As` is valid, the containing method is safe, and we can enclose this call in an unsafe block. The question is, how large should the block be? We could narrowly scope the block, encompassing only the `Unsafe.As`, e.g.:

```diff
if (RuntimeHelpers.ObjectHasComponentSize(obj))
{
// The object has a component size, which means it's variable-length, but we already
// checked above that it's not a string. The only remaining option is that it's a T[]
// or a U[] which is blittable to a T[] (e.g., int[] and uint[]).

// The array may be prepinned, so remove the high bit from the start index in the line below.
// The ArraySegment<T> ctor will perform bounds checking on index & length.
+ unsafe
+ {
segment = new ArraySegment<T>(Unsafe.As<T[]>(obj), index & ReadOnlyMemory<T>.RemoveFlagsBitMask, length);
+ }
return true;
}
```

We could also expand it to include the `ObjectHasComponentSize` call:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime implementation itself may not be the best motivating example. Can we use more user-centric examples that are not an internal runtime implementation details and that are something that can be found in 3rd party library on nuget.org?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes -- this is currently being driven by my annotation experiments in runtime, so I only have runtime-specific examples at the moment.


```diff
+unsafe
+{
if (RuntimeHelpers.ObjectHasComponentSize(obj))
{
// The object has a component size, which means it's variable-length, but we already
// checked above that it's not a string. The only remaining option is that it's a T[]
// or a U[] which is blittable to a T[] (e.g., int[] and uint[]).

// The array may be prepinned, so remove the high bit from the start index in the line below.
// The ArraySegment<T> ctor will perform bounds checking on index & length.

segment = new ArraySegment<T>(Unsafe.As<T[]>(obj), index & ReadOnlyMemory<T>.RemoveFlagsBitMask, length);
return true;
}
else
{
// The object isn't null, and it's not variable-length, so the only remaining option
// is MemoryManager<T>. The ArraySegment<T> ctor will perform bounds checking on index & length.

Debug.Assert(obj is MemoryManager<T>);
if (Unsafe.As<MemoryManager<T>>(obj).TryGetArray(out ArraySegment<T> tempArraySegment))
{
segment = new ArraySegment<T>(tempArraySegment.Array!, tempArraySegment.Offset + index, length);
return true;
}
}
+}
```

If we keep the block small, we can easily see which parts are dangerous and which parts are not. As we expand the block, we capture more of the critical dependencies that flow into unsafe calls, but we also risk capturing pieces that are either not part of the safety contract, or may even be part of a different safety contract. Note that the call `Unsafe.As<MemoryManager<T>>(obj)` contains assumptions that don't even fit in the wider block. We would have to widen the scope even larger.

The principle of capturing all dependencies of unsafe calls is also tricky to pin down. Most safety contracts are much larger than it may seem. For instance, a lot of code requires arrays to meet their language guarantees (e.g., an array of type `T` will not contain an element of unrelated type `U`) or that GC pinning works as promised. If these guarantees were to break the safety guarantees would be lost. However, expanding unsafe blocks to the entire runtime is neither possible nor desirable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, expanding unsafe blocks to the entire runtime is neither possible nor desirable.

The low-level runtime is pretty much all unsafe code. I would be fine with having everything in the low-level runtime marked as unsafe if it comes to it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not what this paragraph is trying to say. It's not about implementation details of the runtime, it's about safety guarantees of the runtime itself. For example, pinning pointers "working" in the sense that an array is always pinned while one exists, is fundamental to many unsafe blocks' correctness. Marking the implementation details of the runtime unsafe doesn't change this, and isn't possible anyway because the implementation details of the runtime are not directly exposed in C#. The point is that there are foundational assumptions in all code that unsafe blocks rely on. We shouldn't have to explicitly state those assumptions in individual unsafe blocks, nor should users of unsafe have to review them all. They should be able to treat pinning pointers as working as specified, List working as specified, etc.

This is a practical, engineering distinction vs. a mathematical, formal verification one. The list of actual dependencies of any given unsafe block are huge, so formal verification would require a mountain of verification to be written. But the farther you get in terms of indirect dependencies, the less useful this verification is, in practice. In practice, almost all the dependencies that matter are the immediate ones.


In sum, the guidance is to **keep unsafe blocks as small as reasonably possible**. In the example above, this would mean preferring the first diff to the second. In some cases the preconditions and unsafe calls are very close together and isolated. If so, it may be acceptable to widen the scope slightly. However, the starting point for unsafe blocks should be to keep them as small as reasonably possible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
In sum, the guidance is to **keep unsafe blocks as small as reasonably possible**. In the example above, this would mean preferring the first diff to the second. In some cases the preconditions and unsafe calls are very close together and isolated. If so, it may be acceptable to widen the scope slightly. However, the starting point for unsafe blocks should be to keep them as small as reasonably possible.
In sum, the guidance is to **keep unsafe blocks as small as reasonably possible**. In the example above, this would mean preferring the first diff to the second. In some cases the preconditions and unsafe calls are very close together and isolated. If so, it may be acceptable to widen the scope slightly. However, the starting point for unsafe blocks should be to keep them as small as reasonably possible.
This topic has been a point of a long discussion in Rust as well and ended with the same conclusion. From https://doc.rust-lang.org/book/ch20-01-unsafe-rust.html: "This helps us keep unsafe blocks as small as possible, as unsafe operations may not be needed across the whole function body.".
The consequence of this guidance is that it is not sufficient to review unsafe code only when auditing for memory safety problems. The safe code that guarantees invariants expected by the unsafe code has to be reviewed as well. Similarly, changes in a safe code can introduce memory safety problems if they break invariants expected by unsafe code, so it is not straightforward to identify changes that may be introducing memory safety problems.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tracking issue from the Rust project is worth a read and likely inclusion, particularly the unresolved questions:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is not sufficient to review unsafe code only when auditing for memory safety problems

This keeps bothering me. I am worried that we are at risk of building something that is not all that useful in practice.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is not sufficient to review unsafe code only when auditing for memory safety problems

This keeps bothering me. I am worried that we are at risk of building something that is not all that useful in practice.

Well, maybe whatever we build will help to keep codebases 100% unsafe free, so no complex review will be required in the first place? E.g. "green" checkmarks on nuget packages will motivate library authors keep their code clean.

@jkotas jkotas Jan 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think we can assume that we can drive unsafe code amount to nil.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I was saying that at very least we deliver a sort of a guarantee that if there is no <AllowUnsafeBlocks>true</AllowUnsafeBlocks> it's very likely your code is safe as is, the probability of memory issues is very low. Today, the lack of AllowUnsafeBlocks cannot promise that as there are too many very dangerous APIs can be used.

Can we even theoretically do better than "if unsafe block exists, you have to review more than just code inside the block" ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there is no true it's very likely your code is safe as is

Dependencies and dependency graphs make reasoning complicated. Some other library with unsafe code may depend on your 100% safe library, and thus change or a bug in your 100% safe library may be indirectly causing memory safety problem in your app. How should people reason about situations like that?

Can we even theoretically do better than "if unsafe block exists, you have to review more than just code inside the block" ?

We may be able to create some framework for what it is ok to take for granted when auditing unsafe code. For example, when I have unsafe code that depends on (safe) Span APIs for bounds check validation, it should be reasonable to assume that those Span APIs work correctly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to see an example.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is not sufficient to review unsafe code only when auditing for memory safety problems

I think the more accurate statement is: when auditing for memory safety issues you must also review dependencies of your unsafe code. I think that, in practice, this is easier than it sounds. In theory the set of indirect dependencies expands until it encompasses the entire program. In practice, programmers can make reasoned judgements like, "this is a fundamental invariant of the system, not specific to my use case, and is therefore assumed correct."

Hence my example with arrays. Let's say you have some unsafe code that takes a pinning pointer to an array. The correctness of that code depends on pinning pointers working as designed -- if they don't, the access is unsound. If you want to specifically prove that your code is correct then a proof that pinning works properly is required. However, this is not useful or required in practice. If fundamental runtime features are broken, the whole system is broken. This particular instance of unsafe code doesn't matter.

The usefulness of unsafe is predicated on assumptions like, fundamental runtime/compiler bugs are unlikely and fixed quickly. Luckily, I think that is a sound assumption in practice.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can split the relevant security sensitive code into:

  1. User written code that performs the unsafe action
  2. User written code that validates preconditions of the unsafe action
  3. User written dependencies of the code that validates preconditions of the unsafe action
  4. Fundamental dependencies of the user written code that validates preconditions of the unsafe action

We are saying that (1) is inside unsafe block, (2) and (3) are described by a comment, and correctness of (4) has to be taken for granted to make this practical.

To audit unsafe code, one has to look at all (1), (2) and (3). (1) is easy to identify thanks to the unsafe block. The question is whether there should be a way to identify (2) and (3) that is better than comments.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should cross-link this with unsafe expressions. I expect that we will end up building unsafe expressions to support this guidance since they are the convenient mechanism for keeping the unsafe scopes as small as possible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to start creating the list of proposed analyzers too - in this doc or in a separate doc? I think it would make sense to have it as part of this doc since the guidance and analyzers should be coupled together.

I think we will want to have analyzer that warns on use of unsafe keyword in method signatures. unsafe keyword in method signature is pretty much never going to be produce minimal unsafe block unless the method is single statement. And if the method is just a single statement, it is better to use unsafe expression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this has a lot of interaction with language design decisions, but I think it's important.

@jjonescz jjonescz Jan 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem useful to me to have preconditions in the unsafe blocks, ever. It is pretty standard that a piece of code has many preconditions (e.g., if (arg == null) throw ...) and usually a programmer should always inspect the surrounding method body to understand preconditions and their dependencies whenever changing/refactoring/auditing the code. (Across methods, preconditions can be asserted.) In other words, I don't see how unsafe differs from normal code in this regard.

I would perhaps recommend people to put comments at the unsafe blocks documenting why it's safe for callers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Across methods

If a method has preconditions that must be guaranteed by the caller for safe use, the method should be always marked as requiring unsafe caller. It is not ok to omit it (and e.g. replace it with asserts) in the name of minimizing unsafe contexts.

It may be worth mentioning it explicitly in the guidance for clarity.

@jjonescz jjonescz Jan 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a method has preconditions that must be guaranteed by the caller for safe use, the method should be always marked as requiring unsafe caller.

Makes sense, I was comparing this to other preconditions (where you could use asserts), not unsafe specifically.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unsafe annotations for fields do not seem to be valuable with the minimal unsafe scope guidance. Assigning or reading fields is never unsafe on itself. It is only once you use the value and that use should get its own minimal scope.

@agocke agocke Jan 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strongly disagree. The motivation for unsafe fields is not about unsafe block scope, it's about contract boundary.

Like calling methods, reading and writing fields crosses a contract boundary in the program. Effectively, it's non-local scope. When writing fields, the field may have a contract that only certain types of variables are allowed (e.g., a lot of unsafe code in the runtime does Unsafe.As on fields, assuming the actual type is only one of a fixed set of types). When reading fields, certain preconditions may need to be satisfied for the field value to be valid.

In both of these cases, it's no different from calling an unsafe method. Use or assignment of the variable has specific non-compiler-verifiable requirements that must be satisfied by code inspection.

@jkotas jkotas Jan 28, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's no different from calling an unsafe method.

Ok, it makes sense to think about fields as methods, and reading/writing fields as calling methods.

Here is a different example: System.Threading.NativeOverlapped.EventHandle field. This field is a public API so it may be a bit easier to understand for folks not versed in runtime internals. This field will be marked as unsafe since it participates in unsafe contract, even though reading/writing of the field alone does not have any memory safety problem. Do you agree?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming I understand the documentation correctly, yes this is a perfect example! Effectively, there is a precondition that the writer of the field must satisfy, otherwise memory safety violations may occur.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should start thinking about design of an auto-fixer that we will recommend people to run as the first thing after opting into unsafe v2. I think this auto-fixer should:

  • Insert unsafe scopes as necessary to make your library build
  • Deletes unsafe in method signatures (if we go with the attribute)
  • Marks your methods as RequiresUnsafeCallers using some reasonabe heuristics. For example, it will mark all methods that take pointers with RequiresUnsafeCallers.
  • Adds TODOs to places that require further review to ensure that the annotations are correct