Fix feed OOM: lazy image loading, inView gating, WebKit memory tuning

- NoteContent: remove ImageGrid from inline mode — images now only load
  when note is inView (via mediaOnly), stopping runaway scrolling leak
- NoteCard: content-visibility:auto to skip layout/paint for off-screen
  cards; inView-gated media, NoteActions, NIP-05 verification
- useInView: new IntersectionObserver hook with 300px rootMargin
- useProfile: MAX_PROFILE_CONCURRENT=8 throttle with fetch queue
- useReplyCount/useZapCount/useReactions: enabled param, throttled queues
- feed.ts: MAX_FEED_SIZE 200→30, live sub disabled (pendingNotes pattern),
  250ms batch debounce on live events
- core.ts: MAX_CONCURRENT_FETCHES=25 global NDK cap, fetchWithTimeout
  uses subscribe+stop instead of fetchEvents (no zombie subscriptions)
- lib.rs: HardwareAccelerationPolicy::Never + CacheModel::DocumentViewer
- main.rs: WEBKIT_DISABLE_COMPOSITING_MODE=1 for Linux
- relay/db.rs: TTL eviction + 5000 event cap
- feedDiagnostics.ts: file-flushing diag log survives crashes
This commit is contained in:
Jure
2026-04-15 20:36:14 +02:00
parent 018ee0e0f3
commit 0894389fe0
20 changed files with 540 additions and 105 deletions
+8 -1
View File
@@ -441,13 +441,20 @@ pub fn run() {
{
let main_window = app.get_webview_window("main").unwrap();
main_window.with_webview(|webview| {
use webkit2gtk::{SettingsExt, WebViewExt};
use webkit2gtk::{CacheModel, SettingsExt, WebContextExt, WebViewExt};
let wv = webview.inner();
if let Some(settings) = wv.settings() {
settings.set_hardware_acceleration_policy(
webkit2gtk::HardwareAccelerationPolicy::Never,
);
}
// Minimize WebKit's in-memory content cache (decoded images, scripts, etc.)
// Default is WebBrowser which caches aggressively. DocumentViewer is the
// minimum: no back/forward page cache, smallest memory footprint.
// This is safe for Vega — it's a single-page app, never navigates between pages.
if let Some(ctx) = wv.context() {
ctx.set_cache_model(CacheModel::DocumentViewer);
}
}).ok();
}
+6
View File
@@ -7,6 +7,12 @@ fn main() {
#[cfg(target_os = "linux")]
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
// Required on Linux with large RAM/swap: WebKitGTK compositor pre-allocates
// ~25% of total virtual memory (RAM+swap) for its tile cache. On a 14GB RAM +
// 19GB swap system this is ~4 GB, filling all RAM and freezing the machine.
// Software rendering is slower but memory-safe. Fix: reduce swap or implement
// virtual scrolling (fewer compositor layers).
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
vega_lib::run()
+52
View File
@@ -3,6 +3,14 @@ use crate::relay::filter::Filter;
use rusqlite::{params, Connection};
use std::path::Path;
/// Keep at most this many text notes (kind 1) in the local relay cache.
/// Older notes beyond this limit are evicted on startup to bound memory usage.
const MAX_KIND1_EVENTS: usize = 5_000;
/// Delete text notes (kind 1) older than this many seconds on startup.
/// 7 days — remote relays have anything older.
const KIND1_TTL_SECS: i64 = 7 * 24 * 3600;
pub fn open_relay_db(data_dir: &Path) -> rusqlite::Result<Connection> {
std::fs::create_dir_all(data_dir).ok();
let path = data_dir.join("relay.db");
@@ -34,9 +42,53 @@ pub fn open_relay_db(data_dir: &Path) -> rusqlite::Result<Connection> {
CREATE INDEX IF NOT EXISTS idx_tags_name_value ON event_tags(tag_name, tag_value);
CREATE INDEX IF NOT EXISTS idx_tags_event ON event_tags(event_id);",
)?;
evict_old_events(&conn)?;
Ok(conn)
}
/// Remove stale text notes on startup to keep the relay cache bounded.
///
/// Two passes:
/// 1. Delete all kind-1 events older than KIND1_TTL_SECS (7 days).
/// 2. If more than MAX_KIND1_EVENTS remain, delete the oldest ones beyond that cap.
///
/// Other kinds (profiles, contact lists, etc.) are not evicted — they are
/// replaceable/parameterized-replaceable and stay small by design.
fn evict_old_events(conn: &Connection) -> rusqlite::Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// Pass 1: TTL — delete kind-1 events older than 7 days
let cutoff = now - KIND1_TTL_SECS;
conn.execute(
"DELETE FROM events WHERE kind = 1 AND created_at < ?1",
params![cutoff],
)?;
// Pass 2: count cap — keep only the most recent MAX_KIND1_EVENTS kind-1 events
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 1",
[],
|row| row.get(0),
)?;
if count > MAX_KIND1_EVENTS as i64 {
conn.execute(
"DELETE FROM events WHERE kind = 1 AND id NOT IN (
SELECT id FROM events WHERE kind = 1
ORDER BY created_at DESC LIMIT ?1
)",
params![MAX_KIND1_EVENTS as i64],
)?;
}
Ok(())
}
/// Store an event. Returns true if the event was newly inserted, false if it already existed.
/// Handles replaceable (kind 0/3/10000-19999) and parameterized replaceable (30000-39999) events.
pub fn store_event(conn: &Connection, event: &Event, raw: &str) -> rusqlite::Result<bool> {