From 1a8647f360c32e87f981db40301cba7594eb4fad Mon Sep 17 00:00:00 2001 From: eJosR-Coding Date: Thu, 17 Sep 2026 11:21:36 -0500 Subject: [PATCH 1/3] fix(core): 20 s timeout on GitHub requests so a hung request can't stall polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poller marks itself running for the whole cycle; a fetch that never settles (seen after a network change) left it stuck on 'Checking…' for hours. Every request now aborts after 20 s and the cycle moves on. --- src/core/github.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/core/github.ts b/src/core/github.ts index 38ce490..fcd0c5e 100644 --- a/src/core/github.ts +++ b/src/core/github.ts @@ -6,6 +6,19 @@ import type { GhEvent } from "./types"; const API = "https://api.github.com"; +/** A request that never answers must not freeze the poll loop. */ +const TIMEOUT_MS = 20_000; + +async function timedFetch(input: string, init: RequestInit = {}): Promise { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: ctl.signal }); + } finally { + clearTimeout(timer); + } +} + /** Turn a user-facing target string into the endpoint we hit. */ export function endpointFor(target: string, login: string | null): string | null { if (target === "@me") { @@ -54,7 +67,7 @@ export async function fetchEvents( token: string, etag: string | null, ): Promise { - const res = await fetch(url, { headers: headers(token, etag) }); + const res = await timedFetch(url, { headers: headers(token, etag) }); const num = (k: string) => { const v = res.headers.get(k); return v === null ? null : Number(v); @@ -84,7 +97,7 @@ export interface Profile { /** Validate a token by asking who it belongs to. */ export async function whoAmI(token: string): Promise { - const res = await fetch(`${API}/user`, { headers: headers(token) }); + const res = await timedFetch(`${API}/user`, { headers: headers(token) }); if (!res.ok) throw new GhError(res.status, await res.text().catch(() => ""), null, null); const data = (await res.json()) as { login: string; name: string | null; avatar_url: string | null }; return { login: data.login, name: data.name ?? null, avatarUrl: data.avatar_url ?? null }; @@ -115,7 +128,7 @@ export interface Suggestions { /** Smart defaults for onboarding: the user's busiest repos and their orgs. */ export async function fetchSuggestions(token: string): Promise { const get = async (path: string) => { - const res = await fetch(`${API}${path}`, { headers: headers(token) }); + const res = await timedFetch(`${API}${path}`, { headers: headers(token) }); if (!res.ok) throw new GhError(res.status, await res.text().catch(() => ""), null, null); return res.json(); }; From 215373ef902951d33cb2b3364bbdc860a4f3a912 Mon Sep 17 00:00:00 2001 From: eJosR-Coding Date: Thu, 17 Sep 2026 11:35:43 -0500 Subject: [PATCH 2/3] docs: changelog entry for the fetch timeout --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a99914..bccf4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,5 @@ ## 0.1.0 - First release: tray app, native toasts, sounds, bell animation + +- 20 s timeout on GitHub requests; a hung request no longer stalls polling From c9aaa46265428c7ed9f6397dda9cad75805def24 Mon Sep 17 00:00:00 2001 From: eJosR-Coding Date: Thu, 17 Sep 2026 11:39:42 -0500 Subject: [PATCH 3/3] fix(core): surface per-target errors instead of hiding them behind an ok status A 404 on a private repo (token without the repo scope) was reported for a split second and then overwritten by 'last check ok'. Failed targets now keep the status red with a hint about the missing scope. --- src/core/i18n.ts | 2 ++ src/core/poller.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 9b51327..817ddbc 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -127,6 +127,7 @@ const dict = { // poller "poller.noToken": "Falta el token de GitHub", "poller.rateLimit": "Rate limit de GitHub", + "poller.noAccess": "sin acceso o no existe (¿el token tiene el permiso repo?)", "poller.more": "…y {n} evento más. Abre la app para verlos.", "poller.morePlural": "…y {n} eventos más. Abre la app para verlos.", // event phrases @@ -268,6 +269,7 @@ const dict = { "empty.body": "Pushes, PRs, reviews… from the repos you watch. The bell is ready.", "poller.noToken": "GitHub token missing", "poller.rateLimit": "GitHub rate limit", + "poller.noAccess": "no access or doesn't exist (does the token have the repo scope?)", "poller.more": "…and {n} more event. Open the app to see it.", "poller.morePlural": "…and {n} more events. Open the app to see them.", "ev.push.title": "{who} pushed to {repo}", diff --git a/src/core/poller.ts b/src/core/poller.ts index 5855780..35d9274 100644 --- a/src/core/poller.ts +++ b/src/core/poller.ts @@ -104,6 +104,7 @@ export class Poller { this.cb.onStatus({ kind: "polling" }); const fresh: Notice[] = []; let remaining: number | null = null; + const failed: string[] = []; for (const target of targets) { const url = endpointFor(target, login); @@ -126,9 +127,12 @@ export class Poller { }); return; } - const msg = e instanceof Error ? e.message : String(e); - this.cb.onStatus({ kind: "error", message: `${target}: ${msg}` }); - // keep going with the other targets, one bad repo shouldn't kill the loop + // keep going with the other targets, one bad repo shouldn't kill + // the loop, but don't hide it behind a green "ok" either + const msg = e instanceof GhError && e.status === 404 + ? t("poller.noAccess") + : e instanceof Error ? e.message : String(e); + failed.push(`${target}: ${msg}`); } } @@ -141,7 +145,11 @@ export class Poller { await this.fire(unique); this.cb.onNotices(unique); } - this.cb.onStatus({ kind: "ok", at: new Date(), remaining }); + if (failed.length) { + this.cb.onStatus({ kind: "error", message: failed.join(" · ") }); + } else { + this.cb.onStatus({ kind: "ok", at: new Date(), remaining }); + } } /** Returns notices for events newer than what we've seen for this target. */