mirror of
https://github.com/hoornet/vega.git
synced 2026-07-24 09:18:11 -07:00
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:
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user