[AC-152] Fixes moz ads telemetry UniFFI callback leak - #7520
Conversation
| pub struct MARSTransport<T: Telemetry> { | ||
| http_cache: Option<HttpCache>, | ||
| telemetry: T, | ||
| telemetry: Option<T>, |
There was a problem hiding this comment.
I'm on board with dropping the caller-provided telemetry callback, but instead of Option<T>, could we default to a NoopTelemetry implementation?
My concern with Option<T> is that it pushes the "is telemetry configured?" check out to every call site and if we end up needing telemetry in more places, that cost multiplies. It's a bit like the log!() macro: having to write if tracing::is_configured() { log!() } everywhere would be pretty annoying, and I'd argue that's the telemetry layer's job to handle, not the caller's.
There's also a longer-term angle: we may want internal telemetry eventually, either by using Glean directly inside a-s components or if we migrate to a different system. A no-op default gives us a seam for that without touching call sites.
If we do want to keep the Option internally, one option is a thin telemetry wrapper that holds the Option<Box<dyn Telemetry>> (or a reference to the trait object) and exposes the same infallible API, so the branching lives in one place.
There was a problem hiding this comment.
If we push the concept of the telemetry wrapper (just as a brain teaser not something I think we need) that could be something like:
struct Telemetry {
layers: Mutex<HashMap<String, Arc<dyn TelemetryLayer>>>,
}
impl Telemetry {
fn new() -> Self {
Self { layers: Mutex::new(HashMap::new()) }
}
// builder-style so it can be chained in `new`
fn add_layer(self, key: &str, layer: Arc<dyn TelemetryLayer>) -> Self {
self.layers.lock().unwrap().insert(key.to_string(), layer);
self
}
fn remove_layer(&self, key: &str) {
self.layers.lock().unwrap().remove(key);
}
fn record(&self, event: &Event) {
for layer in self.layers.lock().unwrap().values() {
layer.record(event);
}
}
}
struct SomeClient {
telemetry: Telemetry,
}
impl SomeClient {
fn new(glean_telemetry: Arc<dyn TelemetryLayer>) -> Self {
let telemetry = Telemetry::new()
.add_layer("some", Arc::new(SomeInternalTelemetry))
.add_layer("log", Arc::new(LoggerTelemetry))
.add_layer("glean", glean_telemetry);
Self { telemetry }
}
fn shutdown(&self) {
self.telemetry.remove_layer("glean");
}
}There was a problem hiding this comment.
Sure, I mention something similar in the text of the post- mostly my lean towards doing it this way was avoiding mutexes, but that's not too important. The layering makes sense. I like the hashmap solution here (though probably not a string) but definitely not needed until we have such internal telemetry.
There was a problem hiding this comment.
Oh yeah good catch, I think we canremove the Mutex and just use mut there!
So for now something like:
fn shutdown(&mut self) {
self.telemetry = NoopTelemetry;
}There was a problem hiding this comment.
Well I do think we end up with a bit of a refactor here either way. For instance: MARSTransport<T: Telemetry> (and all the other calls) have a generic T (referring to the telemetry type) rather then a pointer so it can't be substituted for another T. So that strategy would require changing all our generics for pointers. Alternatively, if we put the equivalent function inside MozAdsTelemetryWrapper, we would need to add a mutex.
So we need to do one of:
- refactor the uses of
<T: Telemetry>to useBox<dyn Telemetry>instead, then we can do inMARSTransport(etc):
fn shutdown(&mut self) {
self.telemetry = MozAdsTelemetryWrapper::noop();
}
- put a mutex inside of
MozAdsTelemetryWrapper. Then, inMozAdsTelemetryWrapperwe can do something like:
fn shutdown(&mut self) {
let mut inner = inner.lock();
self.inner = MozAdsTelemetryWrapper::noop();
}
- Implement
TryTelemetryandtry_record()forOption<T: Telemetry>instead, to keep the Option as it is in the PR now, but we avoid needing to doif let Some(xxx)and all the extra calls as it can be handled intry_record. But this is a bit of a weird pattern and one I don't prefer.
I'm totally happy to use mutexes here (2), as we are already using them elsewhere, and the work here seems 90% synchronous anyway. I was just hoping to avoid it if it all other things were equal.
There was a problem hiding this comment.
Actually, with the understanding that we may be doing multiple layers of telemetry in the future, that adds to the reasoning to use mutexes/strategy 2.
There was a problem hiding this comment.
Oh yeah you are totally right! Well, Mutex, Box dyn or wrapper struct, I feel like all of them sounds okay as long as the implementation details of telemetry stuff don't leak into the rest of the code.
| pub fn shutdown_db(&mut self) -> Result<(), rusqlite::Error> { | ||
| if let Some(cache) = self.http_cache.take() { | ||
| cache.shutdown()?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
There was a problem hiding this comment.
nit: this feels similar to https://doc.rust-lang.org/std/ops/trait.Drop.html
Maybe we could have some Shutdown trait, that could make things clearer?
There was a problem hiding this comment.
I was matching an existing a-s pattern for shutdowns of sqlite here (w.r.t recursive shutdowns) but if this worked on drop and no phabricator change is required (or at least, as a backup), I agree that would certainly be interesting and could be good to do. I will try based on convo below.
I've no objection to turning this into a trait so happy to do so.
There was a problem hiding this comment.
Drop might not be suitableas we can't control the GC :'(
There was a problem hiding this comment.
Or at least we might need an exposed FFI drop / shutdown function
There was a problem hiding this comment.
Just curious but what would happen if we drop the client in HNT? Like if we do something like delete AdsClient.instance in JS code. Would that call Drop trait?
A risk that I anticipate is we can "shutdown" the client and then we get panic when we try to call it back whereas if we could just drop it there would be less risk of making a mistake.
In theory this should not happen because surface code would be careful and shutdown only on exit but better safe than sorry I guess?
…ttps://github.com/mozilla/application-services into AC-152-Fixes-MozAdsTelemetry-UniFFI-callback-leak
|
Revised:
Some thoughts:
I agree. One thing we could consider doing is actually ensuring |
|
So I would say regarding the "panic risk":
|
| // This replaces it with a `NoopMozAdsTelemetry` call, meaning future calls will be noops. | ||
| fn shutdown(&self) { | ||
| let mut inner = self.inner.lock(); | ||
| *inner = Arc::new(NoopMozAdsTelemetry); |
There was a problem hiding this comment.
this is dropping the old inner, which is a uniffi call and quite possibly a call into JS - another option would be to use, say, an Option - eg, Arc<RwLock<Option<Arc<dyn MozAdsTelemetry>>>> (which is still a mouthful), shutdown is something like let _dropped = self.inner.write().take(); (which should drop the lock before inner is actually dropped), and record then needs something like:
let Some(inner) = self.inner.read().clone() else {
return;
};
The risk with holding the lock while running foreign code is that somehow that code makes a call which tries to access the same lock, which will deadlock. That's probably unlikely though? There is also an argument that Option better reflects the semantics rather than swapping the noop one out though, and it looks like you might be able to kill NoopMozAdsTelemetry entirely?
just some driveby food for thought
There was a problem hiding this comment.
This makes good sense to me. Implemented + tested + still works OK.
|
|
||
| ### Ads-Client | ||
|
|
||
| - Add `AdsClient::shutdown_client()`, which closes the database connection early so it happens before Firefox Desktop's late-write shutdown barrier rather than during GC. In addition, this drops all held UniFFI callbacks (held in the MozAdsTelemetryWrapper) to avoid crash on a Firefox Desktop quit. |
There was a problem hiding this comment.
| - Add `AdsClient::shutdown_client()`, which closes the database connection early so it happens before Firefox Desktop's late-write shutdown barrier rather than during GC. In addition, this drops all held UniFFI callbacks (held in the MozAdsTelemetryWrapper) to avoid crash on a Firefox Desktop quit. | |
| - Add `AdsClient::shutdownt()`, which closes the database connection early so it happens before Firefox Desktop's late-write shutdown barrier rather than during GC. In addition, this drops all held UniFFI callbacks (held in the MozAdsTelemetryWrapper) to avoid crash on a Firefox Desktop quit. |
small typo I think?
There was a problem hiding this comment.
oh no okay this is shutdown for FFI and shutdown_client for AdsClient
| // Should be used only when dropping the ads client, this may be extended to drop more things. | ||
| pub fn shutdown_client(&mut self) -> Result<(), rusqlite::Error> { | ||
| // Shutdown DB | ||
| self.client.shutdown_db()?; |
There was a problem hiding this comment.
If that first call fail, the second one will never run.
let db_result = self.client.shutdown_db();
self.telemetry.shutdown();
db_resultThere was a problem hiding this comment.
Also I am not a big fan of comments that describe exactly the code 🤔 //Shutdown DB
And maybe it should be self.telemetry.drop() as you mention "Drop telemetry" in the comment but actually do telemetry.shutdown()
There was a problem hiding this comment.
Good call, I've reordered them for this reason
| // Allows the ads-client to unload some references and prepare for a safe shutdown. | ||
| // Other methods should not be called after this one. | ||
| #[uniffi::method()] | ||
| pub fn shutdown(&self) -> AdsClientApiResult<()> { |
There was a problem hiding this comment.
What happens in term of idempotency if you were to call that method twice? Would the second one fail?
There was a problem hiding this comment.
As it stands, its fine if it runs twice. But I think until we are sure that we want things to work after the call (eg: with regards to future things we need to offload), we should not make that promise yet
This provides the
shutdownfunction for addressing the crash on early ads-client -> HNT builds. When quit, we get:This requires a surface to deload several hanging parts of ads-client, specifically:
We provide such a function here, for use in upcoming future m-c PR to use on shutdown.
Notes
MozAdsTelemetryWrapper, or alternatively creating a new trait likeTryTelemetrybelow that optionally record, that gives a new method toOption<dyn Telemetry>, and swapping all calls to useTryTelemetryinstead. I prefer the explicit usage as shown in the PR, withif let Some(xxx)but not tied to it if we prefer one of these.Pull Request checklist
[ci full]to the PR title.