Skip to content

Is life-before-main / #[ctor] safe? #598

Open

Description

@madsmtm

Rust does not provide facilities for doing work before fn main(), but we can achieve it anyhow on a lot of platforms using linker tricks and global constructors, as done in ctor, inventory and the standard library itself.

Is this safe to do, or should it require unsafe? If so, what are the safety requirements that one needs to uphold inside a global constructor?


In isolation, this feature seems to be safe (?), but one reason to define it as unsafe could be to allow libraries to rely on never being used in a constructor themselves? Consider the following library:

static mut FOO: i32 = 0;

/// Do some "heavy" work to initialize `FOO`.
#[ctor]
fn setup_foo() {
    // SAFETY: `get_foo` is not running at the same time as this constructor, so an unsynchronized write is fine.
    unsafe { FOO = 42 };
}

/// Get the value from `FOO`.
pub fn get_foo() -> NonZero<i32> {
    // SAFETY: The constructor has run before `get_foo`
    let foo = unsafe { FOO };

    // SAFETY: The variable was initialized to a non-zero value.
    unsafe { NonZero::new_unchecked(foo) }
}

The safety comments here rely on:

  1. The constructor not executing concurrently with anything else.
  2. The constructor executing before get_foo.

But those assumptions are actually false, since the user may write:

#[ctor]
fn user_constructor() {
    std::thread::spawn(|| {
        get_foo();
    });
}

And, depending on linker ordering, user_constructor may be run first (unless we can somehow guarantee that setup_foo runs before user_constructor?), in which case the unsynchronized read+write is not actually okay, and the variable may not actually be initialized to a non-zero value.

The correct way to write it then would be:

static FOO: AtomicI32 = AtomicI32::new(0);

#[ctor]
fn setup_foo() {
    // Must use atomics.
    FOO.store(42, Ordering::Relaxed);
}

pub fn get_foo() -> NonZero<i32> {
    let foo = FOO.load(Ordering::Relaxed);

    // Must check that the variable is actually initialized.
    NonZero::new(foo).unwrap()
}

Though this does decrease the utility of #[ctor] by a fair margin as the performance improvements of using a global constructor decreases, and you'd probably just want to use OnceLock now instead.

One noteworthy tidbit is that when using dynamic libraries (e.g. linking libc or libX11), this is a non-issue, as constructors are executed in libraries first, so any global constructors that a dynamic library has are guaranteed to be executed before any code that depends on that dynamic library.


For context, I'm opening this because the ctor crate has made the #[ctor] attribute require unsafe, see mmastrac/linktime#159, but deciding "running code before main is unsafe for xyz reason" is a decision that affects more than just ctor (e.g. it affects inventory as well), so I thought it might be something that t-opsem / the UCG has opinions on?

See also #397.

Activity

  1. RalfJung commented on Jan 13, 2026

    @RalfJung
    Member
  2. madsmtm commented on Jan 13, 2026

    @madsmtm
    MemberAuthor

    It is in the end up to you what you allow to happen inside global constructors and what promises you make to libraries.

    I guess part of my question here is "are global constructors unsound"? Stated differently, is there some interaction with the Rust AM or the standard library or similar where global constructors would be a problem? If I were to write an equivalent C function with __attribute__((constructor)), and then make that call a Rust method over FFI, would that be unsound?

    The underlying linker directives are unsafe, right?

    Yup, #[unsafe(link_section = "...")] (which your RFC made happen ofc ;) ).

  3. CAD97 commented on Jan 14, 2026

    @CAD97
    Contributor

    Theoretically, the system calling __attribute__((constructor)) functions would be no different than calling from FFI main. The actual concrete difference, imo, is to ask whether calling the lang start item after making other calls into AM operations is allowed.

    My initial instinct would be to say that at the AM level, lang start is just a normal function that gets called by system convention, but that std is allowed to make the assumption that no Rust code is called before that, if it wants to (or IOW that it controls any before main code if it controls main).

  4. madsmtm commented on Jan 14, 2026

    @madsmtm
    MemberAuthor

    My initial instinct would be to say that at the AM level, lang start is just a normal function that gets called by system convention

    That's my instinct as well.

    std is allowed to make the assumption that no Rust code is called before that, if it wants to (or IOW that it controls any before main code if it controls main).

    Well, I think that the presence of the std docs on life-before-main in itself is a fairly strong hint that such usage isn't unsound, just likely to break in other ways. But definitely more of a t-libs question, and I should probably open a PR (after we figure out this issue) to document that std doesn't rely on no-code-before-main for soundness.

  5. mmastrac commented on Jan 14, 2026

    @mmastrac

    As the author of the ctor crate, I think the answer is very much "it depends". Basic, non-allocating low-level primitives like atomics? Sure. no_std, non-alloc code? Maybe. Can you prove that a given #[ctor] is safe? No — not without some sort of stdlib-level annotation or guarantee.

    I do think there’s room for a more "official" feature with explicit stdlib cooperation. Even something as simple as a documented guarantee about which CRT initializer slots the runtime will use would be a meaningful improvement.

    I’d genuinely love for this crate to die. Unfortunately, the initialization-ordering problem is extremely hairy, and I don’t see a way to guarantee that any particular use of #[ctor] or any similar API is safe in the general case. Even if I tried to allow-list a handful of APIs, I’d effectively be making policy decisions on behalf of the entire Rust ecosystem, and that’s not a role I want to take on.

    What does seem reasonable is for downstream crates to wrap #[ctor] in narrowly-scoped ways that are provably safe for their specific use cases. That, in my view, is the best path toward pushing unsafety out of ctor itself.

  6. ChrisDenton commented on Jan 14, 2026

    @ChrisDenton
    Member

    Well, I think that the presence of the std docs on life-before-main in itself is a fairly strong hint that such usage isn't unsound, just likely to break in other ways. But definitely more of a t-libs question, and I should probably open a PR (after we figure out this issue) to document that std doesn't rely on no-code-before-main for soundness.

    From the libs perspective, the current docs are the result of the last time this was discussed. In short it's supported on a "best effort basis". That does at least imply life before main can be used soundly.

  7. VorpalBlade commented on Jan 14, 2026

    @VorpalBlade

    Even something as simple as a documented guarantee about which CRT initializer slots the runtime will use would be a meaningful improvement.

    @mmastrac This seems to be a Windows specific concept (a platform I know almost nothing about)? How would this work on / translate to ELF based platforms, or on Mac?

    Does it make sense to make guarantees that aren't useful in a portable way? Wouldn't it just means those guarantees aren't very useful in practice or actively hinder the portability of crates relying on them.

  8. mmastrac commented on Jan 14, 2026

    @mmastrac

    Even something as simple as a documented guarantee about which CRT initializer slots the runtime will use would be a meaningful improvement.

    @mmastrac This seems to be a Windows specific concept (a platform I know almost nothing about)? How would this work on / translate to ELF based platforms, or on Mac?

    Does it make sense to make guarantees that aren't useful in a portable way? Wouldn't it just means those guarantees aren't very useful in practice or actively hinder the portability of crates relying on them.

    Yeah, I used a Windows-ism there as shorthand - sorry. To be clearer, I mean guarantees about ordering in general, using platform-appropriate mechanisms.

    For example, on ELF platforms this could be expressed via constructor priorities (e.g. __attribute__((constructor(N))) -> .init_array.N). On macOS the mechanism is different (e.g. __mod_init_func), and it’s admittedly less clear how strong or portable priority guarantees actually are there. †

    Even more speculatively, this could take the form of a custom .rust.init.array (or similar) section that the runtime guarantees to execute after stdlib setup but before main, or other linker-script–level schenanigans that provide comparable guarantees (hand-wave gesture).

    Some guarantees on some platforms is certainly more valuable than no guarantees at all.

    † EDIT: confirmed, dyld doesn't support it (llvm/llvm-project#15363), but custom sections could be an option there.

  9. ChrisDenton commented on Jan 14, 2026

    @ChrisDenton
    Member

    I think this discussion is moving into RFC territory. I.e. adding actual support for running code before main and not just allowing third parties to do so.

  10. madsmtm commented on Jan 14, 2026

    @madsmtm
    MemberAuthor

    Regardless, even if it was possible to consistently order #[ctor]s, that still wouldn't stop a user from just choosing a higher priority than the library they're using (unless you somehow defined that "priority > 100 requires unsafe" or smth).

    I think the answer is very much "it depends". Basic, non-allocating low-level primitives like atomics? Sure. no_std, non-alloc code? Maybe. Can you prove that a given #[ctor] is safe? No — not without some sort of stdlib-level annotation or guarantee.

    Hmm, but do you have examples of stuff that cannot be used safely in #[ctor]s? I'm not aware of any myself, and I struggle to imagine cases where soundness issue would occur (correctness issues and confusingness are another problem entirely).

    Personally, I'm still not convinced that #[ctor] should be unsafe (or at least not the simpler version that just applies to fns). The user_constructor and the get_foo using atomics I showed above both seem like they're sound (assuming the Rust AM is fine with doing stuff before lang_start).

  11. ChrisDenton commented on Jan 14, 2026

    @ChrisDenton
    Member

    This is platform specific behaviour. At the very least it would need to only be provided on systems that are confirmed to be safe. But even then people make safety assumptions about main that #[ctor] can break. For example, that there are no other threads running.

  12. madsmtm commented on Jan 14, 2026

    @madsmtm
    MemberAuthor

    Right, std::env::set_var is supposed to be safe to use at the top of main.

  13. bjorn3 commented on Jan 14, 2026

    @bjorn3
    Member

    Even more speculatively, this could take the form of a custom .rust.init.array (or similar) section that the runtime guarantees to execute after stdlib setup but before main, or other linker-script–level schenanigans that provide comparable guarantees (hand-wave gesture).

    That is going to be tricky to implement for dylibs.

  14. mmastrac commented on Jan 14, 2026

    @mmastrac

    Hmm, but do you have examples of stuff that cannot be used safely in #[ctor]s? I'm not aware of any myself, and I struggle to imagine cases where soundness issue would occur (correctness issues and confusingness are another problem entirely).

    I haven't checked if this is the case for ages, but println! was unsound for quite some time. This may have been fixed, but it was the original inspiration for the crate many, many years ago. 😄

  15. madsmtm commented on Jan 16, 2026

    @madsmtm
    MemberAuthor

    CC @notgull who maintains ctor-lite, and wrote the following in its safety section:

    Macros from this crate must be used with care. In general Rust code is run with the assumption that no other code is run before program startup, and no other code is run after program shutdown. Specifically, libstd sets up some global variables before the main function and then assumes these variables are set throughout its runtime. Therefore, calling libstd functions that use these variables will lead to undefined behavior.

    Generally, functions from core or alloc are safe to call in these functions. In addition, functions from libc should be able to be called freely, as well as most of the functions contained in rustix. Other crates should be used only when it is understood what other calls they contain.

    In addition, no ordering is guaranteed for functions ran in the ctor or dtor macros.

  16. 6 remaining items

  17. VorpalBlade commented on Jan 18, 2026

    @VorpalBlade

    Isn't this scenario kind of the reverse? Intuitively "life before main" is shorthand for "any normal code from the current crate" [...]

    Shouldn't it be "any normal code from the same shared object/dylib/dll/exe/... or dependencies thereof"? There can be multiple crates in a shared object after all.

    Put differently, you're right that it's not possible to provide the guarantee that all static constructors have run before the actual binary's main(), but I don't think that's necessary either.

    That is a good point, but not what I was arguing about.

    But I think all this confusion points to the question of this issue is then underspecified, since it wasn't clear to me what you considered life before main when there is no main, or main runs before your code is even loaded. When discussing semantics like this it is important to precisely define all terms and not use undefined shorthand and hope everyone has the same mental picture.

  18. simonask commented on Jan 18, 2026

    @simonask

    @VorpalBlade Very good point about all of this being underspecified, and thank you for pointing out the additional complexities that arise from dynamic linking.

    Instead of "before main()", we can say "load-time" or "initialization-time", because this is the behavior of dlopen()/LoadLibraryEx(): the list of static constructors is guaranteed to have been called before the loading function returns.

    My point is made within that context: When any crate type contains static constructors (static library, dynamic library, executable), and the target platform supports it, it is useful to say that they should be executed before any "normal" entry points in that crate are available, meaning that arbitrary code is fine as long as it cannot run any code from the library without first ensuring that it is fully loaded and initialized.

  19. VorpalBlade commented on Jan 18, 2026

    @VorpalBlade

    Instead of "before main()", we can say "load-time" or "initialization-time", because this is the behavior of dlopen()/LoadLibraryEx(): the list of static constructors is guaranteed to have been called before the loading function returns.

    Related but bit of a side question from this issue: is that considered a safety contract of a correctly implemented dynamic loader? Should we specify that contract as well? I'm thinking for use cases where you are writing your own dynamic loader for your own kernel / OS / libc: what guarantees does rust expect of the dynamic loader that the platform / kernel / etc provides (especially if you aren't implementing a POSIX / ELF like system)?

    Is it worth opening a new issue about that? It seems like a potentially very deep rabbit hole...

  20. RalfJung commented on Jan 18, 2026

    @RalfJung
    Member

    I guess part of my question here is "are global constructors unsound"?

    I assume you mean "are safe global constructors unsound".
    As I tried to insinuate in my first post here, the answer is "it depends". It depends on the soundness contract that holds the ecosystem together. Because life-before-main is not an officially supported feature, there is no obvious central entity that would decide what that contract should be.

    • If libraries can rely on their own global ctor being called before any of their code is invoked "from the outside", then global ctors must be unsafe since you must carefully check all code you invoke from them to ensure it supports that. It'd be a bit like how there are restrictions on code you can invoke in signal handlers or after fork.
    • If libraries are not allowed to use global ctors for their own purpose and there is general consensus that all libraries must support being invoked from global ctors, then they can be safe.

    Given that I would advice to treat it as an unsafe operation, to be compatible with either choice.

    Well, I think that the presence of the std docs on life-before-main in itself is a fairly strong hint that such usage isn't unsound,

    It is a strong sign that such usage isn't unsound for the standard library. It's also a strong sign that such usage is considered special and hence may well be unsound for other libraries.

    This seems to have pretty dire consequences for dlopened cdylibs implemented in Rust. From the rust cdylib POV, anything the host application does is "before main", or the concept of main is even poorly defined here.

    I think that's not quite the same since the code outside the dylib cannot invoke the code inside the dylib before it is loaded either. The only thing the library can actually tell is whether its constructors are finished running before any API interactions are done by outside parties. (Right? People mentioned threads above, but can't I spawn a thread pre-main?)

  21. ChrisDenton commented on Jan 18, 2026

    @ChrisDenton
    Member

    Dynamic libraries are indeed different in that, as far as rust is concerned, there is no "main". There may possibly be code that std expects to be run before any rust code is run. Or not.

    Platforms may have mechanisms for running code immediately after the library is loaded (without being explicitly called) but that's very much platform defined behaviour. E.g. on Windows doing it wrong may lead to deadlocks or crashes https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-best-practices

  22. VorpalBlade commented on Jan 18, 2026

    @VorpalBlade

    (Right? People mentioned threads above, but can't I spawn a thread pre-main?)

    Unfortunately you can (at least on Linux), though I would argue that it is discouraged. For example, a C++ dylib that you link (not dlopen1) could have spawned threads before the Rust main ever gets to run. Rust could could do the same, but people are probably more aware of the problems with doing so. That said, I don't know if this is actually done "in the wild".

    An effect of this would be that // SAFETY: We are at the top of main and haven't started threads yet, setting environment/forking is fine might not be true. I think this is another strong argument for general ctor to be unsafe in Rust. (Possibly libraries that expose more limited "register things statically" might be possible to make safe still.)

    Footnotes

    1. Unless you dlopen before main, please just stop. ↩

  23. notgull commented on Jan 19, 2026

    @notgull

    CC @notgull who maintains ctor-lite, and wrote the following in its safety section:

    I stand by this, except maybe for alloc.

  24. madsmtm commented on Jan 30, 2026

    @madsmtm
    MemberAuthor

    I've become less convinced that the "set_var in main" thing is actually a problem?

    Consider a crate access_env_in_ctor_thread:

    //! Repeatedly calls `getenv("FOO")` in a thread in a global constructor.
    //!
    //! # Safety notice
    //!
    //! Without the presence of `unsafe` elsewhere, this library is safe.
    //! 
    //! It does affect usage of [`std::env::set_var`] in `fn main`, since `fn main` can no longer rely
    //! on there being no other threads accessing the global environment.
    
    #[ctor::ctor]
    fn constructor() {
        std::thread::spawn(|| {
            loop {
                // SAFETY: Accessing environment variables is safe.
                let env = unsafe { libc::getenv(c"FOO".as_ptr()) };
                if !env.is_null() {
                    let env = unsafe { CStr::from_ptr(ptr) };
                    println!("{env:?}");
                }
            }
        });
    }

    If we just follow the definition of the word (which is admittedly simplified), this library is sound, in that it is impossible for safe code to cause Undefined Behavior using it.

    If the user then decides to use it like this:

    use access_env_in_ctor_thread as _; // Link
    
    fn main() {
        // SAFETY: We are `fn main`, so there are no other threads reading/writing the environment.
        unsafe { std::env::set_var("FOO", "bar") };
    }

    I'd argue that the error is in the SAFETY comment in fn main, because that assumption just isn't true if linking access_env_in_ctor_thread.

    More concretely, I'd argue that if you want to use std::env::set_var in fn main, you have to ensure that there are no constructors that spawn detached threads. This is often pretty easy, because no sane code does this, but the onus is on you. If you decide to link access_env_in_ctor_thread (either directly or transitively), you haven't upheld that guarantee.

  25. RalfJung commented on Jan 30, 2026

    @RalfJung
    Member

    That's one option to resolve this, but it's also entirely reasonable to say that the safety comment is correct -- main may rely on there not being other threads, and any code that observable breaks that is unsound. To repeat what I said above: because life-before-main is not an officially supported feature, there is no obvious central entity that would decide what that contract should be.

  26. ia0 commented on Jan 30, 2026

    @ia0

    Should life-before-main be added to #379?

    It seems to me that access_env_in_ctor_thread is an example of when the simplified definition of soundness from the glossary falls short of what it should be, namely that the thing under consideration (function, module, crate) is semantically well-typed. In this particular example it would manifest as the ctor being either:

    • semantically ill-typed (thus unsound) if the type interpretation of safe ctor functions does not get a "sendable" shared/fractional resource for the environment (so main gets an exclusive/full resource), and if they need such resource, they need to be made unsafe to link somehow
    • semantically well-typed (thus sound) if the type interpretation of safe ctor functions does get a shared resource for the environment (that can be sent to spawned threads), and it's the responsibility of whoever needs an exclusive resource (like with env::set_var) to make sure all concurrent threads are robust and don't hold any shared resource for the environment, including threads spawned from ctor functions
  27. RalfJung commented on Jan 31, 2026

    @RalfJung
    Member

    It seems to me that access_env_in_ctor_thread is an example of when the simplified definition of soundness from the glossary falls short of what it should be, namely that the thing under consideration (function, module, crate) is semantically well-typed. In this particular example it would manifest as the ctor being either:

    Saying "it must be semantically well-typed" answers nothing. You'd have to first define what you mean by that, and in making that definition you have to make exactly the choice we are talking about here: either life-before-main is safe, or libraries can assume their own life-before-main runs before their public API is used (and maybe main can assume there are no other threads yet). Both of these choices lead to entirely sensible notions of "semantically well-typed".

    That's why these discussions are tricky: to get an official answer to all of them, we need to fully nail down the official notion of semantic typing. The problem with that is that (a) precisely talking about semantic typing seems to require very heavy mathematical hammers, so it's hard to have this discussion while including the wider community, and (b) in nailing down that definition, we would also give definite answers to all the soundness conflicts we didn't even know existed, i.e. we'd be making choices we're not even aware of, which seems suboptimal.

    That's why we currently do this on a case-by-case basis and the typical way we nail down semantic typing is indirectly, by blessing certain APIs as sound.

  28. ia0 commented on Jan 31, 2026

    @ia0

    Saying "it must be semantically well-typed" answers nothing.

    Yes exactly, it depends on type (and second class constructs like modules and crates that would have a type if they were first class) interpretation, which is why I gave at least 2 options for the type interpretation of ctor functions. The goal was not to answer anything, it was to point out that the glossary definition of soundness is not good enough for load-bearing discussions like this one.

    precisely talking about semantic typing seems to require very heavy mathematical hammers, so it's hard to have this discussion while including the wider community

    I might be too optimistic, but I believe it's possible to some extent. The community already understands (to some extent) the concept of resources because of how pervasive borrowing is. And they also understand (to some extent) the notion of resource separation because of aliasing XOR mutability. Life-before-main can be explained with those concepts without going in the details of how to properly define resources (like Iris does). It will be informal, but still more useful than "it must not have UB in any safe context", because that's a definition of soundness that composes (essentially solving the underlying problem of #379).

    That's why we currently do this on a case-by-case basis and the typical way we nail down semantic typing is indirectly, by blessing certain APIs as sound.

    I don't think it's incompatible. This can still be done in a case-by-case basis and by blessing certain APIs as sound. The difference is that when doing so, some form of informal argument regarding resources needs to be provided. In the case of life-before-main it would look like one of:

    • no safe function (main, ctor, or regular) gets any exclusive resource (so when you write env::set_var() you have to argue why you own an exclusive resource to the environment, and saying you're in main is not enough)
    • main gets exclusive resource to all things extern to the program (like the environment but unlike static mut which belongs to the crate defining it) so life-before-main needs to make sure it uses resources appropriately in all the programs it gets linked to (essentially making such library unsafe which is not a concept we have in syntax yet, like requiring extern unsafe crate foo; to link against)
    • main and ctor functions get exclusive resource to all things extern to the program, but ctor functions get a version that cannot be sent to spawned threads
  29. VorpalBlade commented on Jan 31, 2026

    @VorpalBlade

    no safe function (main, ctor, or regular) gets any exclusive resource (so when you write env::set_var() you have to argue why you own an exclusive resource to the environment, and saying you're in main is not enough)

    I don't think this one is reasonable. That would mean you would have to audit all transitive dependencies for usage of life before main, and cargo install without --locked would be unsound. You could essentially never use set_env at all for any non-trivial code base.

  30. ia0 commented on Jan 31, 2026

    @ia0

    I don't think this one is reasonable. That would mean you would have to audit all transitive dependencies for usage of life before main

    Yes, I agree. It's definitely unrealistic in current Rust (even for safety-critical systems where they own everything top to bottom). And even in the hypothetical world where Rust has a notion of "robustness" (as defined in the unsafe mental model), which is the dual of "unsafeness", I don't think libraries would declare themselves as robust regarding them not claiming extern-to-program resources in ctor functions, unless there's some automated tooling (say in cargo) to help doing so. I mostly provided those options as an incomplete illustration of the spectrum.

  31. Kixunil commented on Apr 7, 2026

    @Kixunil

    FTR, there is a way to make usage of set_env sound even if ctor is safe: check the number of threads using the OS API - if there's exactly one then you are guaranteed the number won't increase, so you can call it. I did this in my library where I simply fail initialization if there are other threads running. (The library has to be initialized and it pretty much has to involve running set_env, unfortunately.)

    Over time, I think these things really need an effect system. I know that effect systems have their problems but it seems that we already have the same problems, we just don't have them formalized.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions