Steven
Steven7 min czytania

Narodzony i naprawiony w dwanaście minut

Changelog GeekBye v1.8.10 mówi, że naprawiono awarię podczas edycji skrótów klawiszowych. Owszem — ale awarię wprowadzono i naprawiono w tym samym pull requeście, dwanaście minut od siebie, i nigdy nie dotarła do ani jednego użytkownika. Prawdziwą historią jest kaskada niezawodności, która ją wytworzyła: mała, poprawna zmiana tego, kiedy skróty są zawieszane, i błąd rozbiórki Reacta, który wypadł z jednej linijki mającej uczynić edytor bezpieczniejszym.

Inżynieria
React
Desktop
Wydania GeekBye
Narodzony i naprawiony w dwanaście minut

Oto najuczciwsze zdanie, jakie potrafię napisać o GeekBye v1.8.10: awaria, z której naprawiania słynie, nigdy nikomu się nie przydarzyła. Changelog mówi „Naprawiono awarię podczas edycji skrótów klawiszowych i przełączania okien w trakcie edycji”, co jest prawdą i czyta się jak błąd, który trafił do wydania, ugryzł użytkowników i został załatany w poprawce. Tak nie było. Awarię wprowadził jeden commit, a usunął inny w tym samym pull requeście, dwanaście minut później, tego samego piątkowego poranka. Żyła w całości wewnątrz gałęzi. Notatki do wydania opisują bliznę, która powstała i zagoiła się, zanim ktokolwiek spoza repozytorium mógł ją zobaczyć.

To nie jest wina changelogu; to uczciwy kształt pracy iteracyjnej, i warto o tym opowiedzieć, bo te dwanaście minut zawiera naprawdę pouczający błąd Reacta. Ale żeby zrozumieć awarię, musisz zrozumieć, po co właściwie było to wydanie — bo awaria była szkodą uboczną naprawiania czegoś innego.

O co tak naprawdę chodziło w tym wydaniu

GeekBye pozwala zmieniać przypisania skrótów klawiszowych w Ustawieniach. Gdy nagrywasz nową kombinację, aplikacja musi powstrzymać swoje globalne skróty przed zadziałaniem — inaczej naciśnięcie Cmd+B, by je przypisać, po prostu przełączyłoby okno, zamiast zostać przechwycone. Więc edytor zawiesza globalne skróty przez IPC: renderer wywołuje window.electronAPI.setShortcutsSuspended(true), a ShortcutsHelper w procesie głównym odpowiada wywołaniem unregisterAll(), zrzucając każdą rejestrację globalShortcut, tak by naciśnięcie klawisza dotarło do pola przechwytywania, zamiast wyzwolić akcję. Przy wznowieniu wywołuje registerGlobalShortcuts(), by je przywrócić.

Błąd, który to wszystko zapoczątkował (zgłoszenie #233), dotyczył zakresu tego zawieszenia. Stary kod zawieszał skróty przez cały czas, gdy strona ustawień skrótów była otwarta — nie tylko wtedy, gdy aktywnie nagrywałeś klawisz. Przez większość czasu to niewidoczne. Ale jeśli otworzyłeś stronę, zacząłeś robić coś innego i przeszedłeś dalej, nigdy nie kończąc edycji, aplikacja mogła zostać z wszystkimi globalnymi skrótami wyrejestrowanymi — po cichu martwymi — dopóki nie wróciłeś. Commit, który to naprawia, jest szczery co do objawu: zbyt szerokie zawieszanie groziło „sprawieniem, że aplikacja wygląda na zepsutą, jeśli użytkownik zapomniał, że ma otwartą stronę”.

Poprawka to jednolinijkowe zawężenie: zawieszaj tylko wtedy, gdy wiersz jest faktycznie edytowany — oparte na !!editingShortcut, stanie hooka przechowującym id wiersza, który nagrywasz — zamiast na całą wizytę. Dobra zmiana. Ale „zawieszaj tylko podczas edycji” rodzi natychmiastowe pytanie: co liczy się jako zakończenie edycji? Naciśnięcie klawisza ją kończy. Naciśnięcie Escape ją anuluje. Ale co, jeśli po prostu… klikniesz gdzie indziej albo przełączysz się w środku przechwytywania na zupełnie inną aplikację? Jeśli nic nie anuluje edycji, zostajesz z nagrywaniem skrótu w oknie, które nie ma fokusu, a skróty pozostają zawieszone. Więc ten sam PR dodał zawór bezpieczeństwa: anuluj edycję, gdy fokus opuszcza okno. I to jest ta linijka, która wywołała awarię.

Dwunastominutowy błąd

Zaworem bezpieczeństwa był useEffect w ShortcutsSettings.tsx, który — podczas edycji wiersza — nasłuchiwał zdarzenia blur:

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

Dwa nasłuchy. Jeden na window — odpala się, gdy przełączasz się na inną aplikację. Jeden podpięty bezpośrednio, jako surowy nasłuch DOM, do pola przechwytywania <input> — miał odpalać się, gdy klikniesz na inny element. Wyglądają na zbędne i nieszkodliwe. Nie są.

Prześledź sekwencję dla „przełączania okien w trakcie edycji”, czyli dokładnie zwrotu z changelogu:

  1. Nagrywasz skrót; <input> ma fokus. Robisz Cmd+Tab do innej aplikacji.
  2. Odpala się blur okna. handleBlur uruchamia cancelEditing(), które ustawia editingShortcut z powrotem na null.
  3. Ustawienie tego stanu odmontowuje <input> — wiersz opuszcza tryb edycji, więc pole jest usuwane z DOM.
  4. Usunięcie sfokusowanego elementu z DOM synchronicznie wysyła zdarzenie blur na tym elemencie. Surowy nasłuch inputRef łapie je i wywołuje cancelEditing() ponownie — tym razem w środku fazy commit Reacta, gdy drzewo komponentów jest rozbierane.
  5. Wywołanie settera stanu na fiberze będącym w trakcie rozbiórki narusza jeden z wewnętrznych inwariantów Reacta i rzuca: "Should have a queue". Widok ustawień się wysypuje.

Błędem nie jest nasłuch okna i nie jest nim tak naprawdę koncepcja „anuluj przy blur”. Jest nim to, że jeden z dwóch nasłuchów był surowym addEventListener na elemencie zarządzanym przez React. React nie wie o tym nasłuchu, nie sprząta go przy odmontowaniu i — co kluczowe — przeglądarka odpala blur w trakcie właśnie tego usuwania z DOM, które React wykonuje, więc handler ponownie wchodzi w twoją logikę stanu w najgorszym możliwym momencie. Komunikat commita naprawiającego mówi to precyzyjniej niż ja potrafię: „gdy cancelEditing odmontowuje pole, odpala się blur i wywołuje cancelEditing ponownie podczas cyklu renderowania Reacta”.

Poprawka

bf28a50, dwanaście minut po narodzinach awarii, robi dwie małe rzeczy.

Po pierwsze, usuwa surowy nasłuch pola i zostawia tylko ten na oknie, który jako jedyny w ogóle musiał być ręcznym nasłuchem DOM (nie ma reactowego onBlur dla „całe okno straciło fokus”):

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

Po drugie, przenosi przypadek kliknięcia gdzie indziej — „użytkownik kliknął inny element” — na własny prop onBlur Reacta na polu i odracza go o tik:

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

Obie części mają znaczenie. Użycie propa onBlur Reacta zamiast surowego nasłuchu oznacza, że React jest właścicielem cyklu życia handlera i nie zostawi go wiszącego. A setTimeout(…, 0) gwarantuje, że nawet gdy ten blur jest wyzwolony przez odmontowanie, wywołanie cancelEditing() ląduje w następnym tiku — po zakończeniu bieżącego renderu i commita — więc nigdy nie może zaktualizować stanu na komponencie, który wciąż się rozbiera. Jeśli komponentu już wtedy nie ma, odroczone wywołanie jest nieszkodliwym no-opem.

To cała poprawka. Usuń nasłuch, którego React nie widział; pozwól Reactowi zaplanować ten, który został.

Dlaczego to ten dobry rodzaj nudy

Jest pokusa, by wrzucić to do szufladki „trywialne” — setTimeout, usunięta linijka — i iść dalej. Ale jego kształt to jeden z najczęstszych sposobów, w jaki wysypują się reactowe aplikacje desktopowe, i warto go sobie przyswoić:

  • Awaria pojawia się tylko na granicy cyklu życia (odmontowanie), wyzwolona przez zdarzenie, którego framework nie planuje (natywny blur podczas usuwania z DOM). Nie pokaże się w teście przeklikiwania; potrzebuje konkretnej ścieżki „fokus opuszcza okno podczas edycji”.
  • Przyczyną źródłową jest mieszanie dwóch modeli własności — surowych zdarzeń DOM i syntetycznych, planowanych zdarzeń Reacta — na tym samym elemencie. W momencie, gdy twój handler mutuje stan, który może odmontować ten element, surowa ścieżka staje się ponownie wejściowa.
  • Poprawką nie jest „dodaj flagę zabezpieczającą” (choć ref isMounted by to zamaskował); to całkowite usunięcie surowego nasłuchu, co jest mniejszą zmianą i tą właściwą. Znowu odejmowanie.

A metalekcja, ta, wokół której krąży cała ta seria: czytaj diff, nie notatkę do wydania. „Naprawiono awarię podczas edycji skrótów klawiszowych” jest trafne, ale ukrywa, że awaria była samozadana, z tego samego poranka i nigdy nie trafiła do wydania — oraz że prawdziwym wydaniem jest ciche zwycięstwo poprawności co do tego, kiedy zawiesić globalny skrót.

Co jeszcze trafiło do wydania

Dwie kolejne rzeczy załapały się do v1.8.10, obie warte linijki. System skrótów zyskał też samonaprawę przez sen/wybudzenie: macOS może po cichu zrzucić rejestracje globalnych skrótów aplikacji, gdy maszyna zasypia, więc handler wznowienia teraz je ponownie weryfikuje i rejestruje, co znaczy, że twoje skróty wciąż działają po wybudzeniu laptopa bez ponownego uruchamiania. A po stronie AI klient zmądrzał w kwestii limitów zapytań — żądania strumieniowe, które trafią na 429, teraz wycofują się wykładniczo i resetują swój licznik, gdy zatrzymasz nagrywanie, zamiast odczekiwać stałe opóźnienie. Trzy poprawki niezawodności w różnych podsystemach, z których jedna to awaria, która nigdy nie była awarią.

Po poprzedni rozdział historii v1 — drukowanie spotkania do PDF bez biblioteki PDF (v1.8.9); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.

Powiązane artykuły

Drukowanie spotkania do PDF bez biblioteki PDF
Steven
Steven8 min czytania

Drukowanie spotkania do PDF bez biblioteki PDF

GeekBye eksportuje spotkanie jako PDF, a nigdzie w kodzie nie ma biblioteki PDF. Renderuje HTML w niewidzialnym oknie przeglądarki i drukuje je. Ten wybór to cała historia: uczynił funkcję łatwą do zbudowania i dał jej każdą awarię, jaką ma prawdziwa przeglądarka — białe mignięcie, limit długości URL i podział strony, który przecinał zrzuty ekranu na pół. Lekarstwem na najbrzydszy błąd była jedna linijka CSS.

Inżynieria
Electron
Desktop
Od OCR do pikseli bez utraty ścieżki awaryjnej
Steven
Steven7 min czytania

Od OCR do pikseli bez utraty ścieżki awaryjnej

GeekBye przestał czytać twoje zrzuty ekranu OCR-em i zaczął wysyłać właściwy obraz do modeli wizyjnych — co jest łatwą częścią. Pouczająca część to optymalizacja, która pomijała OCR całkowicie dla użytkowników z widzeniem, po cichu psuła ścieżkę awaryjną do modeli bez widzenia i musiała zostać wycofana do wersji, w której siatka bezpieczeństwa biegnie równolegle i nic nie kosztuje.

Inżynieria
AI
Desktop
Dwa tryby awarii nakładki przepuszczającej kliknięcia
Steven
Steven8 min czytania

Dwa tryby awarii nakładki przepuszczającej kliknięcia

Okno GeekBye unosi się nad wszystkim i przepuszcza twoje kliknięcia na wylot — poza miejscami, gdzie ma przyciski. To dwustronny kontrakt, a v1.8.5 i v1.8.14 pokazują, jak wygląda złamanie każdej ze stron: jedno wydanie, w którym nakładka połknęła systemowe okno dialogowe, i drugie, w którym ukradła twoje naciśnięcia klawiszy. Zwycięskim lekarstwem na to drugie było skasowanie kodu.

Inżynieria
Electron
Desktop