Steven
Steven6 min čtení

Přerušené spojení by nemělo shodit celou aplikaci — ale ta naše to dělala

Když nám backend spadl uprostřed schůzky, nezastavil jen přepis — shodil celou aplikaci. Příčinou byla jediná neošetřená událost a oprava měla deset řádků. Tohle je shluk vydání, díky kterému GeekBye zůstává přihlášený a připojený i skrz věci, které ho dřív zabíjely.

Inženýrství
Spolehlivost
Electron
Vydání GeekBye
Přerušené spojení by nemělo shodit celou aplikaci — ale ta naše to dělala

Některé bugy přeruší jednu funkci. Ty nejhorší srazí všechno kolem ní. V rané verzi GeekBye, pokud nám backend spadl, zatímco jsi byl uprostřed schůzky, přepis se nejen zastavil — spadla celá aplikace. Jedno přerušené spojení a celé okno bylo pryč.

Shluk vydání, který to opravil — od v1.6.8 po v1.6.11 — je mistrovská ukázka nevděčné práce „zůstaň přihlášený, zůstaň připojený". Hlavní oprava měla deset řádků. Tady je, co bylo těch deset řádků a všechno ostatní, co s nimi vyšlo.

Deset řádků mezi odpojením a pádem

Pád žil v audio kódu. Když relace přepisu bourala své WebSockety, volala na nich removeAllListeners() — rozumný úklid. Ale tady je ten detail z Node.js, který úklid promění v katastrofu: událost error bez posluchače se neignoruje. Znovu se vyhodí jako nezachycená výjimka.

Představ si tedy tu sekvenci, když backend spadne: socket má událost error ve frontě, připravenou se spustit. Úklid spustí removeAllListeners() a strhne handler, který by ji zachytil. O chvíli později se chyba z fronty spustí — do prázdna. Node vidí událost error, kterou nikdo neposlouchá, a udělá jedinou věc, kterou mu jeho kontrakt dovoluje: vyhodí ji jako nezachycenou výjimku, což shodí proces. Backend uživatele na dvě vteřiny škytl a jejich aplikace zmizela.

Oprava (v1.6.8) je téměř antiklimatická. Hned po odstranění posluchačů znovu připoj záměrný no-op handler error:

this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events

Ta prázdná funkce je celý smysl. Dá čekající chybě místo, kam přistát — odtok —, takže Node má posluchače a nikdy znovu nevyhodí. Stejný commit také uklidil napůl otevřené sockety, když pokus o připojení selže v půli cesty, aby zpackaný connect nemohl nechat živý socket viset s chybou připravenou se spustit. Komentář commitu to říká jasně: „Bez tohoto se čekající chyby stanou nezachycenými výjimkami a shodí aplikaci." Deset řádků proměnilo úplný pád v tiché, obnovitelné odpojení.

Poučení má větší hodnotu než oprava: kdykoli voláš removeAllListeners() na socketu nebo streamu, který možná ještě bouráš, nejdřív znovu připoj no-op odtok error. Neošetřená událost error je pád, ne řádek v logu.

Zůstat přihlášený: obnov dopředu a obnov při odrazu

Polovina „zůstaň připojený" měla dvojče „zůstaň přihlášený". Před tímto shlukem autentizační token GeekBye prostě vypršel — po chvíli tě bez okolků vyhodil zpátky na přihlašovací obrazovku, uprostřed pracovního toku, bez jakéhokoli viditelného důvodu.

v1.6.8 to opravila z obou směrů:

  • Proaktivně: časovač obnoví token dřív, než vyprší, naplánovaný na vypršení mínus chytrá rezerva (zlomek životnosti tokenu, s dolní a horní hranicí). Jsi obnoven dřív, než by sis toho vůbec všiml.
  • Reaktivně: pokud se požadavek přesto vrátí s 401 — posun hodin, zmeškaný časovač, studený laptop —, klient jednou obnoví a přesně jednou zopakuje původní požadavek. Jediný strážní příznak omezuje toto opakování, takže trvalý 401 se nikdy nemůže roztočit do nekonečné smyčky.

Jemná, ale zásadní část: obě cesty jsou za mutexem. Pokud deset požadavků narazí na 401 ve stejný okamžik, nespustí deset obnov — všechny čekají na jednu probíhající obnovu a pak to zopakují. A když obnovu opravdu nelze zachránit, aplikace ukončí relaci plynule — jasné desetivteřinové oznámení „vaše relace vypršela, přihlaste se prosím znovu" — místo tichého kopance na přihlašovací obrazovku.

Znovupřipojení: přidej ho na klientovi, pak ho přesuň na backend

v1.6.9 učinila přerušené spojení přepisu obnovitelným místo fatálním. Když backend hlásil, že se hlasové spojení směrem nahoru přerušilo, klient přestal vyhazovat fatální chybu a místo toho se znovu připojil — se záměrně klidným UX: jedno jantarové oznámení „Znovupřipojování…" (ne jedno na pokus), zelené oznámení „Znovu připojeno", když se vrátilo, a koncová chyba „prosím restartujte" poté, co byl vyčerpán každý pokus.

Pak něco, co se mi na tomto shluku líbí: o vydání později, ve v1.6.10, jsme tuhle logiku znovupřipojení na straně klienta smazali — asi 113 řádků — a přesunuli znovupřipojení na backend, který teď vysílá tiché stavové zprávy „znovupřipojování", jež klient jen zrcadlí. Obnovu jsme postavili na klientovi, rozhodli jsme, že backend je správný vlastník, a přesunuli ji. To není váhání; to je poctivý životní cyklus těžkého problému. Logika spolehlivosti rozdělená mezi oba konce spojení je varovný signál — vyber vlastníka. (Backend se nakonec stal definitivním vlastníkem tohoto, což je příběh znovupřipojení vyprávěný v proč se váš AI zapisovatel zastaví na špatné Wi-Fi.)

Stejné vydání učinilo časové limity nahrávání lidskými: místo syrové chyby varovné oznámení, které nechá nahrávání pokračovat, a jasnou zprávu „Dosažen limit nahrávání", když se skutečně zastaví — přátelská věta vytažená na povrch, interní prefix odstraněn.

Jedno opakování, které vládne všem

Do v1.6.11 byla stejná logika opakování-s-odstupem zkopírovaná na tři různá místa — autentizace, aktualizace, bourání relace. Tak byla sjednocena do jednoho modulu s jedním sdíleným pravidlem o tom, co vůbec stojí za opakování: přechodná síťová selhání (reset spojení, timeout, škytnutí DNS, přerušení socketu) se opakují s exponenciálním odstupem; skutečná chyba serveru selže rychle, protože opakování opravdového 4xx jen plýtvá časem uživatele. Jeden klasifikátor, pár pojmenovaných přednastavení, tři volající přemigrovaní na něj.

Co nás tento shluk naučil

  1. Neošetřená událost error je pád, ne řádek v logu. Node znovu vyhazuje události error, které nemají posluchače. Odstraň posluchače na socketu, který možná ještě bouráš, a musíš znovu připojit no-op odtok chyb — jinak jedno přerušené spojení vezme celou aplikaci s sebou.
  2. Obnovuj přihlašovací údaje před vypršením a reaktivně při 401 — s mutexem na obojím. Proaktivní tě drží přihlášeného; reaktivní zachytí hraniční případy; mutex znamená, že N souběžných volajících způsobí přesně jednu obnovu a horní hranice opakování znamená, že špatný token nemůže cyklit donekonečna.
  3. Opakuj jen při přechodných chybách a centralizuj klasifikátor. „Stojí tohle za opakování?" je jedno rozhodnutí, které patří na jedno místo. Opakování opravdové chyby je jen pomalejší selhání.
  4. Rozhodni, kdo vlastní znovupřipojení. Přidali jsme ho na klientovi, pak ho přesunuli na backend. Logika obnovy žijící na obou koncích je generátor bugů; dej jí jediný domov.

Tohle je třetí kapitola příběhu o spolehlivosti, který se stává GeekBye v2. Pro předchozí kapitolu viz smazali jsme 5000 řádků audio kódu a přepisy se začaly objevovat dvakrát (v1.6.0); pro to, kde se znovupřipojení nakonec ustálilo, proč se váš AI zapisovatel zastaví na špatné Wi-Fi; a pro celý oblouk anatomie dodávání softwaru k dokonalosti.