From 77e4c84c45bdce7d899783a176f2cb95f41536e0 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 03:51:42 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20live-status=20poller=20=E2=80=94=20evict?= =?UTF-8?q?ion=20now=20runs=20even=20when=20watch=20list=20is=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subtle leak in runLiveStatusBatchPoll: the eviction pass (which removes liveStatusByLogin entries for streamers no longer in config.streamers) ran INSIDE the fetch branch — but the fetch branch is skipped early when logins.length === 0. Concretely: if a user had 3 streamers all marked live, then removed all 3, the poll would early-return at length-check, leaving stale liveStatusByLogin entries forever (until app restart) — main-process memory + an inaccurate get-live-status-snapshot IPC response. Renderer wasn't visibly affected because renderStreamers only looks up entries for streamers in the rendered list, but the underlying state was wrong. Restructured so the eviction pass always runs first based on the current watch list, then the fetch + diff only runs when the list is non-empty. Empty-list case still emits "removed -> offline" changes to the renderer so its parallel map stays in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index 941459a..bb7044d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3750,26 +3750,32 @@ async function runLiveStatusBatchPoll(): Promise { const logins = ((config.streamers as string[]) || []) .map((s) => normalizeLogin(s)) .filter((s): s is string => Boolean(s)); - if (logins.length === 0) return; - const fresh = await fetchLiveStatusBatch(logins); const changes: Array<{ login: string; isLive: boolean }> = []; - const seen = new Set(fresh.keys()); - for (const [login, isLive] of fresh.entries()) { - const prev = liveStatusByLogin.get(login); - if (prev !== isLive) changes.push({ login, isLive }); - liveStatusByLogin.set(login, isLive); - } - // Streamers that vanished from the watch list (or that GQL didn't - // return for) get evicted so a removed streamer doesn't leave a - // ghost-live dot behind. + const watchedSet = new Set(logins); + + // Always run the eviction pass FIRST — entries left over from a + // streamer that's no longer in the watch list must go regardless + // of whether we're about to fetch fresh data. Previously this + // ran inside the fetch branch only, so removing the last + // streamer left ghost entries in liveStatusByLogin until the + // next add. for (const oldLogin of Array.from(liveStatusByLogin.keys())) { - if (!seen.has(oldLogin)) { + if (!watchedSet.has(oldLogin)) { liveStatusByLogin.delete(oldLogin); changes.push({ login: oldLogin, isLive: false }); } } + if (logins.length > 0) { + const fresh = await fetchLiveStatusBatch(logins); + for (const [login, isLive] of fresh.entries()) { + const prev = liveStatusByLogin.get(login); + if (prev !== isLive) changes.push({ login, isLive }); + liveStatusByLogin.set(login, isLive); + } + } + if (mainWindow && changes.length > 0) { // Renderer only consumes `changes` — initial state comes via // the get-live-status-snapshot IPC at boot. Don't ship the