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
+73 -1
View File
@@ -4,9 +4,14 @@
* Data stored in localStorage under "wrystr_feed_diag".
* View in console: JSON.parse(localStorage.getItem("wrystr_feed_diag"))
* Or open DevTools and call: window.__feedDiag()
*
* File log: ~/vega-diag.log — written every 2s, survives WebKit crashes and hard reboots.
* Inspect after crash: tail -100 ~/vega-diag.log | python3 -c "import sys,json;[print(json.dumps(json.loads(l),indent=2)) for l in sys.stdin]"
*/
import { getNDK } from "./nostr/core";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { homeDir } from "@tauri-apps/api/path";
import { getNDK, getActiveFetchCount } from "./nostr/core";
import { debug } from "./debug";
const isDev = import.meta.env.DEV;
@@ -14,6 +19,70 @@ const isDev = import.meta.env.DEV;
const DIAG_KEY = "wrystr_feed_diag";
const MAX_ENTRIES = 200;
// ─── Disk-based diagnostic log ────────────────────────────────────────────────
// Writes JSON-lines to ~/vega-diag.log every 2s.
// Survives WebKit crashes and hard reboots — inspect after hang:
// tail -100 ~/vega-diag.log | python3 -c "import sys,json;[print(json.dumps(json.loads(l),indent=2)) for l in sys.stdin if l.strip()]"
const diagFileBuffer: string[] = [];
let diagFlushTimer: ReturnType<typeof setInterval> | null = null;
let diagLogPath: string | null = null;
async function getDiagLogPath(): Promise<string> {
if (!diagLogPath) {
try {
diagLogPath = (await homeDir()) + "/vega-diag.log";
} catch {
diagLogPath = "/tmp/vega-diag.log";
}
}
return diagLogPath;
}
async function flushDiagBuffer() {
if (diagFileBuffer.length === 0) return;
const lines = diagFileBuffer.splice(0);
try {
const path = await getDiagLogPath();
await writeTextFile(path, lines.join("\n") + "\n", { append: true });
} catch { /* never crash the app on diag write failure */ }
}
/**
* Start periodic disk flushing and memory snapshots.
* Call once at app startup. Data written to ~/vega-diag.log every 2s.
*/
export function startDiagFileFlusher() {
if (diagFlushTimer) return;
// Write a session-start marker
const marker = { ts: Date.now(), t: "session_start", v: "vega-diag-v1" };
diagFileBuffer.push(JSON.stringify(marker));
// Flush immediately so data hits disk before any crash
flushDiagBuffer();
diagFlushTimer = setInterval(async () => {
// Memory snapshot
const mem = (performance as unknown as { memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number } }).memory;
const ndk = getNDK();
const relayCount = ndk.pool?.relays?.size ?? 0;
const connectedRelays = Array.from(ndk.pool?.relays?.values() ?? []).filter((r) => r.connected).length;
diagFileBuffer.push(JSON.stringify({
ts: Date.now(),
t: "mem",
heapMb: mem ? Math.round(mem.usedJSHeapSize / 1048576) : -1,
heapTotalMb: mem ? Math.round(mem.totalJSHeapSize / 1048576) : -1,
heapLimitMb: mem ? Math.round(mem.jsHeapSizeLimit / 1048576) : -1,
activeFetches: getActiveFetchCount(),
relays: `${connectedRelays}/${relayCount}`,
}));
await flushDiagBuffer();
}, 500); // 500ms — fast enough to capture pre-crash state
}
export interface DiagEntry {
ts: string; // ISO timestamp
action: string; // "global_fetch" | "follow_fetch" | "refresh_click" | "relay_state" | etc.
@@ -48,6 +117,9 @@ export function logDiag(entry: DiagEntry) {
log.push(entry);
saveLog(log);
// Also buffer to disk log (flushed every 2s by startDiagFileFlusher)
diagFileBuffer.push(JSON.stringify({ ...entry, _ms: Date.now() }));
// Also log to console with color coding
const style = entry.error
? "color: #ff4444; font-weight: bold"
+83 -20
View File
@@ -18,23 +18,86 @@ export const FEED_TIMEOUT = 8000; // 8s for feed fetches
export const THREAD_TIMEOUT = 5000; // 5s per thread round-trip
export const SINGLE_TIMEOUT = 5000; // 5s for single event lookups
const EMPTY_SET = new Set<NDKEvent>();
// ─── Active fetch counter + concurrency semaphore ──────────────────
let _activeFetchCount = 0;
/** Number of in-flight fetchWithTimeout calls (subscriptions currently open). */
export function getActiveFetchCount(): number { return _activeFetchCount; }
/** Fetch events with a timeout — returns empty set if relay hangs. */
export async function fetchWithTimeout(
// Hard cap on concurrent NDK subscriptions.
// Without this, rendering 200 cached notes triggers 400+ simultaneous subscriptions
// (useReplyCount + useZapCount per note), each receiving events from 7+ relays → OOM.
const MAX_CONCURRENT_FETCHES = 25;
const _fetchQueue: Array<() => void> = [];
function _runNextFetch() {
while (_fetchQueue.length > 0 && _activeFetchCount < MAX_CONCURRENT_FETCHES) {
const next = _fetchQueue.shift()!;
next();
}
}
/**
* Fetch events with explicit subscription lifecycle.
*
* IMPORTANT: Do NOT use instance.fetchEvents() here. fetchEvents() creates an
* NDK subscription internally that we cannot cancel if the timeout fires first.
* Abandoned subscriptions keep receiving relay data forever, leaking memory.
*
* This implementation uses subscribe() directly so we can call sub.stop() on
* both EOSE and timeout — guaranteeing no zombie subscriptions.
*
* Concurrency is capped at MAX_CONCURRENT_FETCHES. Excess calls queue and
* start as slots free up.
*/
export function fetchWithTimeout(
instance: NDK,
filter: NDKFilter,
timeoutMs: number,
relaySet?: NDKRelaySet,
): Promise<Set<NDKEvent>> {
const opts = {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
groupable: false, // Prevent NDK from batching/reusing subscriptions
};
const promise = relaySet
? instance.fetchEvents(filter, opts, relaySet)
: instance.fetchEvents(filter, opts);
return withTimeout(promise, timeoutMs, EMPTY_SET);
return new Promise((resolve) => {
const start = () => {
const events = new Set<NDKEvent>();
let settled = false;
_activeFetchCount++;
const finish = () => {
if (settled) return;
settled = true;
_activeFetchCount--;
clearTimeout(timer);
try { sub.stop(); } catch { /* ignore */ }
resolve(events);
_runNextFetch();
};
const sub = instance.subscribe(
filter,
{
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
groupable: false,
closeOnEose: true,
},
relaySet,
);
sub.on("event", (event: NDKEvent) => {
if (!settled) events.add(event);
});
sub.on("eose", finish);
const timer = setTimeout(() => {
debug.warn(`[Vega] Fetch timed out after ${timeoutMs}ms (collected ${events.size} events, queue: ${_fetchQueue.length})`);
finish();
}, timeoutMs);
};
if (_activeFetchCount < MAX_CONCURRENT_FETCHES) {
start();
} else {
_fetchQueue.push(start);
}
});
}
export const RELAY_STORAGE_KEY = "wrystr_relays";
@@ -101,7 +164,10 @@ export function getNDK(): NDK {
if (!ndk) {
ndk = new NDK({
explicitRelayUrls: getStoredRelayUrls(),
outboxRelayUrls: OUTBOX_RELAYS,
// outboxRelayUrls intentionally omitted — enabling NDK's outbox model causes
// it to discover and connect to every event author's preferred relays, ballooning
// the relay pool from 7 to 40+ and flooding startLiveFeed() with a firehose of
// events from all those relays simultaneously → OOM crash.
});
ndkCreatedAt = Date.now();
}
@@ -121,12 +187,9 @@ export async function resetNDK(): Promise<void> {
const oldInstance = ndk;
const oldSigner = oldInstance?.signer ?? null;
// Preserve all relay URLs (stored + outbox-discovered) before resetting
const oldRelayUrls = oldInstance?.pool?.relays
? Array.from(oldInstance.pool.relays.keys()).map(normalizeRelayUrl)
: [];
// Only preserve the stored relay URLs — do NOT preserve outbox-discovered relays.
// Outbox-discovered relays are the source of the relay pool explosion (7 → 40+).
const storedUrls = getStoredRelayUrls();
const allUrls = [...new Set([...storedUrls, ...oldRelayUrls])];
// Disconnect all relays on old instance
if (oldInstance?.pool?.relays) {
@@ -135,10 +198,10 @@ export async function resetNDK(): Promise<void> {
}
}
// Create fresh instance with all known relay URLs
// Create fresh instance with only the stored relay URLs
ndk = new NDK({
explicitRelayUrls: allUrls,
outboxRelayUrls: OUTBOX_RELAYS,
explicitRelayUrls: storedUrls,
// outboxRelayUrls intentionally omitted — see getNDK() comment
});
ndkCreatedAt = Date.now();