Wysypka jest uczciwa. Mówi, że coś poszło nie tak, podaje numer linii i ślad stosu.
Cisza to też komunikat. Mówi, że wszystko poszło dobrze. Ten komunikat kosztuje znacznie więcej, kiedy jest nieprawdziwy, i akurat ten produkujemy dziś na skalę przemysłową.
W marcu jedną komendą skasowałem 130 własnych commitów. To nie była najgorsza rzecz, jaka spotkała ten projekt. To było głośne. Wiedziałem po czterech sekundach, spędziłem wieczór w reflogu i odzyskałem większość z gałęzi, którą trzy tygodnie wcześniej zrobiłem przez przypadek. Głośne awarie same się zgłaszają, więc się je naprawia.
Te, które prawie mnie wykończyły, były ciche. Przycisk, który meldował sukces i nic nie zapisywał. Kontrola temperatury, która od miesięcy nie czytała niczego. Silnik uczenia, którego nikt nie wołał. Skrót na pulpicie, wydany prawdziwym użytkownikom przez Microsoft Store, który nie otwierał absolutnie niczego.
Żadna z nich nie wywaliła programu. Każda przechodziła wszystkie testy, jakie miałem.
Od czternastu miesięcy buduję publicznie monitor systemu na Windows, sam, wieczorami po pracy. Praca to był magazyn w Holandii, potem zgrzewanie plastiku w warsztacie, a teraz taksówka. Wszystko leży na GitHubie od pierwszego commita, więc lista moich pomyłek nie jest wspomnieniem. Jest zapisem, którego nie mogę edytować.
Sześć prawdziwych i czego uczy każda z nich
Wszystko poniżej wydarzyło się w prawdziwej aplikacji z prawdziwymi użytkownikami. Tam, gdzie wychodzę na niedbałego, byłem niedbały.
1. Przycisk, który mówił "Zastosowano"
Jest edytor krzywej wentylatorów. Przeciągasz punkty, klikasz Zastosuj, pojawia się zielony komunikat.
Komunikat się pojawiał. Plik nigdy nie był zapisywany. Każdy restart w ciszy wyrzucał ustawienia użytkownika i robił to przez dwa wydania.
Nikt tego nie zgłosił i warto się przy tym zatrzymać: użytkownik nie odróżni "zapisało się" od "napisało, że się zapisało". Ustawił krzywą, zobaczył potwierdzenie, po tygodniu zrestartował komputer, zobaczył domyślne wartości i uznał, że sam coś zrobił źle.
2. Kontrola, która nic nie mierzyła
Monitor proaktywny ma ostrzegać, kiedy maszyna robi się gorąca. Napisałem go, przetestowałem, wydałem i napisałem o nim wpis na blogu.
Przez miesiące opierał się na psutil.sensors_temperatures(). Kontrola odpalała się zgodnie z harmonogramem, nie znajdowała nic, o czym warto ostrzec, i meldowała, że jest czysto. Bez końca. Testy były zadowolone, bo sprawdzały, że alert nie odpala przy normalnych temperaturach, a alert, który nie może odpalić nigdy, przechodzi taki test za każdym razem.
Pustego odczytu nie odróżnisz od normalnego, jeśli nigdy nie sprawdzasz, czy odczyt w ogóle istnieje.
Dokumentacja psutila daje przy tej funkcji jedną linijkę: Availability: Linux, FreeBSD. Windowsa tam nie ma. Czyli na Windowsie ta funkcja nie zwraca pustego słownika. Ona w ogóle nie istnieje. Wywołanie kończy się wyjątkiem
AttributeError. Oto sprawdzenie na maszynie, na której powstał ten artykuł, psutil 7.2.1, Windows 10 build 19045:
>>> import psutil
>>> hasattr(psutil, "sensors_temperatures")
False
Błąd ma ten sam rozmiar. Wniosek jest większy. To nigdy nie było "API grzecznie oddało mi pusty wynik". To był wyjątek rzucany przy każdym wywołaniu, lądujący w bloku try, który nie miał nic do powiedzenia. Awaria numer dwa przez cały czas stała na awarii numer trzy, a ja tego nie widziałem, bo opisywałem ją z pamięci. To jest cała teza tego artykułu, wykonana na żywo moim kosztem.
Kod, który dziś czyta temperatury, nie zgaduje. Najpierw pyta, czy funkcja istnieje, a potem sięga po LibreHardwareMonitor, bo stamtąd naprawdę biorą się temperatury na Windowsie:
# core/hardware_sensors.py
temps = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
3. except: pass i cztery miesiące martwego mózgu
To ta, która nadal mnie uwiera.
Aplikacja uczy się, co jest normą dla twojej maszyny: linie bazowe temperatur per obciążenie, anomalie napięć liczone prawdziwą kontrolą statystyczną procesu. Tygodnie pracy. Działało, było testowane, dawało poprawne liczby.
Asystent czatu nigdy tego nie wołał. Znalazłem to, szukając czegoś innego i trafiając na to:
try:
ctx = build_learning_context()
except:
pass
Dwie linijki wyżej kod odwoływał się do zmiennej, której w tym zasięgu nie było. NameError, za każdym razem, przy każdej wiadomości, przez cztery miesiące. Gołe except połykało to w całości. Bez wysypki, bez logu, bez ostrzeżenia. Asystent dalej odpowiadał. Po prostu odpowiadał jak program, który niczego się nie nauczył, bo ze swojej strony faktycznie się nie nauczył.
PEP 8 mówi to wprost od lat: gołe except "potrafi zamaskować inne problemy", więc wskazuj wyjątki, których naprawdę się spodziewasz. To jedna z niewielu reguł stylu, która tak naprawdę jest regułą poprawności.
except: pass nie obsługuje błędu. Ono kasuje dowód, że błąd wystąpił. Jeśli masz zabrać z tego artykułu jedną linijkę, weź tę. Przeszukaj swój projekt pod kątem except: z pass poniżej. Poczekam.4. Przełącznik podpięty do niczego
Na pulpicie aplikacji był przełącznik TURBO. Dało się go kliknąć. Animował się.
Zapisywał flagę, której nic w całym kodzie nie czytało. Funkcja, która miała ją konsumować, istniała, była poprawna i nikt jej nie wołał.
Użytkownicy klikali, czuli się produktywnie i zmieniali dokładnie zero bajtów stanu systemu. Zbudowałem funkcję, zbudowałem przycisk i nigdy ich nie połączyłem, bo funkcję testowałem wywołując ją bezpośrednio, a przycisk testowałem patrząc na niego.
5. Skrót, który nic nie otwierał
Ten trafił do Microsoft Store.
Aplikacje w paczkach MSIX leżą w folderze, w którym użytkownik nie kliknie prawym przyciskiem, więc program proponuje zrobienie skrótu na pulpicie. Taki skrót musi nieść Application User Model ID, czyli identyfikator, po którym powłoka wiąże skrót, proces i okno z jedną aplikacją. Składa się z nazwy rodziny paczki i identyfikatora aplikacji zadeklarowanego w manifeście.
manifest: Application Id="App"
kod skrótu: ...PCWorkman_4hekbcs2ddfbc!PCWorkmanHCK
Każdy użytkownik ze Store, który kliknął "utwórz skrót na pulpicie", dostał skrót uruchamiający nic. Bez okna błędu. Windows po prostu nie znajdował aplikacji o tej nazwie i wracał na pulpit.
Zero zgłoszeń. Oczywiście zero. Nikt nie zakłada zgłoszenia o skrócie, który nic nie robi. Klika dwa razy, wzrusza ramionami i nigdy więcej go nie używa, a funkcja umiera bez zostawiania ciała.
6. Skan, który oskarżył usługę drukowania
W aplikacji jest silnik badania procesów: podpis Authenticode, wykrywanie literówek w nazwach, wykrywanie podszywania się. Poprawnie łapie svch0st.exe udające svchost.exe.
Oznaczył też spoolsv.exe. Usługę drukowania Windows. Poprawny podpis Microsoftu, właściwa ścieżka w System32.
Przyczyna jest tu najbardziej pouczająca i nie jest to literówka. Biblioteka procesów niesie adnotację o znaczeniu "ciężki, obserwuj zużycie zasobów". Ta adnotacja podnosiła werdykt bezpieczeństwa. Dwa zupełnie różne rodzaje prawdy dzieliły jedno pole.
Gorzej, kod już wiedział lepiej i nie mógł tego użyć. Poprawny podpis Microsoftu ma awansować proces do zaufanego, ale ten awans wykonywał się tylko dopóki werdykt był jeszcze "nieznany", a zanim doszło do odczytu podpisu, adnotacja przesuwała go już na "ostrożnie".
Wzorzec w jednej tabeli
Ustaw te sześć obok siebie, a zobaczysz jeden błąd w sześciu kostiumach.
| Co się naprawdę wydarzyło | Co zgłosił system |
|---|---|
| Ustawienia nigdy nie zapisane | Zastosowano pomyślnie |
| Temperatura nigdy nie odczytana | Jest czysto |
| Uczenie nigdy nie wołane | Pewna siebie odpowiedź |
| Funkcja nigdy nie podpięta | Działający przycisk |
| Skrót nigdy niepoprawny | Skrót na pulpicie |
| Adnotacja odczytana nie w tym polu | Werdykt bezpieczeństwa |
Każdy wiersz to działanie, które się nie odbyło, i system, który twierdzi, że się odbyło. Stąd definicja warta zapamiętania: cicha awaria to fałszywa deklaracja sukcesu. Nie brak wyniku. Zły wynik, który przypadkiem uspokaja.
Zwróć uwagę, która warstwa nie łapie żadnej z nich. Kontrola typów nie, bo typy są w porządku. Linter w większości nie, bo składnia jest w porządku. Testy nie, chyba że ktoś pomyślał o sprawdzeniu efektu ubocznego. Jedyne, co łapie tę klasę, to kontrola, że praca się wydarzyła.
Tydzień, w którym przestałem czytać uważnie
Muszę opowiedzieć o konkretnym tygodniu, bo to on jest powodem, dla którego uważam, że ten problem będzie rósł, a nie malał.
Pracuję z asystentem AI i piszę o tym pod każdym publikowanym wpisem. Przez większość ostatniego roku znaczyło to rozmowę: pytam, czytam odpowiedź uważnie, kłócę się z jakąś jedną trzecią i zostawiam to, co przetrwało.
Potem przyszedł tydzień wydania. Zgłoszenie do Store, podbicie wersji w czterdziestu dwóch plikach, piętnaście nowych wpisów na blogu, build, paczka i dniówki za kierownicą. Zacząłem przyjmować więcej i czytać mniej. Oto co z tego wyszło w jeden tydzień.
Wypełnił bazę wiarygodnymi nazwami producentów
Aplikacja niesie bibliotekę znanych procesów: nazwa, producent, co ta rzecz robi. Dziś ma 521 pozycji. Żeby skrócić listę nierozpoznanych procesów, dorzuciliśmy trzydzieści pięć wpisów, a pola producenta wyglądały całkowicie sensownie. bash.exe dostał przypisane "The Git Development Community".
Prawdziwy podpis Authenticode na tym pliku to imię i nazwisko konkretnej osoby. A ponieważ silnik porównuje oczekiwanego producenta z rzeczywistym podpisem, rozbieżność podnosi ostrzeżenie. Trzy procesy, które wcześniej były po prostu nierozpoznane, zostały oznaczone jako podejrzane. Pewne siebie, wiarygodne, błędne i gorsze niż nienapisanie niczego, bo "nieznane" zachęca do sprawdzenia, a wypełnione pole nie.
Naprawa polegała na tym, żeby nazwy producentów brać wyłącznie z samego pliku binarnego.
Napisał wzorzec, który przeszedł testy i poległ w produkcji
Nowa funkcja wstawia klikalne linki do okna czatu. Parser był przetestowany i poprawny. W działającej aplikacji każdy link renderował się jako zwykły tekst.
# Przechodził każdy test jednostkowy. W działającej aplikacji nie trafił nigdy.
pos = text_widget.search(r'\[-> [^\]]+\]', idx, regexp=True)
Testy jednostkowe używały pythonowego modułu re. Widget tekstowy Tk już nie. Przy -regexp oddaje wzorzec własnemu silnikowi wyrażeń regularnych Tcl, a w zaawansowanych wyrażeniach Tcl backslash pozostaje znakiem specjalnym wewnątrz nawiasów kwadratowych, czyli dokładnie odwrotnie, niż zakłada pythonowy wzorzec. Dwa silniki, jeden napis, zero komunikatu o błędzie.
Poprawką nie jest sprytniejszy wzorzec. Poprawką jest odmowa pisania wzorca dla silnika, którego nie sprawdziłem:
pos = text_widget.search('[-> ', idx) # literał, niezależny od silnika
m = re.match(r'\[-> ([^\]]+)\]', line_text) # Python parsuje Pythona
Uruchomił destrukcyjną komendę, a skrypt czyszczący zjadł 38 przecinków
Przy porządkowaniu dwóch lokalnych commitów asystent uruchomił git reset --hard, co skasowało też cały niezacommitowany dorobek tamtego dnia. Dało się to odzyskać wyłącznie dlatego, że godzinę wcześniej powstała pełna kopia folderu.
Wcześniej przebieg czyszczący interpunkcję w piętnastu plikach HTML tej właśnie strony usunął przecinek po trzydziestu ośmiu znacznikach zamykających. "Konflikty sterowników, resztki paczek GPU" zmieniło się w "Konflikty sterownikówresztki paczek GPU". Nic się nie wywaliło. Każda strona renderowała się bez zarzutu. Złapałem to tylko dlatego, że porównałem wynik, zamiast wierzyć kodowi wyjścia.
Zauważ, czego żadna z tych rzeczy nie zrobiła. Żadna nie rzuciła wyjątku. Żadna nie wyprodukowała śladu stosu. Każda dała wynik, który wyglądał poprawnie, a dwie dały wynik pewniej błędny, niż byłoby nierobienie niczego.
To nie jest argument przeciwko takiej pracy. Nadal tak pracuję i ten projekt idzie dzięki temu szybciej. To jest argument o tym, gdzie musi odbywać się recenzja. Generowany kod jest płynny z definicji. Kompiluje się, dobrze się czyta, używa właściwych nazw funkcji. Płynność to nie poprawność, a to właśnie płynność sprawia, że różnica jest niewidoczna.
Jeśli przez to cały artykuł staje się dla ciebie podejrzany, to dobry instynkt. Dwie odpowiedzi. Po pierwsze, te sześć awarii leży w publicznej historii commitów z datami, więc sprawdź jedną. Po drugie, spójrz jeszcze raz na ramkę z korektą w sekcji drugiej: ten tekst zmienił twierdzenie, które opublikowałem trzy razy, bo pisanie go zmusiło mnie do wykonania kontroli. To jest metoda działająca na twoich oczach, a nie obietnica, że działa zawsze.
Pięć kontroli, które naprawdę to łapią
Nic z tego nie jest sprytne. Wszystko wzięło się z poparzenia.
1. Sprawdzaj, że praca się wydarzyła, a nie że wynik wygląda dobrze
# Słabo: przechodzi, gdy odczyt jest pusty, czyli DOKŁADNIE przy błędzie
def test_no_false_alarm():
assert not monitor.check(temps={}).warned
# Mocniej: przedmiotem testu jest sam odczyt
def test_temperature_source_returns_data():
reading = sensors.read_cpu_temp()
assert reading is not None
assert 0 < reading < 150
Jeśli test przeszedłby także na maszynie z wyłączoną funkcją, to nie jest test tej funkcji. Dla przycisku zapisu sprawdzaj, że po kliknięciu plik istnieje na dysku i wczytuje się z powrotem do tego, co zapisałeś.
2. Spraw, by cisza kosztowała
except Exception as e:
log_event("learning_context_failed", repr(e)) # nigdy po cichu
ctx = None
Jedna linijka. Alternatywa kosztowała cztery miesiące. Jeśli naprawdę chcesz zignorować błąd, ignoruj nazwany wyjątek i zostaw komentarz, który wyjątek i dlaczego.
3. Pytaj, jaki silnik naprawdę to wykonuje
Wyrażenie regularne przeszło w teście i poległo w widgecie, bo ten sam napis oceniały dwa różne silniki. Zanim uwierzysz zielonemu testowi, zapytaj, czy sprawdza to samo środowisko uruchomieniowe, na które trafi użytkownik. Test, który zaślepia granicę budzącą twoją niepewność, testuje twoją zaślepkę.
4. Napisz zapadkę tego samego dnia
Kiedy znajdziesz taki błąd, poprawka to połowa roboty. Druga połowa to test, który wywali build, jeśli błąd kiedykolwiek wróci. Nazywam je zapadkami, bo obracają się tylko w jedną stronę, a powód wpisuję w treść komunikatu błędu, żeby przyszły ja dostał instrukcję zamiast czerwonej linii. Trzy z tego repozytorium:
tests/test_navigation.pybuduje prawdziwe główne okno i klika każdą pozycję menu, bo sprzątanie konsoli zepsuło kiedyś całą nawigację, a cały zestaw testów pozostał zielony.tests/test_shortcuts.pyprzypina identyfikator ze Store, przez który skrót na pulpicie nic nie otwierał.tests/test_version.pywywala build przy każdym zahardkodowanym numerze wersji, bo wersja mieszkała kiedyś w ośmiu plikach i zdążyła się rozjechać.
Zestaw testów urósł z 21 w czerwcu do 331 dzisiaj. Prawie żaden z nich nie wziął się z planowania. Wzięły się z błędów takich jak powyższe.
5. Kliknij to, a potem sprawdź wynik zamiast kodu wyjścia
Moje najbardziej upokarzające pięć minut przyszło po refaktoryzacji, która rozbiła jeden duży moduł na siedem. Wszystkie testy automatyczne przeszły. Potem otworzyłem aplikację, kliknąłem pozycję w menu bocznym i każda strona po cichu wracała na pulpit główny. Ani jeden test nie budował prawdziwego okna.
Ta sama zasada dotyczy każdej masowej zmiany: skrypt od przecinków "zadziałał", wpisy producentów "zadziałały", wszystkie sześć błędów z początku artykułu "zadziałało". Po masowej edycji otwórz wynik i coś policz. Dwa nawyki z tej samej rodziny, oba wyuczone boleśnie:
- Mierz pierwszą rozbieżność, nie szkodę na końcu. Spędziłem trzy dni nad błędem odtwarzania, mierząc, jak daleko od siebie kończą dwa przebiegi. Ta liczba mówi tylko o rozmiarze szkody. Logowanie pierwszego ticku, w którym przebiegi przestały się zgadzać, zamieniło wieczory w minuty.
- Ufaj sondom, nie nazwom. Program musi wykryć folder instalacji tylko do odczytu, żeby wiedzieć, gdzie trzymać dane użytkownika. Pierwsza wersja sprawdzała, czy ścieżka zawiera
WindowsApps. Sprawdzenie nazwy to domysł o świecie. Próba zapisu to fakt o świecie.
Dlaczego jedna osoba tego nie widzi
Buduję sam. Nie ma recenzenta, nie ma nikogo, kto powie "chwila, sprawdziłeś, czy to naprawdę się zapisało?". Powyższe błędy nie przetrwały dlatego, że były subtelne. Kilka było oczywistych. Przetrwały, bo mogła je zauważyć dokładnie jedna osoba, a ta osoba zdążyła się już przekonać, że funkcja działa.
Nie piszesz testu do funkcji, o której już wierzysz, że działa.
To zdanie jest całym artykułem i nie jest problemem technicznym. Jest problemem bycia jedynym świadkiem. Mam 22 lata, mieszkam w Radomiu, po technikum douczyłem się reszty sam, a przed tym projektem umarło dwanaście innych. Laptop, na którym powstała większość tego kodu, jest z 2014 roku i pod obciążeniem dochodzi do 94 stopni. Nie robi się uważnej recenzji o północy po dwunastogodzinnej zmianie. Robi się to, co daje poczucie skończenia, a wszystko z pierwszej połowy tego artykułu to jest właśnie "skończone" oglądane pół roku później.
Pomagają trzy rzeczy i żadna z nich nie jest dyscypliną.
Pisz publicznie. Co tydzień publikuję, co wyszło i co się zepsuło. Tłumaczenie funkcji obcym ludziom zmusza mnie do opisania, co ona naprawdę robi, a różnica między opisem a zachowaniem to dokładnie miejsce, gdzie mieszkają te błędy. Więcej niż jedną z tych sześciu znalazłem, pisząc o niej wpis, a korekta z sekcji drugiej powstała przy pisaniu tego tekstu.
Wydawaj ludziom, którzy nic ci nie są winni. Testerzy znaleźli rzeczy, których ja nie mogłem, nie dlatego, że są lepszymi inżynierami, tylko dlatego, że nie zdecydowali z góry, co ten program robi. Jeden odpalił go na Windows 11, napisał, że okno konsoli diagnostycznej się nie zamyka, i nie przyjął wyjaśnienia, że u mnie się zamyka. Miał rację. Wywołanie kończyło się sukcesem i nie robiło nic.
Prowadź dziennik. Nie dla publiczności, tylko dla siebie za pół roku. Wszystko tutaj wyszło z prywatnego devloga i publicznego changeloga, a przysiągłbym przed sądem, że połowa tego nigdy się nie wydarzyła.
Moja pierwsza jednogwiazdkowa recenzja mówiła, że interfejs jest zbyt techniczny, a próg wejścia wysoki. Przeczytałem ją trzy razy i zgodziłem się z każdym słowem. Nauczyła mnie więcej niż większość dobrych tygodni.
Co robi z tym program
To wszystko przeciekło do produktu, bo optymalizator jest dokładnie takim rodzajem oprogramowania, w którym cicha awaria jest niewidoczna z definicji. Klikasz, pasek się animuje, komunikat mówi gotowe. Nikt nie mierzy. Dlatego PC Workman robi teraz zdjęcie stanu przed działaniem, drugie dwadzieścia sekund później i trzyma oba:
To dwanaście linijek hydrauliki, a zmienia uczciwość całej funkcji. Jeśli działanie nic nie dało, paragon to pokaże, i wolę wydać to niż kolejny zielony ptaszek. Ta sama zasada idzie przez resztę programu: werdykt o temperaturze mówi, z jakim obciążeniem porównał, skan procesów mówi "nazwa, ścieżka i podpis zgadzają się ze znanym procesem" zamiast "bezpieczny", a odczyt, który jest oszacowaniem, a nie pomiarem z czujnika, nigdy nie wchodzi do historii uczenia.
Pytania, które padają przy tym temacie
Czym jest cicha awaria w oprogramowaniu?
Cicha awaria to fałszywa deklaracja sukcesu. Kod się wykonuje, kończy normalnie, nic nie loguje i melduje, że zadanie zostało wykonane, podczas gdy praca nigdy się nie odbyła. To nie jest brak wyniku, tylko wynik uspokajający i nieprawdziwy. Dlatego nikt tego nie zgłasza i dlatego taki błąd potrafi przetrwać miesiące w wydanym produkcie.
Dlaczego gołe except: pass jest niebezpieczne w Pythonie?
Gołe except łapie wszystko, łącznie z NameError i AttributeError z własnych literówek, a pass wyrzuca dowód do kosza. PEP 8 ostrzega, że gołe except przechwytuje także SystemExit i KeyboardInterrupt oraz potrafi zamaskować inne problemy, i zaleca wskazywanie konkretnych wyjątków. W PC Workmanie takie except ukrywało NameError przy każdej wiadomości czatu przez cztery miesiące, więc cały podsystem uczenia nie był wołany, a nic nigdzie tego nie zgłosiło.
Jak testować błąd, który już przechodzi wszystkie testy?
Sprawdzaj, czy praca się wydarzyła, a nie tylko czy wynik wygląda rozsądnie. Testuj efekt uboczny: że plik istnieje na dysku po kliknięciu Zastosuj, że odczyt ma wartość w sensownym zakresie zamiast pustego kontenera, że identyfikator w dwóch plikach nadal się zgadza. Dobra zasada: jeśli test przeszedłby także na maszynie z wyłączoną funkcją, to ten test nie sprawdza tej funkcji.
Jesteśmy coraz lepsi w produkowaniu kodu, który się dobrze czyta, i coraz szybsi w jego produkowaniu. Żadna z tych rzeczy nie zwiększa szansy, że kod robi to, co miałeś na myśli.
Nawyk wart wyrobienia jest więc mały i staroświecki. Nie przyjmuj wyniku jako dowodu działania. Ani od swojego kodu, ani od swoich narzędzi, ani od czegokolwiek, co generuje dla ciebie tekst, ani od siebie samego o północy.
Poproś o paragon.
Źródła sprawdzone przy pisaniu: PEP 8 · dokumentacja psutil · Tcl re_syntax · Tk text search · Microsoft, AppUserModelIDs · Microsoft, Get-AuthenticodeSignature