Steven
Steven7 min čtení

Zrozen a opraven za dvanáct minut

Changelog GeekBye v1.8.10 říká, že opravil pád při úpravě klávesových zkratek. Opravdu ho opravil — ale ten pád byl zaveden a opraven ve stejném pull requestu, s odstupem dvanácti minut, a nikdy nedosáhl ani jednoho uživatele. Skutečný příběh je kaskáda spolehlivosti, která ho vyprodukovala: malá, správná změna toho, kdy se zkratky pozastavují, a bug s React demontáží, který vypadl z jednoho řádku, jenž měl udělat editor bezpečnějším.

Inženýrství
React
Desktop
Vydání GeekBye
Zrozen a opraven za dvanáct minut

Zde je nejpoctivější věta, kterou o GeekBye v1.8.10 mohu napsat: pád, kvůli jehož opravě je slavná, se nikdy nikomu nestal. Changelog říká "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," což je pravda a čte se jako bug, který se vydal, kousl uživatele a byl opraven v následné verzi. Nebyl. Pád byl zaveden jedním commitem a smazán jiným ve stejném pull requestu, s odstupem dvanácti minut, téhož pátečního rána. Žil zcela uvnitř jednoho branche. Poznámky k vydání popisují jizvu, která se vytvořila a zahojila, než ji mohl kdokoli mimo repo vidět.

To není selhání changelogu; je to poctivý tvar iterativní práce, a stojí za to to vyprávět, protože těch dvanáct minut obsahuje opravdu poučný React bug. Ale abys pádu porozuměl, musíš pochopit, k čemu vydání vlastně bylo — protože pád byl vedlejší škoda z opravy něčeho jiného.

K čemu vydání vlastně bylo

GeekBye ti umožňuje přemapovat si klávesové zkratky v Nastavení. Zatímco zaznamenáváš novou kombinaci, aplikace musí zabránit svým globálním zkratkám ve spuštění — jinak by stisk Cmd+B pro její přiřazení jen přepnul okno místo toho, aby byl zachycen. Takže editor pozastaví globální zkratky přes IPC: renderer volá window.electronAPI.setShortcutsSuspended(true) a ShortcutsHelper hlavního procesu odpoví voláním unregisterAll(), zahodíc každou registraci globalShortcut, aby stisk klávesy dosáhl zachytávacího inputu místo spuštění akce. Při obnovení volá registerGlobalShortcuts(), aby je vrátil zpět.

Bug, který to všechno spustil (issue #233), byl o rozsahu toho pozastavení. Starý kód pozastavoval zkratky po celou dobu, kdy byla stránka nastavení zkratek otevřená — ne jen zatímco jsi aktivně zaznamenával klávesu. Většinu času je to neviditelné. Ale pokud jsi otevřel stránku, začal dělat něco jiného a odnavigoval, aniž bys kdy dokončil úpravu, aplikace mohla zůstat se všemi svými globálními zkratkami neregistrovanými — tiše mrtvými — dokud ses nevrátil. Commit, který to opravuje, je upřímný ohledně symptomu: příliš široké pozastavení riskovalo "making the app appear broken if the user forgot they had the page open."

Oprava je jednořádkové zúžení: pozastav jen tehdy, když se řádek skutečně upravuje — klíčováno na !!editingShortcut, stav hooku držící id řádku, který zaznamenáváš — místo pro celou návštěvu. Dobrá změna. Ale „pozastav jen během úpravy" vyvolává okamžitou otázku: co se počítá jako dokončení úpravy? Stisk klávesy ji dokončí. Stisk Escape ji zruší. Ale co když prostě… klikneš jinam nebo přepneš úplně do jiné aplikace, uprostřed zachytávání? Pokud nic úpravu nezruší, zůstaneš zaznamenávat zkratku do okna, které není zaostřené, a zkratky zůstanou pozastavené. Tak stejný PR přidal pojistný ventil: zruš úpravu, když fokus odejde. A to je ten řádek, který spadl.

Bug za dvanáct minut

Pojistný ventil byl useEffect v ShortcutsSettings.tsx, který, zatímco se řádek upravoval, naslouchal na blur:

const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine

Dva listenery. Jeden na window — spustí se, když přepneš do jiné aplikace. Jeden připojený přímo, jako raw DOM listener, na zachytávací <input> — určený ke spuštění, když klikneš jinam na jiný prvek. Vypadají nadbytečně a neškodně. Nejsou.

Projdi si sekvenci pro "switching windows mid-edit," což je přesně fráze z changelogu:

  1. Zaznamenáváš zkratku; <input> je zaostřený. Uděláš Cmd+Tab do jiné aplikace.
  2. Blur okna se spustí. handleBlur spustí cancelEditing(), který nastaví editingShortcut zpět na null.
  3. Nastavení toho stavu odmontuje <input> — řádek opustí režim úpravy, takže input je odstraněn z DOM.
  4. Odstranění zaostřeného prvku z DOM synchronně odešle událost blur na tomto prvku. Raw inputRef listener ji zachytí a zavolá cancelEditing() znovu — tentokrát uprostřed commit fáze Reactu, zatímco se strom komponent bourá.
  5. Volání setteru stavu na fiberu, který je uprostřed demontáže, klopýtne o jeden z interních invariantů Reactu, a vyhodí: "Should have a queue". Pohled nastavení spadne.

Bug není listener okna a není to vlastně koncept „zrušení při bluru". Je to tím, že jeden ze dvou listenerů byl raw addEventListener na prvku spravovaném Reactem. React o tom listeneru neví, neuklidí ho při unmount a — což je klíčové — prohlížeč spustí blur během právě toho odstraňování z DOM, které React provádí, takže handler znovu vstoupí do tvé logiky stavu v nejhorší možný okamžik. Zpráva commitu s opravou to říká přesněji, než dokážu já: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Oprava

bf28a50, dvanáct minut poté, co se pád zrodil, dělá dvě malé věci.

Za prvé, maže raw input listener a ponechává jen ten okenní, což je jediný, který vůbec musel být manuální DOM listener (neexistuje React onBlur pro „celé okno ztratilo fokus"):

const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
  window.removeEventListener('blur', handleWindowBlur)
}

Za druhé, přesouvá případ kliknutí-jinam — „uživatel klikl na jiný prvek" — na vlastní onBlur prop Reactu na inputu, a odloží ho o tick:

onBlur={() => {
  // Deferred to avoid state updates during React's render cycle.
  setTimeout(() => cancelEditing(), 0)
}}

Obě části jsou důležité. Použití onBlur propu Reactu místo raw listeneru znamená, že React vlastní životní cyklus handleru a nenechá ho viset. A setTimeout(…, 0) zaručuje, že i když je tento blur vyvolán demontáží, volání cancelEditing() přistane na dalším ticku — poté, co aktuální render a commit skončily — takže nikdy nemůže aktualizovat stav na komponentu, který se pořád bourá. Pokud už je komponent do té doby pryč, odložené volání je neškodný no-op.

To je celá oprava. Smaž listener, který React neviděl; nech React naplánovat ten, který zbyl.

Proč je tohle ten dobrý druh nudy

Existuje pokušení zařadit tohle pod „triviální" — jeden setTimeout, jeden smazaný řádek — a jít dál. Ale jeho tvar je jedním z nejčastějších způsobů, jak React desktopové aplikace padají, a stojí za to si ho zvnitřnit:

  • Pád se objeví jen na hranici životního cyklu (unmount), spuštěný událostí, kterou framework neplánuje (nativní blur během odstraňování z DOM). Neobjeví se v click-through testu; potřebuje konkrétní cestu „fokus odchází během úpravy".
  • Kořenová příčina je míchání dvou modelů vlastnictví — raw DOM události a syntetické, naplánované události Reactu — na tomtéž prvku. Ve chvíli, kdy tvůj handler mutuje stav, který může odmontovat ten prvek, se raw cesta stává reentrantní.
  • Oprava není „přidej ochrannou vlajku" (ačkoli isMounted ref by to zamaskoval); je to úplné odstranění raw listeneru, což je menší změna a ta správná. Zase odčítání.

A meta-lekce, ta, kolem které celá tahle série stále krouží: čti diff, ne poznámku k vydání. "Fixed a crash while editing keyboard shortcuts" je přesné, ale skrývá, že pád byl vlastnoručně způsobený, téhož rána, a nikdy se nevydal — a že skutečné vydání je tiché vítězství správnosti o tom, kdy pozastavit globální zkratku.

Co dalšího se vydalo

Ještě dvě věci se svezly ve v1.8.10, obě stojí za řádek. Systém zkratek také získal samoléčení přes sleep/wake: macOS může tiše zahodit registrace globálních zkratek aplikace, když stroj usne, takže handler obnovení je teď znovu ověřuje a znovu registruje, což znamená, že tvoje zkratky pořád fungují poté, co se laptop probudí, bez restartu. A na straně AI se klient zchytřel ohledně rate limitů — streaming požadavky, které narazí na 429, teď exponenciálně ustupují a resetují svůj čítač, když zastavíš nahrávání, místo aby vyčkávaly fixní zpoždění. Tři opravy spolehlivosti v různých subsystémech, jednou z nichž je pád, který nikdy nebyl pádem.

Předchozí kapitolu příběhu v1 najdete v tisk schůzky do PDF bez PDF knihovny (v1.8.9); a celý oblouk v anatomii dodávání softwaru k dokonalosti.

Související články

Tisk schůzky do PDF bez PDF knihovny
Steven
Steven8 min čtení

Tisk schůzky do PDF bez PDF knihovny

GeekBye exportuje schůzku jako PDF a nikde v kódu není žádná PDF knihovna. Renderuje HTML v neviditelném okně prohlížeče a vytiskne ho. Ta volba je celý příběh: udělala funkci snadnou na postavení a dala jí každé selhání, které má skutečný prohlížeč — bílý záblesk, limit délky URL a zalomení stránky, které rozřízlo screenshoty napůl. Oprava toho nejošklivějšího bugu byla jeden řádek CSS.

Inženýrství
Electron
Desktop
Od OCR k pixelům, aniž bychom ztratili fallback
Steven
Steven7 min čtení

Od OCR k pixelům, aniž bychom ztratili fallback

GeekBye přestal číst tvoje screenshoty pomocí OCR a začal posílat samotný obrázek vision modelům — což je ta snadná část. Poučná část je optimalizace, která pro vision uživatele OCR zcela přeskočila, tiše rozbila fallback k non-vision modelům a musela být vrácena k verzi, ve které záchranná síť běží paralelně a nic nestojí.

Inženýrství
AI
Desktop
Dva režimy selhání click-through overlaye
Steven
Steven8 min čtení

Dva režimy selhání click-through overlaye

Okno GeekBye se vznáší nade vším a nechává tvoje kliknutí procházet skrz — kromě míst, kde má tlačítka. To je oboustranná smlouva a v1.8.5 a v1.8.14 ukazují, jak to vypadá, když se každá strana zlomí: jedno vydání, kde overlay spolkl systémový dialog, a jedno, kde ti ukradl stisky kláves. Vítězná oprava toho druhého bylo smazání kódu.

Inženýrství
Electron
Desktop