Steven
Steven6 min czytania

Zerwane połączenie nie powinno wywalać całej aplikacji — a nasze wywalało

Gdy nasz backend padł w trakcie spotkania, nie tylko wstrzymał transkrypcję — wywalił całą aplikację. Przyczyną było jedno nieobsłużone zdarzenie, a naprawa miała dziesięć linii. To klaster wydań, dzięki któremu GeekBye zostaje zalogowany i połączony przez rzeczy, które kiedyś go zabijały.

Inżynieria
Niezawodność
Electron
Wydania GeekBye
Zerwane połączenie nie powinno wywalać całej aplikacji — a nasze wywalało

Niektóre błędy przerywają funkcję. Najgorsze walą wszystko wokół niej. We wczesnym buildzie GeekBye, jeśli nasz backend padł, gdy byłeś w trakcie spotkania, transkrypcja nie tylko się zatrzymywała — wywalała się cała aplikacja. Jedno zerwane połączenie i całe okno znikało.

Klaster wydań, który to naprawił — od v1.6.8 do v1.6.11 — to majstersztyk nieefektownej pracy w duchu „zostań zalogowany, zostań połączony". Sztandarowa naprawa miała dziesięć linii. Oto czym były te dziesięć linii i wszystko inne, co dostarczyliśmy razem z nimi.

Dziesięć linii między rozłączeniem a wywaleniem

Awaria żyła w kodzie audio. Gdy sesja transkrypcji burzyła swoje WebSockety, wywoływała na nich removeAllListeners() — rozsądne sprzątanie. Ale oto szczegół Node.js, który zmienia sprzątanie w katastrofę: zdarzenie error bez nasłuchiwacza nie jest ignorowane. Jest ponownie rzucane jako nieprzechwycony wyjątek.

Wyobraź więc sobie sekwencję, gdy backend pada: gniazdo ma zdarzenie error ustawione w kolejce do wystrzelenia. Sprzątanie uruchamia removeAllListeners(), zdejmując obsługę, która by je przechwyciła. Chwilę później zakolejkowany błąd wystrzeliwuje — w pustkę. Node widzi zdarzenie error, którego nikt nie nasłuchuje, i robi jedyną rzecz, na jaką pozwala jego kontrakt: rzuca je jako nieprzechwycony wyjątek, co wywala proces. Backend użytkownika czknął na dwie sekundy, a jego aplikacja zniknęła.

Naprawa (v1.6.8) jest niemal antyklimaktyczna. Tuż po zdjęciu nasłuchiwaczy podepnij ponownie celową pustą obsługę error:

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

Ta pusta funkcja to cały sens. Daje oczekującemu błędowi gdzie wylądować — pochłaniacz — więc Node ma nasłuchiwacza i nigdy nie rzuca ponownie. Ten sam commit posprzątał też półotwarte gniazda, gdy próba połączenia zawodzi w połowie, tak by spartaczone połączenie nie mogło zostawić żywego gniazda dyndającego z czekającym błędem. Komentarz commita mówi to wprost: „Bez tego oczekujące błędy stają się nieprzechwyconymi wyjątkami i wywalają aplikację." Dziesięć linii zamieniło pełne wywalenie w ciche, odzyskiwalne rozłączenie.

Lekcja jest warta więcej niż naprawa: za każdym razem, gdy wywołujesz removeAllListeners() na gnieździe lub strumieniu, który być może wciąż burzysz, najpierw podepnij ponownie pusty pochłaniacz error. Nieobsłużone zdarzenie error to wywalenie, nie linijka logu.

Pozostać zalogowanym: odśwież z wyprzedzeniem i odśwież przy odbiciu

Połowa „zostań połączony" miała bliźniaczą połowę „zostań zalogowany". Przed tym klastrem token uwierzytelniający GeekBye po prostu wygasał — po chwili byłeś bez ceregieli wyrzucany z powrotem na ekran logowania, w trakcie pracy, bez widocznego powodu.

v1.6.8 naprawił to z obu stron:

  • Proaktywnie: timer odświeża token zanim wygaśnie, zaplanowany na moment wygaśnięcia minus sprytny bufor (ułamek żywotności tokenu, ograniczony od dołu i od góry). Jesteś odświeżany, zanim byś w ogóle zauważył.
  • Reaktywnie: jeśli żądanie i tak wraca z 401 — przesunięcie zegara, przegapiony timer, wyziębiony laptop — klient odświeża raz i ponawia oryginalne żądanie dokładnie raz. Pojedyncza flaga strażnicza ogranicza to ponowienie, tak by uporczywy 401 nigdy nie mógł rozkręcić się w nieskończoną pętlę.

Subtelna, ale krytyczna część: obie ścieżki stoją za muteksem. Jeśli dziesięć żądań trafia na 401 w tej samej chwili, nie odpalają dziesięciu odświeżeń — wszystkie czekają na to jedno odświeżenie w locie, a potem ponawiają. A gdy odświeżenia naprawdę nie da się uratować, aplikacja wygasza sesję płynnie — jasne dziesięciosekundowe powiadomienie „twoja sesja wygasła, zaloguj się ponownie" — zamiast cichego kopniaka na ekran logowania.

Ponowne łączenie: dodaj je na kliencie, potem przenieś do backendu

v1.6.9 sprawił, że zerwane połączenie transkrypcji stało się odzyskiwalne zamiast fatalnego. Gdy backend zgłaszał, że nadrzędne połączenie z rozpoznawaniem mowy zostało zerwane, klient przestawał wyświetlać błąd fatalny i zamiast tego łączył się ponownie — z celowo spokojnym UX: jedno bursztynowe powiadomienie „Łączenie ponowne…" (nie jedno na próbę), zielone powiadomienie „Połączono ponownie", gdy wróciło, i terminalny błąd „proszę zrestartować" dopiero po wyczerpaniu każdej próby.

Potem coś, co lubię w tym klastrze: jedno wydanie później, w v1.6.10, usunęliśmy tę logikę ponownego łączenia po stronie klienta — około 113 linii — i przenieśliśmy ponowne łączenie do backendu, który teraz emituje ciche komunikaty statusu „łączenie ponowne", które klient po prostu odzwierciedla. Zbudowaliśmy odzyskiwanie na kliencie, uznaliśmy backend za właściwego właściciela i przenieśliśmy je. To nie jest chwiejność; to uczciwy cykl życia trudnego problemu. Logika niezawodności rozbita na oba końce połączenia to zapach — wybierz właściciela. (Backend ostatecznie stał się definitywnym właścicielem tego, co jest historią ponownego łączenia opowiedzianą w dlaczego twój notatnik AI zatrzymuje się przy słabym Wi-Fi.)

To samo wydanie uczyniło limity czasu nagrywania humanitarnymi: zamiast surowego błędu, powiadomienie ostrzegawcze, które pozwala nagrywaniu trwać dalej, i jasny komunikat „Osiągnięto limit nagrywania", gdy naprawdę się zatrzymuje — przyjazne zdanie wysunięte na wierzch, wewnętrzny prefiks usunięty.

Jedno ponowienie, by wszystkimi rządzić

Do v1.6.11 ta sama logika ponawiania z backoffem została skopiowana i wklejona w trzy różne miejsca — uwierzytelnianie, aktualizacje, burzenie sesji. Więc została skonsolidowana w jeden moduł z jedną wspólną regułą o tym, co w ogóle warto ponawiać: przejściowe awarie sieci (reset połączenia, timeout, czkawka DNS, zerwanie gniazda) są ponawiane z wykładniczym backoffem; prawdziwy błąd serwera zawodzi szybko, bo ponawianie autentycznego 4xx tylko marnuje czas użytkownika. Jeden klasyfikator, kilka nazwanych presetów, trzech wywołujących przeniesionych na niego.

Czego nauczył ten klaster

  1. Nieobsłużone zdarzenie error to wywalenie, nie linijka logu. Node ponownie rzuca zdarzenia error, które nie mają nasłuchiwacza. Zdejmij nasłuchiwacze z gniazda, które być może wciąż burzysz, a musisz ponownie podpiąć pusty pochłaniacz error — inaczej jedno zerwane połączenie zabierze ze sobą całą aplikację.
  2. Odświeżaj poświadczenia przed wygaśnięciem i reaktywnie przy 401 — z muteksem na obu. Proaktywne trzyma cię zalogowanym; reaktywne łapie przypadki brzegowe; muteks oznacza, że N jednoczesnych wywołujących powoduje dokładnie jedno odświeżenie, a limit ponowień oznacza, że zły token nie może zapętlić się na zawsze.
  3. Ponawiaj tylko przy błędach przejściowych i scentralizuj klasyfikator. „Czy to warto ponawiać?" to jedna decyzja, która należy do jednego miejsca. Ponawianie prawdziwego błędu to tylko wolniejsza porażka.
  4. Zdecyduj, kto jest właścicielem ponownego łączenia. Dodaliśmy je na kliencie, potem przenieśliśmy do backendu. Logika odzyskiwania żyjąca na obu końcach to generator błędów; daj jej jeden dom.

To trzeci rozdział opowieści o niezawodności, która staje się GeekBye v2. Po poprzedni rozdział zobacz usunęliśmy 5000 linii kodu audio, a transkrypcje zaczęły pojawiać się podwójnie (v1.6.0); po to, gdzie ponowne łączenie ostatecznie osiadło, dlaczego twój notatnik AI zatrzymuje się przy słabym Wi-Fi; a po cały łuk, anatomię dostarczania oprogramowania do perfekcji.