madsmtm / objc2

Bindings to Apple's frameworks in Rust
https://docs.rs/objc2/
MIT License
373 stars 39 forks source link

How do we handle availability? #266

Open madsmtm opened 2 years ago

madsmtm commented 2 years ago

In Apple's Objective-C headers, most classes and methods are annotated with an availability attribute such as API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0)); this is absolutely a great idea (!!!), it very cleanly allows you to mark which APIs you may use, and which ones are not usable on your current deployment target.

We should have some way of doing the same as @available in Objective-C; however, since we are not a compiler like clang is, this is quite tricky!

A quick demonstration of what I want:

  1. Declare an API which is only available on a specific deployment target
    #[cfg_available(macos = 10.10, ..)]
    #[sel(doSomething:)]
    fn doSomething(&self, arg: i32);
  2. Prevent the user from using said API if their deployment target is not high enough:

    // MACOSX_DEPLOYMENT_TARGET=10.7
    obj.doSomething(32); // Fails (ideally at compile time, otherwise with debug assertions at runtime)
    
    // MACOSX_DEPLOYMENT_TARGET=10.10
    obj.doSomething(32); // Works
  3. Allow the user (usually libraries) to use the API if they've verified that the target version is high enough (this uses a dynamic runtime check except if the deployment target is high enough). Done in https://github.com/madsmtm/objc2/pull/661.
    if available!(macos = 10.12, ..) { // Anything higher than what `doSomething` needs
        obj.doSomething(32); // Works no matter the deployment target
    }
  4. When declaring a class and overriding methods, if you know when the method was added (and hence, when it will be called), communicate this availability to the body of the function:
    #[cfg_available(macos = 10.10, ..)] // Same as superclass'
    #[sel(doSomething:)]
    fn doSomething(&self, arg: i32) {
        // Allowed to do things only available on macOS 10.10 and above
    }

For this, Contexts and capabilities come to mind, similar to how it would be useful for autorelease pools, but alas, we can't do that yet, so this will probably end up as a debug assertions runtime check.

See also the original https://github.com/SSheldon/rust-objc/issues/111, a WIP implementation can be found in https://github.com/madsmtm/objc2/pull/212.

madsmtm commented 2 years ago

There are effectively two versions that affect availability and what we should do: the deployment target and the SDK version.

To illustrate, let's assume an API fn foo() { ... } that's introduced in some version i, later deprecated in some version d, and finally removed in some version r*.

Deployment target SDK version fn declaration foo() if_available(introduced_version_or_above) { foo() }
..i ..i None Fails Fails
..i i..d fn foo() { assert_available(introduced_version); ... } Panics Success
..i d..r #[deprecated] fn foo() { assert_available(introduced_version); ... } Warning + Panics Warning
..i r.. None* Fails Fails
i..d i..d fn foo() { ... } Success Success
i..d d..r #[deprecated] fn foo() { ... } Warning Warning
i..d r.. None* Fails Fails
d..r d..r #[deprecated] fn foo() { ... } Warning Warning
d..r r.. None* Fails Fails
r.. r.. None* Fails Fails

*Note that I don't really know a case where an API has been removed, but we can handle it if need be.

madsmtm commented 2 years ago

From the above, we can see that the logic in cfg_available is basically: Deployment target < Introduced version -> Add assertion that the user has checked the availability before calling Deprecation version <= SDK version -> Mark the item as deprecated Removal version <= SDK version -> Remove the item

madsmtm commented 2 years ago

We should similarly try to support such attributes on each impl item

EDIT: Moved to https://github.com/madsmtm/objc2/issues/285

madsmtm commented 1 year ago

We need to do both a static and a dynamic check - the static one is fairly straightforward, but I'm unsure of how we should do the dynamic one?

I would have thought that clang just called some library function, but it's actually implemented as a compiler builtin, which calls into CoreFoundation and reads /System/Library/CoreServices/SystemVersion.plist, see os_version_check.c.

Do we really need that as well? Or can we perhaps get by with just reading kCFCoreFoundationVersionNumber (or maybe NSFoundationVersionNumber)?

madsmtm commented 1 year ago

Actually, turns out the @available in Objective-C is much newer than i thought - see the initial proposal here.

We could reconsider the check to be just is_available!(MyClass::doSomething), but I think the reasoning in that post (better for control-flow) apply to us as well, at least if we do end up getting something like contexts and capabilities. The optimization potential once the user switches to a higher deployment target is also nice.

madsmtm commented 1 year ago

Swift's #available works similarly, see Availability.swift and Availability.mm.

Though they use (a weak symbol to) os_system_version_get_current_version, are we allowed to do that too? And where is that even defined?

madsmtm commented 1 year ago

There's a new RFC that would help with OS compile-time detection: https://github.com/rust-lang/rfcs/pull/3379 (though we'd still want a macro for the compile-time + runtime detection fallback)

madsmtm commented 11 months ago

A short, very incomplete list I made a while ago on different behaviour in clang based on the deployment target (clang v13 source):

My conclusion was that it's not super important for the runtime i.e. objc2 to know the deployment target statically, especially not after https://github.com/madsmtm/objc2/pull/530.

(That said, it's of course still very important for the user to know, so we still need this feature in some shape or form).

madsmtm commented 11 months ago

I wrote some ideas for how the availability check might work internally in this playground.

madsmtm commented 11 months ago

We have rustc --print deployment-target for retrieving the current deployment target, and since https://github.com/rust-lang/cc-rs/pull/848 the cc crate has been automatically using that for setting the deployment target for build scripts. I've opened https://github.com/rust-lang/cargo/issues/13115 for making the deployment target even more easily accessible from build scripts.

madsmtm commented 2 months ago

This also affects the "unstable-static-class" feature, we'd have to make sure to weakly link the class if the deployment target is not high enough for it to always be available.

madsmtm commented 1 month ago

Have been working on this recently, I think there is value in exposing just the runtime lookup functionality of this (i.e. an available! macro), and deferring the compile-time checks to later.

madsmtm commented 1 month ago

Another idea would be to have a flag ASSUME_LOW_VERSION or something that the user can set, and which makes all availability checks return the deployment target + makes all APIs that require newer versions panic instead. That way, the user could still test their application for invalid assumptions about availability, without having the hardware to test it on.

madsmtm commented 1 month ago

Yet another idea would be to develop this in rustc itself.

BlackHoleFox commented 1 month ago

Yet another idea would be to develop this in rustc itself.

I suggest that whenever the topic of OS version checking or availability come up in github.com/rust/, like before in https://github.com/rust-lang/rfcs/pull/3379#issuecomment-1850531357 (you also commented there). Maybe we should fork/revive that RFC in a smaller form? Would love to see std have an available! macro if nothing else that does the mix of static deployment target and runtime checking.

Had some experiments earlier in the year too looking at how possible it would be to duplicate the codegen LLVM injects into builds to handle dynamic checking at runtime via #available and nothing there looked unstable enough to be impossible...

madsmtm commented 1 month ago

Yeah, I'm actually in the process of writing the available! macro right now that does static deployment target + runtime checking, see https://github.com/madsmtm/objc2/pull/661 for my current progress. And while doing this, it has indeed become apparent that such a macro really belongs in std.

Alongside that, I'm considering implementing a #[cfg(rustc_os_version(macos = 10.14.7, ios = ...))] attribute or similar to do the cfg-based checking of the deployment target. That way we can experiment with it in std first, before really figuring out the RFC that's needed to get it stable. I'll probably need to file an MCP for that first.

BlackHoleFox commented 1 month ago

Alongside that, I'm considering implementing a #[cfg(rustc_os_version(macos = 10.14.7, ios = ...))] attribute or similar

I'd try and keep rustc or cargo out of it tbh. You can set an env variable for rustc to read today but some of the proposals want to make this cargo configurable, so keeping the attribute agnostic might help it age better.