Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@fontsource-variable/nunito": "5.2.7",
"@fontsource/space-mono": "5.2.9",
"@lottiefiles/dotlottie-react": "^0.12.0",
"@lottiefiles/dotlottie-web": "0.40.1",
"@noble/hashes": "^2.2.0",
"@phosphor-icons/react": "^2.1.10",
"@sableclient/twemoji-font": "^1.0.4",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 36 additions & 21 deletions src-tauri/src/network/media_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ use tauri::{
};

mod crypto;
mod lane;
mod response;
mod session;

use crypto::EncryptionStore;
use lane::{LanePermit, LifoLane};
use response::{
apply_cors_headers, error_response, ok_response, read_full, serve_range, serve_range_memory,
session_unavailable_response, sniff_image_content_type,
Expand All @@ -27,10 +29,7 @@ use tauri_plugin_http::reqwest::{
header::{AUTHORIZATION, CONTENT_TYPE},
Client, Url,
};
use tokio::{
sync::{Mutex as AsyncMutex, Semaphore},
time::Instant,
};
use tokio::{sync::Mutex as AsyncMutex, time::Instant};

pub const MEDIA_URI_SCHEME: &str = "sable-media";
const MEDIA_SESSION_MARKER: &str = "__sable_media_session";
Expand Down Expand Up @@ -60,8 +59,8 @@ pub struct MediaSessionState {
session_store: SessionStore,
encryption: EncryptionStore,
client: OnceLock<Client>,
thumbnail_semaphore: Semaphore,
download_semaphore: Semaphore,
thumbnail_lane: LifoLane,
download_lane: LifoLane,
cache_miss_gates: Mutex<HashMap<String, Weak<AsyncMutex<Option<FetchResult>>>>>,
negative_cache: Mutex<HashMap<String, (StatusCode, Instant)>>,
}
Expand All @@ -72,8 +71,8 @@ impl Default for MediaSessionState {
session_store: SessionStore::default(),
encryption: EncryptionStore::default(),
client: OnceLock::new(),
thumbnail_semaphore: Semaphore::new(MAX_CONCURRENT_THUMBNAIL_REQUESTS),
download_semaphore: Semaphore::new(MAX_CONCURRENT_DOWNLOAD_REQUESTS),
thumbnail_lane: LifoLane::new(MAX_CONCURRENT_THUMBNAIL_REQUESTS),
download_lane: LifoLane::new(MAX_CONCURRENT_DOWNLOAD_REQUESTS),
cache_miss_gates: Mutex::new(HashMap::new()),
negative_cache: Mutex::new(HashMap::new()),
}
Expand Down Expand Up @@ -488,23 +487,29 @@ async fn ensure_cached_with_limits(
}

// Thumbnails queue separately so a few large downloads cannot stall a painting timeline.
async fn acquire_lane<'a>(
state: &'a MediaSessionState,
media_url: &Url,
) -> Result<tokio::sync::SemaphorePermit<'a>, StatusCode> {
let semaphore = if is_thumbnail_request(media_url) {
&state.thumbnail_semaphore
async fn acquire_lane<'a>(state: &'a MediaSessionState, media_url: &Url) -> LanePermit<'a> {
let lane = if is_thumbnail_request(media_url) {
&state.thumbnail_lane
} else {
&state.download_semaphore
&state.download_lane
};
semaphore
.acquire()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
lane.acquire().await
}

fn is_thumbnail_request(media_url: &Url) -> bool {
media_url.path().contains("/thumbnail/")
let Some(segments) = media_url.path_segments() else {
return false;
};
let mut after_media = segments.skip_while(|segment| *segment != "media").skip(1);
match after_media.next() {
// The legacy endpoints carry a version segment before the action.
Some(segment) if is_media_api_version(segment) => after_media.next() == Some("thumbnail"),
segment => segment == Some("thumbnail"),
}
}

fn is_media_api_version(segment: &str) -> bool {
matches!(segment, "v1" | "v3" | "r0")
}

#[allow(clippy::too_many_arguments)]
Expand All @@ -521,7 +526,7 @@ async fn fetch_and_cache(
max_persistent_cache_bytes: u64,
max_temp_cache_bytes: u64,
) -> Result<(String, Option<Arc<Vec<u8>>>, PathBuf), StatusCode> {
let permit = acquire_lane(state, &media_url).await?;
let permit = acquire_lane(state, &media_url).await;

let mut upstream = state
.client()
Expand Down Expand Up @@ -1408,6 +1413,16 @@ mod tests {
.unwrap();
assert!(super::is_thumbnail_request(&thumbnail));
assert!(!super::is_thumbnail_request(&download));

let legacy =
super::Url::parse("https://matrix.example.org/_matrix/media/v3/thumbnail/x/y").unwrap();
assert!(super::is_thumbnail_request(&legacy));

// A media id spelled "thumbnail" is still a download.
let lookalike =
super::Url::parse("https://matrix.example.org/_matrix/media/v3/download/x/thumbnail")
.unwrap();
assert!(!super::is_thumbnail_request(&lookalike));
}

#[test]
Expand Down
101 changes: 101 additions & 0 deletions src-tauri/src/network/media_protocol/lane.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use std::sync::{Mutex, MutexGuard, PoisonError};

use tokio::sync::oneshot;

/// Hands a freed slot to the newest waiter. Media is requested for what is on screen, so
/// the latest request is the one being waited on; `Semaphore` is fair, which buries a
/// just-opened picker behind the avatar backlog of a member list that started first.
pub(super) struct LifoLane {
inner: Mutex<LaneInner>,
}

struct LaneInner {
available: usize,
waiters: Vec<oneshot::Sender<()>>,
}

impl LifoLane {
pub(super) fn new(permits: usize) -> Self {
Self {
inner: Mutex::new(LaneInner {
available: permits,
waiters: Vec::new(),
}),
}
}

fn lock(&self) -> MutexGuard<'_, LaneInner> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}

pub(super) async fn acquire(&self) -> LanePermit<'_> {
let receiver = {
let mut inner = self.lock();
if inner.available > 0 {
inner.available -= 1;
return LanePermit { lane: self };
}
let (sender, receiver) = oneshot::channel();
inner.waiters.push(sender);
receiver
};

let _ = receiver.await;
LanePermit { lane: self }
}

fn release(&self) {
let mut inner = self.lock();
while let Some(waiter) = inner.waiters.pop() {
// A waiter that went away passes its slot to the next one down.
if waiter.send(()).is_ok() {
return;
}
}
inner.available += 1;
}
}

pub(super) struct LanePermit<'a> {
lane: &'a LifoLane,
}

impl Drop for LanePermit<'_> {
fn drop(&mut self) {
self.lane.release();
}
}

#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};

use super::LifoLane;

#[tokio::test]
async fn lane_serves_the_newest_waiter_first() {
let lane = Arc::new(LifoLane::new(1));
let started = Arc::new(Mutex::new(Vec::new()));

let held = lane.acquire().await;

let mut queued = Vec::new();
for id in 0..3 {
let lane = lane.clone();
let started = started.clone();
queued.push(tokio::spawn(async move {
let _permit = lane.acquire().await;
started.lock().unwrap().push(id);
}));
// Queue in a known order.
tokio::task::yield_now().await;
}

drop(held);
for task in queued {
task.await.unwrap();
}

assert_eq!(*started.lock().unwrap(), vec![2, 1, 0]);
}
}
2 changes: 1 addition & 1 deletion src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
},
};

const VIRTUAL_OVER_SCAN = 2;
const VIRTUAL_OVER_SCAN = 10;

type EmojiBoardProps = {
tab?: EmojiBoardTab;
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/emoji-board/components/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ export function CustomEmojiItem({
>
<MediaImage
loading="lazy"
sessionCache
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
info={image.info}
mimeType={image.info?.mimetype}
src={getPackImageSrc(mx, image, useAuthentication, saveStickerEmojiBandwidth, 32, 32)}
/>
Expand Down Expand Up @@ -143,8 +145,10 @@ export function StickerItem({
>
<MediaImage
loading="lazy"
sessionCache
className={css.StickerImg}
alt={image.body || image.shortcode}
info={image.info}
mimeType={image.info?.mimetype}
src={getPackImageSrc(mx, image, useAuthentication, saveStickerEmojiBandwidth, 125, 125)}
/>
Expand Down
65 changes: 65 additions & 0 deletions src/app/components/media/Image.tauri.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { stubObjectUrls } from '../../../test/objectUrlStub';

vi.mock('@tauri-apps/api/core', () => ({
isTauri: () => true,
}));

import { clearMediaObjectUrls } from '$utils/mediaObjectUrlCache';
import { Image } from './Image';

const wrapMediaUrl = (target: string) =>
`http://sable-media.localhost/${encodeURIComponent(target)}?__sable_media_cache=3`;

const THUMBNAIL_URL = wrapMediaUrl('https://example.org/_matrix/media/v3/thumbnail/a/b');
const DOWNLOAD_URL = wrapMediaUrl('https://example.org/_matrix/media/v3/download/a/b');
const LOOKALIKE_URL = wrapMediaUrl('https://example.org/_matrix/media/v3/download/a/thumbnail.png');

beforeEach(() => {
stubObjectUrls();
clearMediaObjectUrls();
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(new Response('image-bytes', { status: 200 }))
);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe('Image on Tauri', () => {
it('serves thumbnails from the session blob cache, fetching once across remounts', async () => {
const first = render(<Image src={THUMBNAIL_URL} alt="thumb" />);
await waitFor(() => expect(screen.getByAltText('thumb')).toHaveAttribute('src', 'blob:mock-1'));
first.unmount();

render(<Image src={THUMBNAIL_URL} alt="thumb-again" />);
expect(screen.getByAltText('thumb-again')).toHaveAttribute('src', 'blob:mock-1');
expect(fetch).toHaveBeenCalledTimes(1);
});

it('keeps full-size downloads on the native scheme path', () => {
render(<Image src={DOWNLOAD_URL} alt="full" />);

expect(screen.getByAltText('full')).toHaveAttribute('src', DOWNLOAD_URL);
expect(fetch).not.toHaveBeenCalled();
});

it('does not treat a download named "thumbnail" as a thumbnail', () => {
render(<Image src={LOOKALIKE_URL} alt="lookalike" />);

expect(screen.getByAltText('lookalike')).toHaveAttribute('src', LOOKALIKE_URL);
expect(fetch).not.toHaveBeenCalled();
});

it('session-caches small picker downloads and skips files over the size gate', async () => {
render(<Image src={DOWNLOAD_URL} alt="emote" sessionCache info={{ size: 20_000 }} />);
await waitFor(() => expect(screen.getByAltText('emote')).toHaveAttribute('src', 'blob:mock-1'));

render(<Image src={DOWNLOAD_URL} alt="big" sessionCache info={{ size: 5_000_000 }} />);
expect(screen.getByAltText('big')).toHaveAttribute('src', DOWNLOAD_URL);
});
});
Loading
Loading