Skip to content

Conversation

@agocke
Copy link
Member

@agocke agocke commented Jan 24, 2026

Rendered

These conventions are certainly up for debate -- I wanted to propose a starting point.

* 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
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
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
Member

Choose a reason for hiding this comment

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

Also, "preconnditions" -> "preconditions"


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.

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. No newline at end of file
Copy link
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
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
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
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.

Copy link
Member

@jkotas jkotas Jan 27, 2026

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
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
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
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
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
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.


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.

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. No newline at end of file
Copy link
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.


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.

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. No newline at end of file
Copy link
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
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.

@EgorBo
Copy link
Member

EgorBo commented Jan 25, 2026

I’m starting to wonder if we even need the unsafe-as-expression feature. If the recommendation is to "keep unsafe blocks as small as reasonably possible," then most unsafe APIs will just become x = unsafe(Unsafe.As(obj)), making the feature not better than just special naming convention (Unsafe in the name). Unsafe {} blocks at least sort of punish/demotivate using the unsafe code.

While I’m fine with the recommendation, I’m not sure that narrowing the scope as much as possible directly correlates with safety. I don't see an issue with a single, larger unsafe { } block within a method; the developer is taking responsibility for maintaining all invariants/safety and the resulting state once the block ends - it's up to them how wide they're willing to be responsible for?

}
```

We could also expand it to include the `ObjectHasComponentSize` call:
Copy link
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
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.


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
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
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.


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.

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. No newline at end of file
Copy link
Member

@jjonescz jjonescz Jan 26, 2026

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
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.

Copy link
Member

@jjonescz jjonescz Jan 26, 2026

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.


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.

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. No newline at end of file
Copy link
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.

Copy link
Member Author

@agocke agocke Jan 28, 2026

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.

Copy link
Member

@jkotas jkotas Jan 28, 2026

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
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.


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.

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. No newline at end of file
Copy link
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

@agocke
Copy link
Member Author

agocke commented Jan 28, 2026

I’m starting to wonder if we even need the unsafe-as-expression feature. If the recommendation is to "keep unsafe blocks as small as reasonably possible," then most unsafe APIs will just become x = unsafe(Unsafe.As(obj)), making the feature not better than just special naming convention (Unsafe in the name). Unsafe {} blocks at least sort of punish/demotivate using the unsafe code.

I don't agree with this reasoning -- for one, the point of the feature is that many APIs don't have a consistent naming convention, so it isn't clear whether or not an API is unsafe based on it's name.

However, I don't completely disagree with the conclusion. The reason is that I think pretty much all unsafe code needs a comment. For unsafe members, they need comments on how to use the member correctly. For unsafe blocks, they need a comment about how they've fulfilled all the requirements of the unsafe members that they use.

If every well-written unsafe block has a comment, I don't see a lot of value in unsafe expressions. You don't really save keystrokes or clarity.

@jkotas
Copy link
Member

jkotas commented Jan 28, 2026

For unsafe blocks, they need a comment about how they've fulfilled all the requirements of the unsafe members that they use.

I would expect that fulfillment of the unsafe requirements to be apparent, without needing a comment, for average unsafe code out there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants