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

Tři slovesa, která udržují Web Audio naživu
Steven
Steven8 min čtení

Tři slovesa, která udržují Web Audio naživu

Dvě bodová vydání GeekBye, dva měsíce od sebe a ve dvou různých souborech, naučila náš audio kód stejné lekci z opačných konců: přestaň zacházet s AudioContext prohlížeče jako s jednorázovým. Jedno vydání se naučilo volat resume() na kontextu, který macOS tiše suspendoval uprostřed nahrávání; druhé se naučilo volat suspend() místo close(), aby po sobě jdoucí sesiony přestaly narážet do stropu Chromia zhruba šesti kontextů. Resume, suspend, close — to je celá zápletka.

Inženýrství
Audio
Desktop
Rozeznat hovor od otevřené aplikace
Steven
Steven8 min čtení

Rozeznat hovor od otevřené aplikace

GeekBye si umí všimnout, že ses připojil k videoschůzce, a nabídnout, že ji nahraje. Detekce se ukazuje být tou snadnou polovinou — Swift binary čtoucí titulky oken každých deset sekund. Tvrdá polovina je přesnost: nespustit se, když je Zoom jen otevřený, neptat se na schůzku, kterou už nahráváš, a neztlumit mikrofon v hovoru, ve kterém opravdu jsi. Tři vydání, a každé je pojistka, která se musela naučit neporazit sama sebe.

Inženýrství
macOS
Desktop
Vyjmutí backendu z cesty nahrávání
Steven
Steven7 min čtení

Vyjmutí backendu z cesty nahrávání

GeekBye nahrává tvou obrazovku a ukládá video na tvůj Google Drive. První verze posílala každou nahrávku cestou tam přes vlastní servery GeekBye; o vydání později šel soubor přímo z tvého stroje na Drive a backend byl degradován na držení jediného ukazatele. Zajímavé je, jak málo kódu vlastně obsahuje „přímá, resumable" verze — protože resumabilita přišla ze smazání proxy, ne z jeho napsání.

Inženýrství
Architektura
Desktop