-
Notifications
You must be signed in to change notification settings - Fork 238
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
Implement the Cleaner for kotlin Objects #1869
Conversation
|
||
// This is from an earlier GC test; ealier, we made 1000 new objects. | ||
// By now, the GC has had time to clean up, and now we should see 0 alive. | ||
assert(getNumAlive() == 0UL) { "Num alive is ${getNumAlive()}. GC/Cleaner hasn't run fully" }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess there's no guarantee that the GC actually ran at this point, right? Will this lead to annoying intermittents on CI?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps. On my slow machine, this test never failed.
The test failure on first push (not this one) suggested that the thread was working rather better on CI than my machine.
The alternative: putting in ~500ms sleep– which I was trying to avoid.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about manually calling System.gc()
before? It doesn't actually guarantee the GC will be done at that point, but it should make it more likely.
EDIT: actually, I just saw this comment, but I don't see why this should be a problem in a test. If you want to have reliable tests for GC, then you should probably force a GC pass.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me. The only reason I didn't check approve is I also wonder about the tests failing intermittently.
|
||
// Use a static inner class instead of a closure so as not to accidentally | ||
// capture `this` as part of the cleanable's action. | ||
private class _CleanAction(private val pointer: Pointer?) : Runnable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: I like using Uniffi
whenever we need a unique prefix.
// | ||
// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: | ||
// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: | ||
// 3. The memory is reclaimed when the process terminates. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer relying on 2
and would love to get rid of the destroy()
method. It gets around corner cases and feels more consistent with GC-based language.
How serious is the risk of cleaner thread starvation? I hope it's something most applications can ignore.
I don't think this needs to be a blocker. I'd be happy to merge this one as-is and discuss in a future PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(1) is the escape hatch for when thread starvation happens. As you say, most apps won't have to worry about it.
We should deprecate and then remove the Disposable
interface, and the destroy
method, though this isn't done here, because we should go through the formal deprecation process.
3e81ca8
to
23aa4cb
Compare
Ok, all checks passing. |
I tested this on our Android app and the app instantly crashed. It turns out both java.lang.ref.Cleaner and android.system.SystemCleaner are only available from API 33+, which is only used in around 36% of all available devices. Given some input from my team, I thought this class would be desugared and added to our Kotlin sources from the Java 9+ APIs, but it turns out it's not. However, JNA does have its own Cleaner class ( So maybe for the Android version, something like this could be used? val cleaner = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
SystemCleaner.cleaner()
} else {
com.sun.jna.internal.Cleaner.getCleaner()
} The problem with these is, these 2 |
In https://github.com/jmartinesp/uniffi-rs/tree/android-cleaner-wrapper I created an example wrapper for Android based on the
I checked both Android implementations work using devices with different API versions, so maybe you can use something like this as a base for the implementation. |
Oh, amazing. Thank you! I'm pretty busy this week, so probably won't get to this until next week. Do you want to take this over, or can it wait until then? |
Take it over as in creating a new PR with these changes plus mine? If so, I think the work should probably continue in this PR, maybe cherry picking my Also, I don't think anyone is in a hurry for this, since anyone that uses UniFFI with Kotlin is already freeing the memory manually, so we can keep doing that until this is ready. That said, it would be super useful to have it 😅 . |
23aa4cb
to
b25774e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jmartinesp would you mind having another look at this sometime? I haven't been able to test it on Android, either with or without android_cleaner = true
.
// | ||
// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: | ||
// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: | ||
// 3. The memory is reclaimed when the process terminates. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(1) is the escape hatch for when thread starvation happens. As you say, most apps won't have to worry about it.
We should deprecate and then remove the Disposable
interface, and the destroy
method, though this isn't done here, because we should go through the formal deprecation process.
27fafe1
to
19120c8
Compare
Sorry, I deleted the comments above because I just realised I was testing an outdated version of the branch 🤦 . With the latest commits I can confirm it works both on the newest API (API 34, Android 14) and old ones (API 23, Android 7) using their respective versions of the cleaner. |
It also works well with the workaround you added with However, maybe some kind of message should be logged here? I wouldn't have realised what was happening until I took a look at the implementation details. |
19120c8
to
3a5172c
Compare
There doesn't seem to be anything logged by the Kotlin FFI at this point. Where do you want to log to in (a potentially but not definitely non-Android) context? e.g. I'm happy to log if this is a solved problem already, but I'd also be happy to not. Last commit fixes up documentation. |
Good question. I think |
…droid being enabled
50d74f0
to
f0c67fc
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me. I'm mostly relying on @jmartinesp's review, but I'm happy to rubber stamp.
I am getting the following error:
in this generated code: // The fallback Jna cleaner, which is available for both Android, and the JVM.
private class UniffiJnaCleaner : UniffiCleaner {
private val cleaner = com.sun.jna.internal.Cleaner.getCleaner()
override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable =
UniffiJnaCleanable(cleaner.register(value, cleanUpTask))
}
private class UniffiJnaCleanable(
private val cleanable: com.sun.jna.internal.Cleaner.Cleanable,
) : UniffiCleaner.Cleanable {
override fun clean() = cleanable.clean()
} I have set |
JNA's EDIT: I just realised this project sets a min version for JNA of I guess either the docs must be updated to change the new min version or the implementation must change. |
I previously had my classpath pointing at a JNA which came with Android Studio, which presumably was an older version (the filename had no version - just jna.jar). I then downloaded jna-5.14.0.jar and pointed at that, which got past my compiler errors but now I always get:
I'm not sure if this is just another example of "tests which rely on clocks etc are always going to fail" like a few others we have (I did make one feeble attempt at sleeping longer, but couldn't make it work), or there is something else going on, but now I just tend to run UniFFI with kotlin disabled :( |
// This makes a cleaner a better alternative to _not_ calling `destroy()` as | ||
// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` | ||
// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner | ||
// thread may be starved, and the app will leak memory. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi there 👋
I'm looking to use UniFFI for a library which will be used in Android. The paragraph here gave me pause: will an application actually leak memory in an unrecoverable way, or will it "just" hold on to the memory longer than necessary?
I'm asking since the preconditions seem to be that drop
is slow or that it takes a long time to cleanup all the objects. That makes me think that the app will be slower than ideal, but that it will still (eventually) free the memory?
Even if the phone runs out of RAM, the OS will eventually clean things up as mentioned further up in the comment.
What am I missing here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm far far from an expert in Kotlin, but my understanding of the situation is that the memory held by the Rust object will not be free'd until the generated object's generated destroy()
method is called. However, the java garbage collector is both non-deterministic and makes no real attempt to keep memory usage low. The "modern" way of cleaning stuff up is apparently via this "cleaner" mechanism, but even this isn't perfect as it runs on its own thread and nothing guarantees that thread doesn't get starved. In that situation you will have what looks very much like a leak even though it's not really a leak - there's nothing stopping these things getting collected, it's just that they have not been.
Firefox for Android doesn't really exercise much of this - the objects we use tend to be long lived and small in number, whereas most data is stored in records which doesn't have this consideration as records don't have an "attached" Rust object.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @mhammond, thanks a lot for the quick reply!
I'm far far from an expert in Kotlin,
Me neither, for the record 😅 I learned Java some 20 years ago and I broadly hope Kotlin is a nicer version of that.
In that situation you will have what looks very much like a leak even though it's not really a leak - there's nothing stopping these things getting collected, it's just that they have not been.
That makes sense! Perhaps it's just a small matter of terminology: I'm used to thinking of a leak as "ups, I threw away the pointer!" and as a result, the allocation cannot be freed.
The indeterministic behavior of a garbage collector is something else for me — even if it results in the program using more memory than intended.
I guess a reference cycle in Rust also falls under the same category of leaks now that I think of it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense! Perhaps it's just a small matter of terminology: I'm used to thinking of a leak as "ups, I threw away the pointer!" and as a result, the allocation cannot be freed.
The indeterministic behavior of a garbage collector is something else for me — even if it results in the program using more memory than intended.
Me too! However, our Android engineers are very vocal in treating it far more seriously than that. I can only assume that's because so many bugs in our Android apps are due to our processes being killed due to memory pressure - I guess Android doesn't simply run a GC to try and reclaim memory before unilaterally killing it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see... I actually work in Android, but in the Rust part, so I don't really know anything about how apps work. But I can see how people will want stern warnings about this when it leads to the app being killed at random times.
Thanks for clearing it up for me!
This PR adds:
java.lang.ref.Cleaner
to perform cleanup of Rust Arcs shortly after kotlin Garbage Collection.It leaves intact the
AutoCloseable
infrastructure previously used, so as to allow developers the choice ofSome further work may be necessary:
Disposable
in favour ofjava.lang.AutoCloseable
.AutoCloseable
behind a[Traits=]
trait declaration.For ease of review, and backwards compatibility, I'd suggest keeping these separate from this PR.