Steven
Steven5 min czytania

Zabezpieczenie, przez które nie dało się zamknąć naszej aplikacji

Automatyczna aktualizacja była najtrudniejszą funkcją, jaką kiedykolwiek wypuściliśmy — sześć wydań w cztery dni, żeby przestała psuć aplikację. Najgorszy błąd był tym, który wprowadziliśmy, próbując być ostrożni: 500-milisekundowe „zabezpieczenie", które zmieniało nieudaną aktualizację w proces, którego dosłownie nie dało się zamknąć.

Inżynieria
Electron
Niezawodność
Wydania GeekBye
Zabezpieczenie, przez które nie dało się zamknąć naszej aplikacji

Każdy twórca aplikacji desktopowej dokładnie raz nie docenia automatycznej aktualizacji. Wygląda na rozwiązany problem — biblioteka pobiera nową wersję i restartuje aplikację. Potem to wypuszczasz i dowiadujesz się, że „zrestartuj aplikację" to jedna z najniebezpieczniejszych rzeczy, o które można poprosić program, bo dzieje się to dokładnie w chwili, gdy aplikacja rozbiera samą siebie i ma najmniejszy margines na błąd.

Automatyczna aktualizacja GeekBye potrzebowała sześciu wydań w cztery dni — od v1.5.14 do v1.5.19 — żeby się ustabilizować. To jest historia najgorszego błędu z tego odcinka, który sami spowodowaliśmy, próbując być ostrożni.

Sześć wydań i to jedno, które miało znaczenie

Łuk zaczął się prozaicznie. v1.5.14 naprawiło wstydliwy błąd klasy literówki: kanał aktualizacji wskazywał na nazwę repozytorium GitHub, które nie istniało, więc updater sprawdzał 404. v1.5.15 dodało ręczny przycisk „Check for Updates" i prawdziwy komunikat o błędzie. Potem zaczęły się błędy quitAndInstall i wydania szły szybko — bo gdy twój mechanizm aktualizacji jest zepsuty, nie możesz wypuścić jego naprawy przez mechanizm aktualizacji. Każda iteracja to hazard z ręczną ponowną instalacją.

Tym, które ma znaczenie, jest v1.5.18. Cała jego zawartość to pojedynczy commit z tytułem, na który wciąż się krzywię: restore original quitAndInstall behavior to prevent unkillable app.

Jak „bycie ostrożnym" zepsuło aplikację

Oto tło. Gdy aktualizacja jest pobrana, quitAndInstall Electrona ma zamknąć aplikację i podmienić ją na nową wersję. We wcześniejszym wydaniu ktoś — rozsądnie — obawiał się, że zamykanie bezwarunkowe jest ryzykowne. A gdyby instalacja się nie powiodła? Czy nie byłoby bezpieczniej zamykać tylko wtedy, gdy wszystko wygląda zdrowo?

Więc kod obrósł zabezpieczeniem, które wyglądało sensownie:

autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
  if (this.updateDownloaded)
    app.quit() // only quit if the update is still "good"
  else console.log('Error detected — keeping app open')
}, 500)

Logika: odpal instalację, zaczekaj pół sekundy i wymuś ostateczne app.quit() tylko wtedy, gdy flaga updateDownloaded wciąż jest prawdziwa — w przeciwnym razie zostaw aplikację otwartą, żeby użytkownik nie został na lodzie.

Pułapka jest o jedną linię dalej, w handlerze błędu. Ten handler ustawiał this.updateDownloaded = false. Wyobraź więc sobie nieudaną instalację: zdarzenie error się odpala i czyści flagę. Ale quitAndInstall już rozpoczął rozbiórkę — zamknął okna i usunął nasłuchiwacze zamknięcia aplikacji. Potem budzi się 500 ms timer, sprawdza teraz-fałszywą flagę, decyduje „wykryto błąd, zostaw aplikację otwartą" i pomija app.quit().

Teraz masz, konkretnie na macOS, najgorszy możliwy stan. macOS nie zamyka aplikacji tylko dlatego, że zamknęło się jej ostatnie okno — to zachowanie window-all-closed, na którym opiera się każda aplikacja Maca. Więc proces wciąż żyje, ale nie ma żadnego okna, żadnej ścieżki przez pasek menu, a jego nasłuchiwacze zamknięcia zostały wyrwane. Nie ma w co kliknąć. Cmd-Q nie ma z czym rozmawiać. Jedyne wyjście to Force Quit z Activity Monitor. „Zabezpieczenie" zamieniło nieudaną aktualizację — odwracalną uciążliwość — w zombie, którego nie dało się zabić.

Naprawa: rozbiórka musi być bezwarunkowa

Poprawka w v1.5.18 jest niemal agresywnie nudna, i o to właśnie chodzi. Usuwa spryt:

  1. Usuń nasłuchiwacze window-all-closed i before-quit, które mogłyby przeszkodzić.
  2. Zniszcz każde okno — window.destroy(), nie window.close(). Zamknięcie może zawetować handler; zniszczenie nie może. Gdy zobowiązujesz się do wyłączenia, nie pytasz grzecznie.
  3. Wywołaj quitAndInstall.
  4. Wywołaj app.quit() bezwarunkowo.

Żadnej flagi, żadnego timera, żadnego „zostaw otwarte na wszelki wypadek". Bo prawda o ścieżce wyłączenia jest taka, że wyłączenie zrobione w połowie jest gorsze niż którykolwiek z wyników. Pełne zamknięcie jest w porządku. Pełne pozostanie otwartym jest w porządku. Jedynym stanem, którego nigdy nie wolno ci osiągnąć, jest rozebrany, ale wciąż działający — i to dokładnie stan, w którym warunkowe zamknięcie może cię uwięzić.

Dwie kolejne lekcje z tego samego tygodnia

Błąd nie do zabicia jest nagłówkiem, ale sześciowydaniowa gonitwa zahartowała dwa inne nawyki warte skradzenia.

Filtruj telemetrię awarii po dokładnych sygnaturach, a nie szerokich słowach kluczowych. W środku gonitwy odkryliśmy, że nasz raporter błędów był skonfigurowany tak, by odrzucać wszystko, co zawierało słowa takie jak permission, token czy microphone — próba wycięcia szumu, która po cichu połykała prawdziwe awarie, które akurat wspominały te słowa. Wyrwaliśmy kocowe filtry i zastąpiliśmy je dokładnymi ciągami odmowy (konkretny komunikat, który macOS emituje, gdy odmówiono uprawnienia) oraz konkretnymi przejściowymi kodami sieci, jak ERR_NETWORK_CHANGED. Redukcja szumu i ukrywanie błędów to to samo pokrętło przekręcone w przeciwne strony; jeśli filtrujesz na wyczucie, odfiltrujesz to, co musiałeś zobaczyć.

Każda automatyczna ścieżka potrzebuje ręcznej furtki bezpieczeństwa. Automatyczna aktualizacja jest z natury najlepszym staraniem — sieci szwankują, instalacje padają. Więc każdy tryb awarii dostał ludzki plan awaryjny: ręczny przycisk „Check for Updates", ponawianie z wykładniczym wycofaniem, timer ponownego sprawdzania oraz — zarezerwowany konkretnie na przypadek, gdy ręczna próba użytkownika zawiodła — prosty w słowach komunikat „usuń aplikację i zainstaluj ponownie ze strony". Automatyczna ścieżka to wygoda; ręczna ścieżka to gwarancja.

Wniosek

  1. Zabezpieczenie wokół nieodwracalnej akcji jest groźniejsze niż sama akcja. Warunkowe zamknięcie próbowało zapobiec zamknięciu aplikacji przez złą aktualizację, a zamiast tego stworzyło stan gorszy niż zamknięcie czy niezamknięcie. Ścieżki wyłączenia i instalacji powinny być bezwarunkowe i idempotentne — nigdy nie bramkowane na zmiennej fladze, którą inny handler może wywinąć ci spod nóg.
  2. Na macOS „brak okien" to nie „brak aplikacji". Każda logika rozbiórki musi uwzględniać platformę, na której proces bez okien dalej działa. Testuj ścieżkę awarii, na prawdziwym systemie, a nie tylko szczęśliwą ścieżkę.
  3. Funkcji, którą wypuszczasz przez system aktualizacji, nie da się przetestować przez system aktualizacji. Ta asymetria jest powodem, dla którego automatyczna aktualizacja zasługuje na paranoidalny, bezwarunkowy, mocno ręcznie weryfikowany kod. Łatwy sposób naprawy dostajesz dopiero po tym, jak już działa.

To najwcześniejszy rozdział pracy nad niezawodnością, która ostatecznie stała się GeekBye v2. O tym, dokąd zaprowadziła ta droga, przeczytaj ile naprawdę kosztuje wersja 2 (v2.0.0) oraz cały łuk w anatomii dowożenia oprogramowania do perfekcji.