Merge perf updates and fixes from kerenl

from remote-tracking branches:
  * kernel/fix-soft-keyboard
  * kernel/composite-profiles-perf
  * kernel/fix-nav-flicker

kernelkind (8):
      Revert "fix: nav drawer shadow extends all the way vertically"
      chore(profiling): markup composite render path
      chore(tracy): repaint every frame
      feat(composite-cluster): do culling for pfps
      feat(mime-cache): upgrade UrlMimes
      feat(reactions): use ProfileKey when possible for performance
      fix(nav-drawer): shadow extends all the way vertically
      fix(thread): remove flicker on opening thread
This commit is contained in:
William Casarin
2025-10-27 10:26:13 -07:00
8 changed files with 418 additions and 96 deletions

View File

@@ -545,6 +545,7 @@ pub struct LatestTexture {
pub request_next_repaint: Option<SystemTime>,
}
#[profiling::function]
pub fn get_render_state<'a>(
ctx: &egui::Context,
images: &'a mut Images,

View File

@@ -4,23 +4,90 @@ use std::{
io::{Read, Write},
path::PathBuf,
sync::{Arc, RwLock},
time::{Duration, SystemTime},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use egui::TextBuffer;
use mime_guess::Mime;
use poll_promise::Promise;
use serde::{Deserialize, Serialize};
use tracing::trace;
use url::Url;
use crate::{Error, MediaCacheType};
const FILE_NAME: &str = "urls.bin";
const SAVE_INTERVAL: Duration = Duration::from_secs(60);
const MIME_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 7); // one week
const FAILURE_BACKOFF_BASE: Duration = Duration::from_secs(4);
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(60 * 60 * 6);
const FAILURE_BACKOFF_EXPONENT_LIMIT: u32 = 10;
type UrlsToMime = HashMap<String, String>;
type UrlsToMime = HashMap<String, StoredMimeEntry>;
#[derive(Clone, Serialize, Deserialize)]
struct StoredMimeEntry {
entry: MimeEntry,
last_updated_secs: u64,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
enum MimeEntry {
Mime(String),
Fail { count: u32 },
}
impl StoredMimeEntry {
fn new_mime(mime: String, last_updated: SystemTime) -> Self {
Self {
entry: MimeEntry::Mime(mime),
last_updated_secs: system_time_to_secs(last_updated),
}
}
fn new_failure(count: u32, last_updated: SystemTime) -> Self {
Self {
entry: MimeEntry::Fail { count },
last_updated_secs: system_time_to_secs(last_updated),
}
}
fn last_updated(&self) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(self.last_updated_secs)
}
fn expires_at(&self) -> SystemTime {
let ttl = match &self.entry {
MimeEntry::Mime(_) => MIME_TTL,
MimeEntry::Fail { count } => failure_backoff_duration(*count),
};
self.last_updated()
.checked_add(ttl)
.unwrap_or(SystemTime::UNIX_EPOCH)
}
fn is_expired(&self, now: SystemTime) -> bool {
self.expires_at() <= now
}
fn failure_count(&self) -> Option<u32> {
match &self.entry {
MimeEntry::Fail { count } => Some(*count),
_ => None,
}
}
}
#[derive(Clone)]
struct CachedMime {
mime: Option<Mime>,
expires_at: SystemTime,
}
/// caches mime type for a URL. saves to disk on interval [`SAVE_INTERVAL`]
pub struct UrlCache {
last_saved: SystemTime,
last_pruned: SystemTime,
path: PathBuf,
cache: Arc<RwLock<UrlsToMime>>,
from_disk_promise: Option<Promise<Option<UrlsToMime>>>,
@@ -34,19 +101,29 @@ impl UrlCache {
pub fn new(path: PathBuf) -> Self {
Self {
last_saved: SystemTime::now(),
last_pruned: SystemTime::now(),
path: path.clone(),
cache: Default::default(),
from_disk_promise: Some(read_from_disk(path)),
}
}
pub fn get_type(&self, url: &str) -> Option<String> {
fn get_entry(&self, url: &str) -> Option<StoredMimeEntry> {
self.cache.read().ok()?.get(url).cloned()
}
pub fn set_type(&mut self, url: String, mime_type: String) {
fn set_entry(&mut self, url: String, entry: StoredMimeEntry) {
if url.is_empty() {
return;
}
if let Ok(mut locked_cache) = self.cache.write() {
locked_cache.insert(url, mime_type);
locked_cache.insert(url, entry);
}
}
fn remove(&mut self, url: &str) {
if let Ok(mut locked_cache) = self.cache.write() {
locked_cache.remove(url);
}
}
@@ -67,6 +144,13 @@ impl UrlCache {
self.last_saved = SystemTime::now();
}
}
if let Ok(cur_duration) = SystemTime::now().duration_since(self.last_pruned) {
if cur_duration >= SAVE_INTERVAL {
self.purge_expired(SystemTime::now());
self.last_pruned = SystemTime::now();
}
}
}
pub fn clear(&mut self) {
@@ -79,10 +163,22 @@ impl UrlCache {
});
}
}
fn purge_expired(&self, now: SystemTime) {
let cache = self.cache.clone();
std::thread::spawn(move || {
if let Ok(mut locked_cache) = cache.write() {
locked_cache.retain(|_, entry| !entry.is_expired(now));
}
});
}
}
fn merge_cache(cur_cache: Arc<RwLock<UrlsToMime>>, from_disk: UrlsToMime) {
fn merge_cache(cur_cache: Arc<RwLock<UrlsToMime>>, mut from_disk: UrlsToMime) {
std::thread::spawn(move || {
let now = SystemTime::now();
from_disk.retain(|_, entry| !entry.is_expired(now));
if let Ok(mut locked_cache) = cur_cache.write() {
locked_cache.extend(from_disk);
}
@@ -97,9 +193,28 @@ fn read_from_disk(path: PathBuf) -> Promise<Option<UrlsToMime>> {
let mut file = File::open(path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let data: UrlsToMime =
bincode::deserialize(&buffer).map_err(|e| Error::Generic(e.to_string()))?;
Ok(data)
if buffer.is_empty() {
return Ok(Default::default());
}
match bincode::deserialize::<UrlsToMime>(&buffer) {
Ok(data) => {
trace!("Got {} mime entries", data.len());
Ok(data)
}
Err(err) => {
tracing::debug!("Unable to deserialize UrlMimes with new format: {err}. Attempting legacy fallback.");
let legacy: HashMap<String, String> =
bincode::deserialize(&buffer).map_err(|e| Error::Generic(e.to_string()))?;
trace!("legacy fallback has {} entries", legacy.len());
let now = SystemTime::now();
let migrated = legacy
.into_iter()
.map(|(url, mime)| (url, StoredMimeEntry::new_mime(mime, now)))
.collect();
Ok(migrated)
}
}
})();
match result {
@@ -119,12 +234,13 @@ fn save_to_disk(path: PathBuf, cache: Arc<RwLock<UrlsToMime>>) {
let result: Result<(), Error> = (|| {
if let Ok(cache) = cache.read() {
let cache = &*cache;
let num_items = cache.len();
let encoded =
bincode::serialize(cache).map_err(|e| Error::Generic(e.to_string()))?;
let mut file = File::create(&path)?;
file.write_all(&encoded)?;
file.sync_all()?;
tracing::debug!("Saved UrlCache to disk.");
tracing::debug!("Saved UrlCache with {num_items} mimes to disk.");
Ok(())
} else {
Err(Error::Generic(
@@ -139,6 +255,26 @@ fn save_to_disk(path: PathBuf, cache: Arc<RwLock<UrlsToMime>>) {
});
}
fn system_time_to_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs()
}
fn failure_backoff_duration(count: u32) -> Duration {
if count == 0 {
return FAILURE_BACKOFF_BASE;
}
let exponent = count.saturating_sub(1).min(FAILURE_BACKOFF_EXPONENT_LIMIT);
let base_secs = FAILURE_BACKOFF_BASE.as_secs().max(1);
let multiplier = 1u64 << exponent;
let delay_secs = base_secs.saturating_mul(multiplier);
let max_secs = FAILURE_BACKOFF_MAX.as_secs();
Duration::from_secs(delay_secs.min(max_secs))
}
fn ehttp_get_mime_type(url: &str, sender: poll_promise::Sender<MimeResult>) {
let request = ehttp::Request::head(url);
@@ -181,6 +317,7 @@ fn extract_mime_type(content_type: &str) -> &str {
pub struct UrlMimes {
pub cache: UrlCache,
in_flight: HashMap<String, Promise<MimeResult>>,
mime_cache: HashMap<String, CachedMime>,
}
impl UrlMimes {
@@ -188,41 +325,169 @@ impl UrlMimes {
Self {
cache: url_cache,
in_flight: Default::default(),
mime_cache: Default::default(),
}
}
pub fn get(&mut self, url: &str) -> Option<String> {
if let Some(mime_type) = self.cache.get_type(url) {
Some(mime_type)
} else if let Some(promise) = self.in_flight.get_mut(url) {
if let Some(mime_result) = promise.ready_mut() {
match mime_result {
Ok(mime_type) => {
let mime_type = mime_type.take();
self.cache.set_type(url.to_owned(), mime_type.clone());
self.in_flight.remove(url);
Some(mime_type)
}
Err(HttpError::HttpFailure) => {
// allow retrying
//self.in_flight.remove(url);
None
}
Err(HttpError::MissingHeader) => {
// response was malformed, don't retry
None
}
}
} else {
None
pub fn get_or_fetch(&mut self, url: &str) -> Option<&Mime> {
let now = SystemTime::now();
if let Some(cached) = self.mime_cache.get(url) {
if cached.expires_at > now {
return self
.mime_cache
.get(url)
.and_then(|cached| cached.mime.as_ref());
}
tracing::trace!("mime {:?} at url {url} has expired", cached.mime);
self.mime_cache.remove(url);
}
let stored_entry = self.cache.get_entry(url);
let previous_failure_count = stored_entry
.as_ref()
.and_then(|entry| entry.failure_count())
.unwrap_or(0);
if let Some(entry) = stored_entry.as_ref() {
if !entry.is_expired(now) {
return match &entry.entry {
MimeEntry::Mime(mime_string) => match mime_string.parse::<Mime>() {
Ok(mime) => {
let expires_at = entry.expires_at();
trace!("inserted {mime:?} in mime cache for {url}");
self.mime_cache.insert(
url.to_owned(),
CachedMime {
mime: Some(mime),
expires_at,
},
);
self.mime_cache
.get(url)
.and_then(|cached| cached.mime.as_ref())
}
Err(err) => {
tracing::warn!("Failed to parse mime '{mime_string}' for {url}: {err}");
self.record_failure(
url,
previous_failure_count.saturating_add(1),
SystemTime::now(),
);
None
}
},
MimeEntry::Fail { .. } => {
trace!("Read failure from storage for {url}, wrote None to cache");
let expires_at = entry.expires_at();
self.mime_cache.insert(
url.to_owned(),
CachedMime {
mime: None,
expires_at,
},
);
None
}
};
}
if !matches!(entry.entry, MimeEntry::Fail { count: _ }) {
self.cache.remove(url);
}
}
let Some(promise) = self.in_flight.get_mut(url) else {
if Url::parse(url).is_err() {
trace!("Found invalid url: {url}");
self.mime_cache.insert(
url.to_owned(),
CachedMime {
mime: None,
expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(u64::MAX / 2), // never expire...
},
);
}
} else {
let (sender, promise) = Promise::new();
ehttp_get_mime_type(url, sender);
self.in_flight.insert(url.to_owned(), promise);
None
return None;
};
let Ok(mime_type) = promise.ready_mut()? else {
self.in_flight.remove(url);
self.record_failure(
url,
previous_failure_count.saturating_add(1),
SystemTime::now(),
);
return None;
};
let mime_string = std::mem::take(mime_type);
self.in_flight.remove(url);
match mime_string.parse::<Mime>() {
Ok(mime) => {
let fetched_at = SystemTime::now();
let prev_entry = stored_entry;
let entry = StoredMimeEntry::new_mime(mime_string, fetched_at);
let expires_at = entry.expires_at();
if let Some(Some(failed_count)) = prev_entry.map(|p| {
if let MimeEntry::Fail { count } = p.entry {
Some(count)
} else {
None
}
}) {
trace!("found {mime:?} for {url}, inserting in cache & storage AFTER FAILING {failed_count} TIMES");
} else {
trace!("found {mime:?} for {url}, inserting in cache & storage");
}
self.cache.set_entry(url.to_owned(), entry);
self.mime_cache.insert(
url.to_owned(),
CachedMime {
mime: Some(mime),
expires_at,
},
);
self.mime_cache
.get(url)
.and_then(|cached| cached.mime.as_ref())
}
Err(err) => {
tracing::warn!("Unable to parse mime type returned for {url}: {err}");
self.record_failure(
url,
previous_failure_count.saturating_add(1),
SystemTime::now(),
);
None
}
}
}
fn record_failure(&mut self, url: &str, count: u32, timestamp: SystemTime) {
let count = count.max(1);
let entry = StoredMimeEntry::new_failure(count, timestamp);
let expires_at = entry.expires_at();
trace!(
"failed to get mime for {url} {count} times. next request in {:?}",
failure_backoff_duration(count)
);
self.cache.set_entry(url.to_owned(), entry);
self.mime_cache.insert(
url.to_owned(),
CachedMime {
mime: None,
expires_at,
},
);
}
}
#[derive(Debug)]
@@ -231,6 +496,7 @@ pub struct SupportedMimeType {
}
impl SupportedMimeType {
#[profiling::function]
pub fn from_extension(extension: &str) -> Result<Self, Error> {
if let Some(mime) = mime_guess::from_ext(extension)
.first()
@@ -257,11 +523,15 @@ impl SupportedMimeType {
}
pub fn to_cache_type(&self) -> MediaCacheType {
if self.mime == mime_guess::mime::IMAGE_GIF {
MediaCacheType::Gif
} else {
MediaCacheType::Image
}
mime_to_cache_type(&self.mime)
}
}
fn mime_to_cache_type(mime: &Mime) -> MediaCacheType {
if *mime == mime_guess::mime::IMAGE_GIF {
MediaCacheType::Gif
} else {
MediaCacheType::Image
}
}
@@ -269,8 +539,13 @@ fn is_mime_supported(mime: &mime_guess::Mime) -> bool {
mime.type_() == mime_guess::mime::IMAGE
}
#[profiling::function]
fn url_has_supported_mime(url: &str) -> MimeHostedAtUrl {
if let Ok(url) = Url::parse(url) {
let url = {
profiling::scope!("url parse");
Url::parse(url)
};
if let Ok(url) = url {
if let Some(mut path) = url.path_segments() {
if let Some(file_name) = path.next_back() {
if let Some(ext) = std::path::Path::new(file_name)
@@ -289,19 +564,18 @@ fn url_has_supported_mime(url: &str) -> MimeHostedAtUrl {
MimeHostedAtUrl::Maybe
}
#[profiling::function]
pub fn supported_mime_hosted_at_url(urls: &mut UrlMimes, url: &str) -> Option<MediaCacheType> {
match url_has_supported_mime(url) {
MimeHostedAtUrl::Yes(cache_type) => Some(cache_type),
MimeHostedAtUrl::Maybe => urls
.get(url)
.and_then(|s| s.parse::<mime_guess::mime::Mime>().ok())
.and_then(|mime: mime_guess::mime::Mime| {
SupportedMimeType::from_mime(mime)
.ok()
.map(|s| s.to_cache_type())
}),
MimeHostedAtUrl::No => None,
}
let Some(mime) = urls.get_or_fetch(url) else {
return match url_has_supported_mime(url) {
MimeHostedAtUrl::Yes(media_cache_type) => Some(media_cache_type),
MimeHostedAtUrl::Maybe | MimeHostedAtUrl::No => None,
};
};
Some(mime)
.filter(|mime| is_mime_supported(mime))
.map(mime_to_cache_type)
}
enum MimeHostedAtUrl {

View File

@@ -301,29 +301,27 @@ impl Chrome {
// if the soft keyboard is open, shrink the chrome contents
let mut action: Option<ChromePanelAction> = None;
if keyboard_height == 0.0 {
action = self.panel(ctx, ui, keyboard_height);
} else {
// build a strip to carve out the soft keyboard inset
StripBuilder::new(ui)
.size(Size::remainder())
.size(Size::exact(keyboard_height))
.vertical(|mut strip| {
// the actual content, shifted up because of the soft keyboard
strip.cell(|ui| {
action = self.panel(ctx, ui, keyboard_height);
});
// the filler space taken up by the soft keyboard
strip.cell(|ui| {
// keyboard-visibility virtual keyboard
if virtual_keyboard && keyboard_height > 0.0 {
virtual_keyboard_ui(ui, ui.available_rect_before_wrap())
}
});
// build a strip to carve out the soft keyboard inset
let prev_spacing = ui.spacing().item_spacing;
ui.spacing_mut().item_spacing.y = 0.0;
StripBuilder::new(ui)
.size(Size::remainder())
.size(Size::exact(keyboard_height))
.vertical(|mut strip| {
// the actual content, shifted up because of the soft keyboard
strip.cell(|ui| {
action = self.panel(ctx, ui, keyboard_height);
});
}
// the filler space taken up by the soft keyboard
strip.cell(|ui| {
// keyboard-visibility virtual keyboard
if virtual_keyboard && keyboard_height > 0.0 {
virtual_keyboard_ui(ui, ui.available_rect_before_wrap())
}
});
});
ui.spacing_mut().item_spacing = prev_spacing;
// hovering virtual keyboard
if virtual_keyboard {
@@ -345,6 +343,11 @@ impl Chrome {
impl notedeck::App for Chrome {
fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> AppResponse {
#[cfg(feature = "tracy")]
{
ui.ctx().request_repaint();
}
if let Some(action) = self.show(ctx, ui) {
action.process(ctx, self, ui);
self.nav.close();

View File

@@ -370,6 +370,7 @@ mod tests {
reaction: Reaction {
reaction: "+".to_owned(),
sender: self.random_sender(),
sender_profilekey: None,
},
}))
}

View File

@@ -169,6 +169,11 @@ fn to_reaction<'a>(
created_at: reacted_to_note.created_at(),
};
let sender_profilekey = ndb
.get_profile_by_pubkey(txn, payload.note.pubkey())
.ok()
.and_then(|p| p.key());
Some(ReactionResponse {
fragment: ReactionFragment {
noteref_reacted_to,
@@ -176,6 +181,7 @@ fn to_reaction<'a>(
reaction: Reaction {
reaction: reaction.to_string(),
sender: Pubkey::new(*payload.note.pubkey()),
sender_profilekey,
},
},
pk: payload.note.pubkey(),

View File

@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, HashSet};
use enostr::Pubkey;
use nostrdb::ProfileKey;
use notedeck::NoteRef;
use crate::timeline::note_units::{CompositeKey, CompositeType, UnitKey};
@@ -275,6 +276,7 @@ impl ReactionFragment {
pub struct Reaction {
pub reaction: String, // can't use char because some emojis are 'grapheme clusters'
pub sender: Pubkey,
pub sender_profilekey: Option<ProfileKey>,
}
/// Represents a singular repost

View File

@@ -705,16 +705,25 @@ fn render_reaction_cluster(
underlying_note: &Note,
reaction: &ReactionUnit,
) -> RenderEntryResponse {
let profiles_to_show: Vec<ProfileEntry> = reaction
.reactions
.values()
.filter(|r| !mute.is_pk_muted(r.sender.bytes()))
.map(|r| &r.sender)
.map(|p| ProfileEntry {
record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(),
pk: p,
})
.collect();
let profiles_to_show: Vec<ProfileEntry> = {
profiling::scope!("vec profile entries");
reaction
.reactions
.values()
.filter(|r| !mute.is_pk_muted(r.sender.bytes()))
.map(|r| (&r.sender, r.sender_profilekey))
.map(|(p, key)| {
let record = if let Some(key) = key {
profiling::scope!("ndb by key");
note_context.ndb.get_profile_by_key(txn, key).ok()
} else {
profiling::scope!("ndb by pubkey");
note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok()
};
ProfileEntry { record, pk: p }
})
.collect()
};
render_composite_entry(
ui,
@@ -728,6 +737,7 @@ fn render_reaction_cluster(
}
#[allow(clippy::too_many_arguments)]
#[profiling::function]
fn render_composite_entry(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
@@ -772,6 +782,7 @@ fn render_composite_entry(
.show(ui, |ui| {
let show_label_newline = ui
.horizontal_wrapped(|ui| {
profiling::scope!("header");
let pfps_resp = ui
.allocate_ui_with_layout(
vec2(ui.available_width(), 32.0),
@@ -832,6 +843,7 @@ fn render_composite_entry(
.inner;
if let Some(desc) = show_label_newline {
profiling::scope!("description");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(48.0);
@@ -868,6 +880,7 @@ fn render_composite_entry(
RenderEntryResponse::Success(action)
}
#[profiling::function]
fn render_profiles(
ui: &mut egui::Ui,
profiles_to_show: Vec<ProfileEntry>,
@@ -895,16 +908,35 @@ fn render_profiles(
}
let resp = ui.horizontal(|ui| {
profiling::scope!("scroll area");
ScrollArea::horizontal()
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
let mut last_pfp_resp = None;
profiling::scope!("scroll area closure");
let clip_rect = ui.clip_rect();
let mut last_resp = None;
let mut rendered = false;
for entry in profiles_to_show {
let mut resp = ui.add(
&mut ProfilePic::from_profile_or_default(img_cache, entry.record.as_ref())
let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click());
let should_render = rect.intersects(clip_rect);
if !should_render {
if rendered {
break;
} else {
continue;
}
}
profiling::scope!("actual rendering individual pfp");
let mut widget =
ProfilePic::from_profile_or_default(img_cache, entry.record.as_ref())
.size(24.0)
.sense(Sense::click()),
);
.sense(Sense::click());
let mut resp = ui.put(rect, &mut widget);
rendered = true;
if let Some(record) = entry.record.as_ref() {
resp = resp.on_hover_ui_at_pointer(|ui| {
@@ -913,14 +945,14 @@ fn render_profiles(
});
}
last_pfp_resp = Some(resp.clone());
if resp.clicked() {
action = Some(NoteAction::Profile(*entry.pk))
action = Some(NoteAction::Profile(*entry.pk));
}
last_resp = Some(resp);
}
last_pfp_resp
last_resp
})
.inner
});

View File

@@ -144,9 +144,11 @@ fn render_pfp(
match cur_state.texture_state {
notedeck::TextureState::Pending => {
profiling::scope!("Render pending");
egui::InnerResponse::new(None, paint_circle(ui, ui_size, border, sense))
}
notedeck::TextureState::Error(e) => {
profiling::scope!("Render error");
let r = paint_circle(ui, ui_size, border, sense);
show_one_error_message(ui, &format!("Failed to fetch profile at url {url}: {e}"));
egui::InnerResponse::new(
@@ -159,6 +161,7 @@ fn render_pfp(
)
}
notedeck::TextureState::Loaded(textured_image) => {
profiling::scope!("Render loaded");
let texture_handle =
ensure_latest_texture(ui, url, cur_state.gifs, textured_image, animation_mode);