Steven
Steven7 min čítania

Zrodený a opravený za dvanásť minút

Changelog GeekBye v1.8.10 hovorí, že opravil pád počas úpravy klávesových skratiek. Opravil — ale pád bol zavedený a opravený v tom istom pull requeste, s odstupom dvanástich minút, a nedostal sa k jedinému používateľovi. Skutočný príbeh je kaskáda spoľahlivosti, ktorá ho vyprodukovala: malá, správna zmena toho, kedy sa skratky pozastavujú, a bug rozoberania Reactu, ktorý vypadol z jedného riadka, čo mal spraviť editor bezpečnejším.

Inžinierstvo
React
Desktop
Vydania GeekBye
Zrodený a opravený za dvanásť minút

Toto je najpoctivejšia veta, akú viem napísať o GeekBye v1.8.10: pád, ktorého opravou sa preslávil, sa nikdy nikomu nestal. Changelog hovorí „Opravený pád počas úpravy klávesových skratiek a prepínania okien uprostred úpravy”, čo je pravda a číta sa ako bug, ktorý sa dostal do vydania, uhryzol používateľov a bol zaplátaný v nadväzujúcom vydaní. Nebolo to tak. Pád zaviedol jeden commit a zmazal ho iný v tom istom pull requeste, s odstupom dvanástich minút, v to isté piatkové ráno. Žil úplne vnútri vetvy. Poznámky k vydaniu opisujú jazvu, ktorá sa vytvorila a zahojila predtým, než ju mohol vidieť ktokoľvek mimo repozitára.

To nie je zlyhanie changelogu; je to poctivý tvar iteratívnej práce a stojí za to ho vyrozprávať, lebo tých dvanásť minút obsahuje naozaj poučný bug Reactu. Ale aby si pochopil pád, musíš pochopiť, na čo bolo vydanie vlastne určené — lebo pád bol vedľajšou škodou opravy niečoho iného.

O čom vydanie vlastne bolo

GeekBye ti umožňuje preväzovať svoje klávesové skratky v Nastaveniach. Kým nahrávaš novú kombináciu, aplikácia musí zabrániť svojim globálnym skratkám v spustení — inak by stlačenie Cmd+B na jeho priradenie len preplo okno namiesto toho, aby bolo zachytené. Tak editor pozastaví globálne skratky cez IPC: renderer zavolá window.electronAPI.setShortcutsSuspended(true) a ShortcutsHelper hlavného procesu odpovie volaním unregisterAll(), zahodiac každú registráciu globalShortcut, takže stlačenie klávesy dosiahne zachytávací input namiesto spustenia akcie. Pri obnovení zavolá registerGlobalShortcuts(), aby ich vrátil.

Bug, ktorý toto všetko spustil (issue #233), bol o rozsahu toho pozastavenia. Starý kód pozastavoval skratky na celý čas, čo bola otvorená stránka nastavení skratiek — nie len počas toho, keď si aktívne nahrával klávesu. Väčšinu času je to neviditeľné. Ale ak si otvoril stránku, začal robiť niečo iné a odišiel preč bez toho, aby si vôbec dokončil úpravu, aplikácia mohla zostať so všetkými globálnymi skratkami odregistrovanými — ticho mŕtvymi — kým si sa nevrátil. Commit, ktorý to opravuje, je úprimný ohľadom symptómu: príliš široké pozastavenie riskovalo „že aplikácia bude pôsobiť pokazene, ak používateľ zabudol, že má stránku otvorenú”.

Oprava je zúženie na jeden riadok: pozastaviť len vtedy, keď sa riadok naozaj upravuje — kľúčované na !!editingShortcut, stav hooku, ktorý drží id riadka, ktorý nahrávaš — namiesto celej návštevy. Dobrá zmena. Ale „pozastaviť len počas úpravy” vyvoláva okamžitú otázku: čo sa počíta ako dokončenie úpravy? Stlačenie klávesy ju dokončí. Stlačenie Escape ju zruší. Ale čo ak len… odklikneš preč, alebo prepneš na inú aplikáciu úplne, uprostred zachytávania? Ak úpravu nič nezruší, zostaneš nahrávať skratku do okna, ktoré nemá fokus, a skratky zostanú pozastavené. Tak ten istý PR pridal poistný ventil: zrušiť úpravu, keď fokus odíde. A to je ten riadok, ktorý padol.

Bug na dvanásť minút

Poistný ventil bol useEffect v ShortcutsSettings.tsx, ktorý, kým sa riadok upravoval, počúval blur:

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

Dvaja poslucháči. Jeden na window — spustí sa, keď prepneš na inú aplikáciu. Jeden pripojený priamo, ako surový poslucháč DOM, na zachytávací <input> — mal sa spustiť, keď odklikneš preč na iný prvok. Vyzerajú nadbytočne a neškodne. Nie sú.

Prejdi si sekvenciu pre „prepínanie okien uprostred úpravy”, čo je presne fráza changelogu:

  1. Nahrávaš skratku; <input> má fokus. Spravíš Cmd+Tab na inú aplikáciu.
  2. Spustí sa blur okna. handleBlur spustí cancelEditing(), ktorý nastaví editingShortcut späť na null.
  3. Nastavenie toho stavu odpojí <input> — riadok opustí režim úpravy, takže input sa odstráni z DOM.
  4. Odstránenie zafokusovaného prvku z DOM synchrónne odošle udalosť blur na tom prvku. Surový poslucháč inputRef ju zachytí a zavolá cancelEditing() znova — tentoraz uprostred fázy commitu Reactu, kým sa strom komponentov rozoberá.
  5. Volanie settera stavu na fiberi uprostred rozoberania spustí jeden z interných invariantov Reactu a on vyhodí: "Should have a queue". Pohľad nastavení padne.

Bug nie je poslucháč okna a nie je to ani koncept „zrušiť pri blur”. Je to to, že jeden z dvoch poslucháčov bol surový addEventListener na prvku spravovanom Reactom. React o tom poslucháčovi nevie, neupratuje ho pri odpojení a — čo je kľúčové — prehliadač spustí blur počas práve toho odstránenia z DOM, ktoré React vykonáva, takže handler znova vstúpi do tvojej logiky stavu v najhoršom možnom okamihu. Správa opravného commitu to hovorí presnejšie, než viem ja: „when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle.”

Oprava

bf28a50, dvanásť minút po tom, čo sa pád zrodil, robí dve malé veci.

Po prvé, zmaže surový poslucháč inputu a ponechá len ten okenný, ktorý je jediný, čo vôbec musel byť manuálnym poslucháčom DOM (neexistuje React onBlur pre „celé okno stratilo fokus”):

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

Po druhé, presunie prípad odkliknutia preč — „používateľ klikol na iný prvok” — na vlastný prop Reactu onBlur na inpute a odloží ho o tik:

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

Obe časti sú dôležité. Použitie propu Reactu onBlur namiesto surového poslucháča znamená, že React vlastní životný cyklus handlera a nenechá ho visieť. A setTimeout(…, 0) zaručuje, že aj keď je tento blur naozaj spustený odpojením, volanie cancelEditing() pristane na nasledujúcom tiku — po tom, čo aktuálny render a commit skončili — takže nikdy nemôže aktualizovať stav na komponente, ktorý sa ešte rozoberá. Ak je komponent dovtedy už preč, odložené volanie je neškodná prázdna operácia.

To je celá oprava. Zmaž poslucháča, ktorého React nevidel; nechaj React naplánovať toho, čo zostal.

Prečo je toto dobrý druh nudy

Je tu pokušenie zaradiť toto pod „triviálne” — setTimeout, zmazaný riadok — a ísť ďalej. Ale jeho tvar je jeden z najbežnejších spôsobov, akými padajú desktopové aplikácie Reactu, a oplatí sa ho zvnútorniť:

  • Pád sa objaví len na hranici životného cyklu (odpojenie), spustený udalosťou, ktorú framework neplánuje (natívny blur počas odstránenia z DOM). Neukáže sa v preklikávacom teste; potrebuje konkrétnu cestu „fokus odíde počas úpravy”.
  • Koreňová príčina je miešanie dvoch modelov vlastníctva — surových udalostí DOM a syntetických, plánovaných udalostí Reactu — na tom istom prvku. V okamihu, keď tvoj handler zmutuje stav, ktorý môže ten prvok odpojiť, surová cesta sa stane reentrantnou.
  • Oprava nie je „pridať ochranný príznak” (hoci ref isMounted by to zamaskoval); je to úplné odstránenie surového poslucháča, čo je menšia zmena a tá správna. Znova odčítanie.

A meta-lekcia, tá, okolo ktorej celá táto séria stále krúži: čítaj diff, nie poznámku k vydaniu. „Opravený pád počas úpravy klávesových skratiek” je presné, ale skrýva, že pád bol samospôsobený, v to isté ráno, a nikdy sa nedostal do vydania — a že skutočné vydanie je tichá výhra správnosti o tom, kedy pozastaviť globálnu skratku.

Čo ešte vyšlo

Ešte dve veci sa zviezli vo v1.8.10, obe hodné riadka. Systém skratiek tiež získal samoliečenie naprieč spánkom/prebudením: macOS môže ticho zahodiť registrácie globálnych skratiek aplikácie, keď stroj zaspí, takže handler obnovenia ich teraz znova overí a znova zaregistruje, čo znamená, že tvoje skratky stále fungujú po prebudení notebooku bez reštartu. A na strane AI sa klient zmúdrel ohľadom limitov rýchlosti — streamovacie požiadavky, ktoré narazia na 429, teraz exponenciálne cúvajú a resetujú svoje počítadlo, keď zastavíš nahrávanie, namiesto vyčkávania pevného oneskorenia. Tri opravy spoľahlivosti v rôznych podsystémoch, jednou z ktorých je pád, ktorý nikdy nebol pádom.

Pre predchádzajúcu kapitolu príbehu v1 — tlač stretnutia do PDF bez knižnice PDF (v1.8.9); a pre celý oblúk — anatómiu dodávania softvéru do dokonalosti.

Súvisiace články

Tlač stretnutia do PDF bez knižnice PDF
Steven
Steven8 min čítania

Tlač stretnutia do PDF bez knižnice PDF

GeekBye exportuje stretnutie ako PDF a nikde v kóde nie je knižnica PDF. Renderuje HTML v neviditeľnom okne prehliadača a vytlačí ho. Ten výber je celý príbeh: spravil funkciu ľahkou na zostavenie a dal jej každé zlyhanie, aké má skutočný prehliadač — biele bliknutie, limit dĺžky URL a zlom strany, ktorý rozrezal snímky obrazovky napoly. Liekom na najškaredší bug bol jeden riadok CSS.

Inžinierstvo
Electron
Desktop
Od OCR k pixelom bez straty záložnej cesty
Steven
Steven7 min čítania

Od OCR k pixelom bez straty záložnej cesty

GeekBye prestal čítať tvoje snímky obrazovky cez OCR a začal posielať samotný obraz vidiacim modelom — čo je tá ľahká časť. Poučná časť je optimalizácia, ktorá preskakovala OCR úplne pre používateľov s vidiacim modelom, potichu lámala záložnú cestu k modelom bez videnia a musela byť vrátená späť do verzie, kde záchranná sieť beží paralelne a nestojí nič.

Inžinierstvo
AI
Desktop
Dva režimy zlyhania prekrytia prepúšťajúceho kliknutia
Steven
Steven8 min čítania

Dva režimy zlyhania prekrytia prepúšťajúceho kliknutia

Okno GeekBye pláva nad všetkým a púšťa tvoje kliknutia naskrz — okrem miest, kde má tlačidlá. To je dvojstranná zmluva a v1.8.5 a v1.8.14 ukazujú, ako vyzerá zlyhanie každej zo strán: jedno vydanie, kde prekrytie prehltlo systémový dialóg, a druhé, kde ti ukradlo stlačenia klávesov. Víťazným liekom na to druhé bolo zmazanie kódu.

Inžinierstvo
Electron
Desktop