You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Promotion introduces UB into otherwise well-defined code #493
While investigating rust-lang/rust#121610, I noticed something problematic... we currently accept code like this:
let x:&'staticOption<Cell<i32>> = &None;
Promotion kicks in despite interior mutability, because it sees the None variant and considers that one variant to not have interior mutability.
But also, both Stacked Borrows and Tree Borrows consider this to be legal:
let o:Option<Cell<i32>> = None;let x = &o;
ptr::from_ref(x).cast_mut().write(Some(Cell::new(0)));
They allow this because we avoid making "do we allow mutation" value-dependent. This is for two reasons: (a) we don't even want to require the reference to point to a valid value, but if the memory might store any sequence of bytes then the concept of value-dependency doesn't even make sense, and (b) checking the value stored behind the reference would introduce something very close to a cyclic argument into the memory model. So when creating a reference to o, we can't know whether o is None or Some, and therefore we just conservatively assume that interior mutability is allowed.
It should follow that this is also legal:
let x:&Option<Cell<i32>> = &None;
ptr::from_ref(x).cast_mut().write(Some(Cell::new(0)));
But this is UB, due to promotion putting the None into read-only memory. That's Not Good, promotion is introducing UB!
This even has issues with the weaker vertical model I proposed, since you could do:
let offset = {let x = Some(Cell::new(0i32));let p = match&x{Some(y) => y as*const_,None => unreachable!()};
p.byte_offset_from(addr_of!(x))};let x:&Option<Cell<i32>> = &None;
ptr::from_ref(x).cast_mut().byte_offset(offset).cast::<i32>().write(0);
I'm assuming we can't not promote there b/c it's valid on stable with &'static.
I think there's two options here, both bad:
We adopt the variant-granular model for enums*
We accept that const promotion can create UB
I don't think that it would be reasonable to lift read-only status, because that poses issues with (de-)duplication.
This even has issues with the weaker vertical model I proposed, since you could do:
Yeah, this needs full variant-awareness to be justified.
I'm assuming we can't not promote there b/c it's valid on stable with &'static.
Just because it's stable doesn't mean we can't try to slowly change it, maybe not promote it over an edition, whatever.
But I worry this might be extremely widely used. And until inline const finally stabilizes, we often don't have any alternative we can offer. And any kind of migration for promotion-related things is very hard to do since it is basically impossible to have a lint for "you are relying on X being promoted, but in the future this will not be promoted any more, use inline const instead".
IIUC, there are something like one-and-a-half issues here:
Promotion can cause programs to exhibit UB where they otherwise wouldn't
2. Stacked Borrows and Tree Borrows aren't equipped to even detect this UB
For (2), instead of considering the value behind the reference, could this be addressed by distinguishing between read-only and mutable provenances, where the former is used for static read-only memory, and is "stronger than" interior mutability? Of course this might be a heavy hammer for dealing with a single edge case like this.
(I've been casually following the provenance and aliasing model discussions, but amn't fully in the loop, so feel free to dismiss this as a silly idea if it is one.)
The case for item or inline const is certainly a footgun, but not model-breaking; "just" give all references to const data frozen/immutable provenance independent of the pointee type when created. This at least means any reliance on "infectious" mutability cannot be sound API even if well-defined in controlled environments.
IIUC, it's not plausible to only do const promotion when necessary to satisfy outlives requirements (scope-bound temporary lifetime extension when sufficient), as codegen decisions based on region inference is a no-go. It would certainly be a huge and undesirable footgun if we did it, but it would partially resolve the issue, in that code WF absent implicit const promotion would remain WF. (The same applies for a region-inference dependent lint.)
I see three options, roughly matching Connor's:
Stop doing implicit const promotion for types which are not Freeze; require those usages to do manual promotion with const. (breaking)
Always do implicit const promotion of candidate1 lifetime-extended temporarys. Accept that this can introduce UB into code otherwise WF with nonconst lifetime extension.
Variant: attempt to lint problem cases, somehow.
Variant: attempt to skip const promotion when possible in order to mitigate UB.
Adopt more granular shared mutability rules that inspect the enum discriminant, making the example always UB instead of conditionally WF.
If union literals are ever eligible for const promotion (they don't appear to be currently), they would encounter the same footgun and lack the option to granularly restrict shared mutability based on discriminant.
If we're going to in any way constrain const promotion from what's currently eligible in the future (i.e. over an edition) for any reason, it's probably a good idea to also constrain it to Freeze pointee types at the same time. Only with cross-edition "nobody notices" cleanup of const promotion, though, the potential footgun will remain in not-that-future edition code.
Personally, I don't think it's too bad — code relying on "adjacent" mutability is already a bad idea and only ever tenuously sound — but it is unfortunate that promotion — conceptually a "more things just work" feature — would restrict what code is WF. But not annoying enough to justify discriminant dependent retagging.
At a bare minimum, a clippy restriction lint against const promotion of ?Freeze types makes sense (once inline const exists).
Footnotes
The straightforward rule for candidate expressions I've seen is essentially just literal expressions, with adhoc bonus inclusions for the few specially tagged std functions. This naturally excludes any shared mutability since that transitively requires non-pattern construction (UnsafeCell::new or a transmutation source). ↩
If union literals are ever eligible for const promotion (they don't appear to be currently), they would encounter the same footgun and lack the option to granularly restrict shared mutability based on discriminant.
AFAIK unions get promoted but only if they are Freeze. We don't do value-based reasoning for unions, and rust-lang/rust#121893 adds a test ensuring it stays that way.
Fixing this the way I'd like to (by not doing value-based reasoning for interior mutability) would leak to 4k crater regressions (see rust-lang/rust#122789).
Not sure how to best proceed here. I think the best possible outcome at this point is to do this promotion change over an edition that tells people to use const { ... } instead, and say that inline consts are immutable even if the same expression at runtime would allow some mutability. (Option 1 in CAD's list above.) Ironically, if today we removed value-based reasoning from promotion but kept it for the const outer scope rule (to make const { &None } work but reject the fully implicit case), const { &None::<Cell<i32>> } would trigger rust-lang/rust#122153...
it is unfortunate that promotion — conceptually a "more things just work" feature — would restrict what code is WF. But not annoying enough to justify discriminant dependent retagging.
I've opened a separate issue for discussing the proposal of having the "const transition" implicitly make all pointers immutable: #502. (@CAD97 only mentioned references but I think it has to also include raw pointers.)
I think promotion was clearly intended to be "transparent" to users - ie. the only visible side effect should be the extended lifetime. If it was meant to have other side effects then it would have been introduced with an explicit syntax for promotion, since Rust generally places a lot of importance on explicitness.
Given that, I think the proposed solution of removing implicit promotion for !Freeze types is the best option.
I think that even having promotion for !Freeze types inside const {...} blocks is quite magical. What about requiring manual promotion (ie. you must declare a static variable of the !Freeze type) for this kind of promotion to work:
let x:&'staticOption<Cell<i32>> = {static cell:Option<Cell<i32>> = None;&cell
};
AIUI, cell would not even be placed in read-only memory, and so the mutations would be completely legal (assuming appropriate synchronization).
I think that even having promotion for !Freeze types inside const {...} blocks is quite magical.
Yes, it is. OTOH, if users don't write unsafe, it will actually do exactly what they want. So there's a question here whether it's worth requiring the extra syntactic overhead of something even more explicit (and making that even possible with generics), given that most code won't care and given that we have 10 years worth of code relying on this happening fully implicitly.
I am not aware of cases where this caused issues -- we won't see all those cases, obviously, but writing to such a promoted would fairly reliably segfault since they are stored in read-only memory, so if people were surprised by that I suspect they would tell us.
What about requiring manual promotion (ie. you must declare a static variable of the !Freeze type) for this kind of promotion to work:
This doesn't work with generics so we'll probably not be able to offer good migration for some amount of the existing code -- leaving aside the ergonomic questions.
given that most code won't care and given that we have 10 years worth of code relying on this happening fully implicitly.
We've established that this would require an edition change anyway, so if a more explicit syntax is better, then we should change to that syntax, rather than compromising for no reason. While the amount of breakage is enough to require an edition change, in the grand scheme of things a tiny amount of code would be affected (and thus require the longer syntax).
This doesn't work with generics so we'll probably not be able to offer good migration for some amount of the existing code -- leaving aside the ergonomic questions.
Ah... Still, there are other more explicit options.
I mean you could add any syntax you want to explicitly call it out, but the obvious one is to use a const instead of a static as I originally suggested. Generic consts are unstable but don't suffer from the implementation problems of generic statics. rust-lang/rust#113521
I have no idea what you mean. If you want to reject const { &None::<Cell<T>> } then clearly we should also reject
constMY_CONST:&'staticCell<i32> = &None;
so we can't use consts instead of statics either.
Put differently, we already have a syntax for
{const cell:Option<Cell<i32>> = &None;
cell
}
and it's const { &None }.
Now I realize your desugaring put the & outside the static, but that will work even less because then we'd still have to implicitly promote an interior mutable value: &CONST relies on promotion to get lifetime 'static since CONST is a value. (In contrast, &STATIC always has 'static lifetime since STATIC is a place, not a value.)
IOW, this is still implicit promotion causing UB in what looks like well-defined code and should be rejected, so we can't have it desugar to this:
constC:Option<Cell<i32>> = None;let x = &C;
ptr::from_ref(x).cast_mut().write(Some(Cell::new(0)));
So, if you think const { &None } should still error then I don't think there is any kind of desugaring in terms of existing or proposed language mechanisms that we can use. We'd need an entirely new mechanism. I think the end result would be more confusing than using const { ... } for this.
An alternative solution (as hinted towards by @Jules-Bertholet in #236 (comment)) could be to argue that the &None::<Cell<i32>> literal is not of type &Option<Cell<i32>> but instead of a pattern refined type like &(Option<Cell<i32>> is None), which doesn't contain UnsafeCell in any way, and just coerces to &Option<Cell<i32>>. It's definitely not a nice solution if the surface language doesn't actually have such refinement types, but it could justify the promotion, if we want to keep it. And since promotion is doing value based checking, the pattern refined type basically is the current rule.
At least for any reasonable spelling; I didn't actually check whether &(None as Option<Cell<i32>>) is eligible for promotion, and I don't think anyone would actually miss it if it was.
Is there any way forward? Future incompatibilty lint and forbid it in next (or even one after that) edition? It seems a warning would at least be a good idea so the number of affected crates don't keep growing?
A future-compat lint has the same problems as in rust-lang/rust#124328: we'll have to first borrowck without promoting these, then if that fails borrowck against with promoteds and lint if that makes a difference.
It's not impossible, but someone who knows the borrowck API very well would have to find some time to implement this.
We could promote these without removing the runtime code and having some sort of switching statement in mir that borrowck understands as "take path A if in normal mode, if there are failures, take path B and future incompat lint in case it has no errors now"
Activity
chorman0773 commentedon Feb 28, 2024
This even has issues with the weaker vertical model I proposed, since you could do:
I'm assuming we can't not promote there b/c it's valid on stable with
&'static.I think there's two options here, both bad:
I don't think that it would be reasonable to lift read-only status, because that poses issues with (de-)duplication.
RalfJung commentedon Feb 28, 2024
Yeah, this needs full variant-awareness to be justified.
Just because it's stable doesn't mean we can't try to slowly change it, maybe not promote it over an edition, whatever.
But I worry this might be extremely widely used. And until inline const finally stabilizes, we often don't have any alternative we can offer. And any kind of migration for promotion-related things is very hard to do since it is basically impossible to have a lint for "you are relying on X being promoted, but in the future this will not be promoted any more, use inline const instead".
What does the
*mean?chorman0773 commentedon Feb 28, 2024
I'm wondering if even that is sufficient.
I'm not sure I can come up with a counterexample, though.
RalfJung commentedon Feb 28, 2024
Well, even with inline const it is somewhat questionable to make
&const { None::<Cell<i32>> }point to constant memory...Auto merge of rust-lang#121786 - RalfJung:interior-mut-value-reasonin…
glaebhoerl commentedon Mar 3, 2024
IIUC, there are something like one-and-a-half issues here:
2. Stacked Borrows and Tree Borrows aren't equipped to even detect this UB
For (2), instead of considering the value behind the reference, could this be addressed by distinguishing between read-only and mutable provenances, where the former is used for static read-only memory, and is "stronger than" interior mutability? Of course this might be a heavy hammer for dealing with a single edge case like this.
(I've been casually following the provenance and aliasing model discussions, but amn't fully in the loop, so feel free to dismiss this as a silly idea if it is one.)
RalfJung commentedon Mar 4, 2024
Not sure what you mean, Miri detects this UB just fine.
CAD97 commentedon Mar 4, 2024
The case for item or inline
constis certainly a footgun, but not model-breaking; "just" give all references toconstdata frozen/immutable provenance independent of the pointee type when created. This at least means any reliance on "infectious" mutability cannot be sound API even if well-defined in controlled environments.IIUC, it's not plausible to only do
constpromotion when necessary to satisfy outlives requirements (scope-bound temporary lifetime extension when sufficient), as codegen decisions based on region inference is a no-go. It would certainly be a huge and undesirable footgun if we did it, but it would partially resolve the issue, in that code WF absent implicit const promotion would remain WF. (The same applies for a region-inference dependent lint.)I see three options, roughly matching Connor's:
Freeze; require those usages to do manual promotion withconst. (breaking)enumdiscriminant, making the example always UB instead of conditionally WF.If
unionliterals are ever eligible for const promotion (they don't appear to be currently), they would encounter the same footgun and lack the option to granularly restrict shared mutability based on discriminant.If we're going to in any way constrain const promotion from what's currently eligible in the future (i.e. over an edition) for any reason, it's probably a good idea to also constrain it to
Freezepointee types at the same time. Only with cross-edition "nobody notices" cleanup of const promotion, though, the potential footgun will remain in not-that-future edition code.Personally, I don't think it's too bad — code relying on "adjacent" mutability is already a bad idea and only ever tenuously sound — but it is unfortunate that promotion — conceptually a "more things just work" feature — would restrict what code is WF. But not annoying enough to justify discriminant dependent retagging.
At a bare minimum, a clippy restriction lint against const promotion of
?Freezetypes makes sense (once inline const exists).Footnotes
The straightforward rule for candidate expressions I've seen is essentially just literal expressions, with adhoc bonus inclusions for the few specially tagged std functions. This naturally excludes any shared mutability since that transitively requires non-pattern construction (
UnsafeCell::newor a transmutation source). ↩RalfJung commentedon Mar 5, 2024
AFAIK unions get promoted but only if they are
Freeze. We don't do value-based reasoning for unions, and rust-lang/rust#121893 adds a test ensuring it stays that way.RalfJung commentedon Mar 31, 2024
Fixing this the way I'd like to (by not doing value-based reasoning for interior mutability) would leak to 4k crater regressions (see rust-lang/rust#122789).
Not sure how to best proceed here. I think the best possible outcome at this point is to do this promotion change over an edition that tells people to use
const { ... }instead, and say that inline consts are immutable even if the same expression at runtime would allow some mutability. (Option 1 in CAD's list above.) Ironically, if today we removed value-based reasoning from promotion but kept it for the const outer scope rule (to makeconst { &None }work but reject the fully implicit case),const { &None::<Cell<i32>> }would trigger rust-lang/rust#122153...Agreed.
RalfJung commentedon Apr 1, 2024
I've opened a separate issue for discussing the proposal of having the "const transition" implicitly make all pointers immutable: #502. (@CAD97 only mentioned references but I think it has to also include raw pointers.)
Diggsey commentedon Aug 18, 2024
I think promotion was clearly intended to be "transparent" to users - ie. the only visible side effect should be the extended lifetime. If it was meant to have other side effects then it would have been introduced with an explicit syntax for promotion, since Rust generally places a lot of importance on explicitness.
Given that, I think the proposed solution of removing implicit promotion for !Freeze types is the best option.
I think that even having promotion for !Freeze types inside
const {...}blocks is quite magical. What about requiring manual promotion (ie. you must declare a static variable of the !Freeze type) for this kind of promotion to work:AIUI,
cellwould not even be placed in read-only memory, and so the mutations would be completely legal (assuming appropriate synchronization).RalfJung commentedon Aug 18, 2024
Yes, it is. OTOH, if users don't write unsafe, it will actually do exactly what they want. So there's a question here whether it's worth requiring the extra syntactic overhead of something even more explicit (and making that even possible with generics), given that most code won't care and given that we have 10 years worth of code relying on this happening fully implicitly.
I am not aware of cases where this caused issues -- we won't see all those cases, obviously, but writing to such a promoted would fairly reliably segfault since they are stored in read-only memory, so if people were surprised by that I suspect they would tell us.
This doesn't work with generics so we'll probably not be able to offer good migration for some amount of the existing code -- leaving aside the ergonomic questions.
Diggsey commentedon Aug 18, 2024
We've established that this would require an edition change anyway, so if a more explicit syntax is better, then we should change to that syntax, rather than compromising for no reason. While the amount of breakage is enough to require an edition change, in the grand scheme of things a tiny amount of code would be affected (and thus require the longer syntax).
Ah... Still, there are other more explicit options.
RalfJung commentedon Aug 18, 2024
Such as? I don't think there are.
Diggsey commentedon Aug 18, 2024
I mean you could add any syntax you want to explicitly call it out, but the obvious one is to use a
constinstead of astaticas I originally suggested. Generic consts are unstable but don't suffer from the implementation problems of generic statics.rust-lang/rust#113521
RalfJung commentedon Aug 19, 2024
I have no idea what you mean. If you want to reject
const { &None::<Cell<T>> }then clearly we should also rejectso we can't use
consts instead ofstatics either.Put differently, we already have a syntax for
and it's
const { &None }.Now I realize your desugaring put the
&outside thestatic, but that will work even less because then we'd still have to implicitly promote an interior mutable value:&CONSTrelies on promotion to get lifetime'staticsinceCONSTis a value. (In contrast,&STATICalways has'staticlifetime sinceSTATICis a place, not a value.)IOW, this is still implicit promotion causing UB in what looks like well-defined code and should be rejected, so we can't have it desugar to this:
So, if you think
const { &None }should still error then I don't think there is any kind of desugaring in terms of existing or proposed language mechanisms that we can use. We'd need an entirely new mechanism. I think the end result would be more confusing than usingconst { ... }for this.RalfJung commentedon Sep 4, 2024
I think fixing this can only really happen as part of rust-lang/rust#124328.
Auto merge of rust-lang#128543 - RalfJung:const-interior-mut, r=fee1-…
Auto merge of #128543 - RalfJung:const-interior-mut, r=fee1-dead
CAD97 commentedon Mar 20, 2025
An alternative solution (as hinted towards by @Jules-Bertholet in #236 (comment)) could be to argue that the
&None::<Cell<i32>>literal is not of type&Option<Cell<i32>>but instead of a pattern refined type like&(Option<Cell<i32>> is None), which doesn't containUnsafeCellin any way, and just coerces to&Option<Cell<i32>>. It's definitely not a nice solution if the surface language doesn't actually have such refinement types, but it could justify the promotion, if we want to keep it. And since promotion is doing value based checking, the pattern refined type basically is the current rule.At least for any reasonable spelling; I didn't actually check whether
&(None as Option<Cell<i32>>)is eligible for promotion, and I don't think anyone would actually miss it if it was.Auto merge of #142287 - RalfJung:no-promote-im, r=<try>
Auto merge of #142287 - RalfJung:no-promote-im, r=<try>
VorpalBlade commentedon Jun 12, 2025
Is there any way forward? Future incompatibilty lint and forbid it in next (or even one after that) edition? It seems a warning would at least be a good idea so the number of affected crates don't keep growing?
RalfJung commentedon Jun 12, 2025
A future-compat lint has the same problems as in rust-lang/rust#124328: we'll have to first borrowck without promoting these, then if that fails borrowck against with promoteds and lint if that makes a difference.
It's not impossible, but someone who knows the borrowck API very well would have to find some time to implement this.
oli-obk commentedon Jun 12, 2025
We could promote these without removing the runtime code and having some sort of switching statement in mir that borrowck understands as "take path A if in normal mode, if there are failures, take path B and future incompat lint in case it has no errors now"
Crude, but could work
Auto merge of #128543 - RalfJung:const-interior-mut, r=fee1-dead