Skip to content

Writing to a static is somehow allowed while initializing that same static. #142404

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
theemathas opened this issue Jun 12, 2025 · 28 comments
Open
Labels
A-const-eval Area: Constant evaluation, covers all const contexts (static, const fn, ...) C-bug Category: This is a bug. I-lang-nominated Nominated for discussion during a lang team meeting. P-lang-drag-2 Lang team prioritization drag level 2.https://rust-lang.zulipchat.com/#narrow/channel/410516-t-lang.

Comments

@theemathas
Copy link
Contributor

I tried this code:

#![allow(dead_code)]

struct Foo {
    x: i32,
    y: (),
}

static S: Foo = Foo {
    x: 0,
    y: unsafe {
        (&raw const S.x).cast_mut().write(1);  // Writing to an immutable static that's still being initialized!
    },
};

fn main() {
    println!("{}", S.x);
}

I expected to see this happen: An error of some sort, or Miri reports UB.

Instead, this happened: The program compiles and outputs 0. Miri does not detect UB.

Note that writing to a static while initializing a different static is not allowed. For example,

static mut S: i32 = 0;

static T: () = unsafe {
    (&raw mut S).write(1);
};

this produces the following error

error[E0080]: could not evaluate static initializer
    --> src/lib.rs:4:5
     |
4    |     (&raw mut S).write(1);
     |     ^^^^^^^^^^^^^^^^^^^^^ modifying a static's initial value from another static's initializer
     |
note: inside `std::ptr::mut_ptr::<impl *mut i32>::write`
    --> /playground/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mut_ptr.rs:1507:18
     |
1507 |         unsafe { write(self, val) }
     |                  ^^^^^^^^^^^^^^^^
note: inside `std::ptr::write::<i32>`
    --> /playground/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:1580:9
     |
1580 |         intrinsics::write_via_move(dst, src)
     |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here

For more information about this error, try `rustc --explain E0080`.
error: could not compile `playground` (lib) due to 1 previous error

Meta

Reproducible on the playground with 1.89.0-nightly (2025-06-11 e703dff8fe220b78195c)

@rustbot labels +A-const-eval

@theemathas theemathas added the C-bug Category: This is a bug. label Jun 12, 2025
@rustbot rustbot added needs-triage This issue may need triage. Remove it if it has been sufficiently triaged. A-const-eval Area: Constant evaluation, covers all const contexts (static, const fn, ...) labels Jun 12, 2025
@theemathas
Copy link
Contributor Author

Another variant:

#![allow(dead_code)]

struct Foo {
    x: i32,
    y: (),
}

static mut S: Foo = Foo {
    x: 0,
    y: unsafe {
        S.x = 1;
    },
};

fn main() {
    println!("{}", unsafe { S.x });
}

This code compiles and prints 0.

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

Fun. we even have tests for this... but apparently something is not catching it

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

We thought #116564 only made it possible to read from statics while initializing them (which just gives you uninit memory). But looking at the diff a test definitely changed to allow writing to statics, too

@theemathas
Copy link
Contributor Author

From my testing, reading from a static while initializing it produces a "encountered static that tried to initialize itself with itself" error.

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

oh right, we had to prevent that for zsts as zsts perform no read, so we need to ensure they don't self-initialize

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

Or maybe we said this was fine since the value was always getting overwritten by the finial expression anyway? Because we had no way to allow the final write to the static while forbidding other writes. Slowly some memories of thinking about this before are coming up

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

Ok, yea, this was def intended (#116564 (comment)), although arguably not discussed sufficiently.

I had another look at the impl and it would need a bit of extra interpreter machine logic to prevent (or more specifically to special case _0 = foo MIR assignments to be ok, but all other uses of the static will get an error).

idk, considering

this was fine since the value was always getting overwritten by the finial expression anyway

There's nothing really important to change here. Considering

    let mut data = (0, 0);
    data = (1, {
        data.0 = 5;
        2
    });
    assert_eq!(data, (1, 2));

is fine, I don't see a good argument for rejecting the static equivalent

@Nemo157
Copy link
Member

Nemo157 commented Jun 12, 2025

Might be a good candidate for a clippy lint in that case.

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

cc @rust-lang/wg-const-eval

I think this is working as intended, but it is a bit surprising as it's rare that you can refer to the address of something before it is initialized

@RalfJung
Copy link
Member

(or more specifically to special case _0 = foo MIR assignments to be ok, but all other uses of the static will get an error)

IMO that is the main point: there's no difference between writing to &raw mut S and writing to _0, we just don't have syntax for the latter. _0 always gets fully overwritten at the end, so these intermediate writes can't even have any effect. But semantically this must already work, so we may as well have it work in all cases.

But ultimately it is up to @rust-lang/lang whether they consider this problematic.

@lcnr
Copy link
Contributor

lcnr commented Jun 12, 2025

I feel like I don't have a good tell on this behavior yet. It feels somewhat similar to the following, forbidden code:

#![allow(dead_code)]

struct Foo {
    x: i32,
    y: (),
}

fn main() {
    let s: Foo;
    s = Foo {
        x: 0,
        y: unsafe {
            (&raw const s.x).cast_mut().write(1); // Writing to an immutable static that's still being initialized!
        },
    };
    println!("{}", s.x);
}

If I understand correctly, we do not allow reading from statics in their initializer, i.e. the following errors with "could not evaluate static initializer" https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=47e4a7aebe19fdd8b8c7f4e45fa8fa81:

static S: Foo = Foo {
    x: 0,
    y: unsafe {
        let _ = (&raw const S.x).cast::<()>().read(); 
    },
};

Is the following summary correct:

  • reading from statics in statics initializers is required to be non-cyclic. you can't read from the static you're currently initializing
  • writing to the (regardless of mutability) static we're currently initializing is allowed and will always get overwritten by the final write to the return place of the initializer

This means while the writes to the static are weird, they do not cause any opsem or soundness issues.

I personally feel like we should forbid these useless writes. Afaict they are always irrelevant and I can't think of a case where it would be desirable. If we only allow initializing the static with the final value of its initializer were trivial to do, do you think we'd still want to support these irrelevant earlier writes?

@oli-obk
Copy link
Contributor

oli-obk commented Jun 12, 2025

I personally feel like we should forbid these useless writes. Afaict they are always irrelevant and I can't think of a case where it would be desirable. If we only allow initializing the static with the final value of its initializer were trivial to do, do you think we'd still want to support these irrelevant earlier writes?

I don't want to support these irrelevant earlier writes. It's just a bit of extra complexity to prevent them, so we actually need to want to prevent them explicitly and thus take the complexity hit.

@RalfJung
Copy link
Member

I wouldn't mind forbidding them if there was a nice way to do it. But syntactically special-casing the _0 = expr write is a bit too dirt of a hack IMO.

@lcnr
Copy link
Contributor

lcnr commented Jun 12, 2025

It feels somewhat surprising to me that we even treat the static and the return place as the same thing instead of evaluating the static initializer just like another const body and then manually using its result as the value of the static 🤔 why is this the case?

@RalfJung
Copy link
Member

RalfJung commented Jun 12, 2025

We do treat it like any other const body. Const bodies evaluate into a return destination (i.e., they return a place, not a value). For statics, we make that return destination be the static itself.

For some time we had a "temporary" return destination, but that lead to the uncomfortable situation where the AllocId representing the static was different when evaluating the static vs later.

@lcnr
Copy link
Contributor

lcnr commented Jun 12, 2025

but that lead to the uncomfortable situation where the AllocId representing the static was different when evaluating the static vs later.

why this? and how do we make sure that the AllocId used for other statics doesn't differ between statics we evaluate?

@lcnr
Copy link
Contributor

lcnr commented Jun 12, 2025

I guess we also have AllocIds for locals?

@RalfJung
Copy link
Member

why this? and how do we make sure that the AllocId used for other statics doesn't differ between statics we evaluate?

We don't have two AllocIds for a static any more so there's no problem any more. :)

And yes, every allocation has an AllocId -- locals, globals, heap allocations, vtables, functions (as the target of function pointers), everything.

@bjorn3
Copy link
Member

bjorn3 commented Jun 12, 2025

IMO that is the main point: there's no difference between writing to &raw mut S and writing to _0, we just don't have syntax for the latter. _0 always gets fully overwritten at the end, so these intermediate writes can't even have any effect. But semantically this must already work, so we may as well have it work in all cases.

If we allow this to work, then we won't be able to do RVO anymore, nor would language builtin pin-init be possible. As those would avoid _0 being fully overwritten after (&raw const S.x).cast_mut().write(1) runs.

@RalfJung
Copy link
Member

We don't optimize consts so RVO isn't much of a concern. Not sure how pin-init would work in consts?

@traviscross traviscross added I-lang-nominated Nominated for discussion during a lang team meeting. P-lang-drag-2 Lang team prioritization drag level 2.https://rust-lang.zulipchat.com/#narrow/channel/410516-t-lang. and removed needs-triage This issue may need triage. Remove it if it has been sufficiently triaged. labels Jun 14, 2025
@traviscross
Copy link
Contributor

traviscross commented Jun 14, 2025

But ultimately it is up to @rust-lang/lang whether they consider this problematic.

Thanks for the report and analysis. We'll discuss.

For my part, as @lcnr pointed out, it does seem a bit odd that these are inconsistent:

// With local.
let x: (u8, ());
x = (0, unsafe {
    (&raw const x.0).cast_mut().write(1);
    //~^ ERROR used binding `x` isn't initialized
});
// With static.
static X: (u8, ()) = (0, unsafe {
    (&raw const X.0).cast_mut().write(1);
    //~^ OK?
});

Playground link

Regarding the complexity hit here, sometimes, in the past, it's been easier to add a lint for something than to make it a hard error (since it was OK if the lint was only mostly correct). Any chance that this is one of those cases?

@RalfJung
Copy link
Member

RalfJung commented Jun 14, 2025

it does seem a bit odd that these are inconsistent:

This is a direct consequence of the following, fully intentional "inconsistency":

struct S(&'static S);

static MY_S: S = S(&MY_S); // works

let my_s = S(&my_s); // does not work

@traviscross

This comment has been minimized.

@RalfJung
Copy link
Member

Sorry, I had a little typo:

struct S(&'static S);
static MY_S: S = S(&MY_S); // works

@traviscross
Copy link
Contributor

traviscross commented Jun 14, 2025

Thanks. Is the following also equivalent to your point (i.e. without that 'static lifetime)?:

struct S(*const S);
unsafe impl Sync for S {}

fn main() {
    static MY_S: S = S(&raw const MY_S);
    let my_s;
    my_s = S(&raw const my_s);
    //~^ ERROR used binding `my_s` isn't initialized
}

@RalfJung
Copy link
Member

RalfJung commented Jun 14, 2025

Thanks. Is the following also equivalent to your point

Yes. More broadly, we allow statics to recursively point to themselves. This goes waaaay back. That makes statics consistent with other items like functions and types that are also allowed to recursively refer to themselves.

We don't allow the same for let bindings. I don't know why this choice was made, but it is the common choice among languages I know of. It is also crucial to allow shadowing patterns like let x = convert(x);.

@traviscross
Copy link
Contributor

I'd expect let x; x = .. and let x = .. to not have the same behavior with respect to shadowing.

@RalfJung
Copy link
Member

Yeah, if we allowed taking a raw pointer to an uninitialized local we would likely accept that code. We'd have to ensure the local is considered "live" by MIR building the moment a raw borrow occurs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-const-eval Area: Constant evaluation, covers all const contexts (static, const fn, ...) C-bug Category: This is a bug. I-lang-nominated Nominated for discussion during a lang team meeting. P-lang-drag-2 Lang team prioritization drag level 2.https://rust-lang.zulipchat.com/#narrow/channel/410516-t-lang.
Projects
None yet
Development

No branches or pull requests

8 participants