
Проверката за безопасност, която направи приложението ни невъзможно за спиране
Автоматичното обновяване беше най-трудната функция, която някога сме пускали — шест издания за четири дни, за да спрем то да убива приложението. Най-лошата грешка беше тази, която сами въведохме, опитвайки се да сме внимателни: 500-милисекундна "предпазна" проверка, която превърна неуспешно обновяване в процес, който буквално не можеше да бъде спрян.
Всеки разработчик на настолни приложения подценява автоматичното обновяване точно веднъж. Изглежда като решен проблем — библиотека изтегля нова версия и рестартира приложението ти. После го пускаш и научаваш, че "рестартирай приложението си" е едно от най-опасните неща, за които една програма може да бъде помолена, защото се случва в точния момент, в който приложението ти се разрушава и има най-малко резерв за грешки.
Автоматичното обновяване на GeekBye отне шест издания за четири дни — от v1.5.14 до v1.5.19 — за да се стабилизира. Това е историята на най-лошата грешка в този период, която сами причинихме, опитвайки се да сме внимателни.
Шест издания и това, което имаше значение
Всичко започна обикновено. v1.5.14 поправи неудобна грешка от класа на печатните: емисията за обновяване сочеше към име на GitHub хранилище, което не съществуваше, така че обновителят проверяваше 404. v1.5.15 добави ръчен бутон "Check for Updates" и истинско съобщение за грешка. После започнаха грешките с quitAndInstall и изданията заваляха бързо — защото когато механизмът ти за обновяване е счупен, не можеш да пуснеш поправката за него чрез механизма за обновяване. Всяка итерация е залог за ръчна преинсталация.
Това, което има значение, е v1.5.18. Цялото ѝ съдържание беше един-единствен комит със заглавие, което все още ме кара да трепна: restore original quitAndInstall behavior to prevent unkillable app.
Как "внимателността" направи приложението неизползваемо
Ето постановката. Когато обновяване бъде изтеглено, quitAndInstall на Electron би трябвало да затвори приложението и да го смени с новата версия. В по-ранно издание някой — резонно — се притесни, че спирането безусловно е рисковано. Ами ако инсталацията даде грешка? Нямаше ли да е по-безопасно да се спре само ако всичко изглежда наред?
Затова кодът се сдоби със защита, която изглеждаше разумна:
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)
Логиката: задей инсталацията, изчакай половин секунда и наложи окончателното app.quit() само ако флагът updateDownloaded все още е истина — иначе остави приложението отворено, за да не остане потребителят блокиран.
Капанът е на един ред разстояние, в обработчика на грешки. Този обработчик задаваше this.updateDownloaded = false. Затова си представи неуспешна инсталация: събитието error се задейства и изчиства флага. Но quitAndInstall вече беше започнал разрушаването — беше затворил прозорците и премахнал слушателите за спиране на приложението. После 500ms таймерът се събужда, проверява вече-неистинния флаг, решава "открита е грешка, остави приложението отворено" и пропуска app.quit().
Сега имаш, конкретно на macOS, най-лошото възможно състояние. macOS не спира приложение само защото последният му прозорец се е затворил — това е поведението window-all-closed, на което разчита всяко Mac приложение. Така че процесът е все още жив, но има никакъв прозорец, никакъв път през лентата с менюта и премахнати слушатели за спиране. Няма върху какво да се щракне. Cmd-Q няма с какво да говори. Единственият изход е Force Quit от Activity Monitor. "Предпазната" проверка беше превърнала неуспешно обновяване — досадна, но поправима ситуация — в зомби, което не можеш да убиеш.
Поправката: разрушаването трябва да е безусловно
Корекцията във v1.5.18 е почти агресивно скучна, което е и целта. Тя премахва мъдруването:
- Премахни слушателите
window-all-closedиbefore-quit, които биха могли да пречат. - Унищожи всеки прозорец —
window.destroy(), неwindow.close(). Close може да бъде отхвърлено от обработчик; destroy не може. Когато си се ангажирал да спираш, не питаш учтиво. - Извикай
quitAndInstall. - Извикай
app.quit()безусловно.
Никакъв флаг, никакъв таймер, никакво "остави го отворено за всеки случай." Защото истината за пътя на спиране е, че наполовина завършено спиране е по-лошо от която и да е от двете крайности. Пълното спиране е наред. Пълното оставане отворено е наред. Единственото състояние, до което никога не бива да достигаш, е разрушено, но все още работещо — и точно това е състоянието, в което условното спиране може да те остави блокиран.
Още два урока, на които същата седмица научи
Невъзможната за спиране грешка е водеща, но паническото препускане през шест издания затвърди два други навика, които си струва да откраднеш.
Филтрирай телеметрията си за сривове по точни сигнатури, а не по широки ключови думи. По средата на препускането открихме, че отчитането ни на грешки беше конфигурирано да отхвърля всичко, съдържащо думи като permission, token или microphone — опит да се реже шум, който тихо поглъщаше реални сривове, случайно споменаващи тези думи. Изтръгнахме одеалните филтри и ги заменихме с точни низове за отказ (конкретното съобщение, което macOS издава при отказано разрешение) и специфични временни мрежови кодове като ERR_NETWORK_CHANGED. Намаляването на шума и скриването на грешки са едно и също копче, завъртяно в противоположни посоки; ако филтрираш по усещане, ще филтрираш точно нещото, което е трябвало да видиш.
Всеки автоматичен път се нуждае от ръчен авариен изход. Автоматичното обновяване по природа е "най-добро усилие" — мрежите се дъним, инсталациите се провалят. Затова всеки режим на провал получи човешки резервен вариант: ръчният бутон "Check for Updates", повторни опити с експоненциално отдръпване, таймер за повторна проверка и — запазено конкретно за случая, когато ръчният опит на потребителя се провали — съобщение на прост език "изтрий приложението и преинсталирай от уебсайта". Автоматичният път е удобство; ръчният път е гаранцията.
Изводът
- Защита около необратимо действие е по-опасна от самото действие. Условното спиране се опитваше да предотврати неуспешно обновяване да спре приложението и вместо това създаде състояние, по-лошо и от спирането, и от неспирането. Пътищата за спиране и инсталация трябва да бъдат безусловни и идемпотентни — никога зависими от променлив флаг, който друг обработчик може да обърне изпод теб.
- На macOS "без прозорци" не значи "без приложение." Всяка логика за разрушаване трябва да отчита платформата, където процес без прозорци продължава да работи. Тествай пътя на провала, на реалната ОС, а не само щастливия път.
- Функцията, която пускаш чрез системата за обновяване, не може да бъде тествана чрез системата за обновяване. Тази асиметрия е причината автоматичното обновяване да заслужава параноичен, безусловен, силно ръчно проверен код. Получаваш възможността да го поправиш по лесния начин чак след като то вече работи.
Това е най-ранната глава от работата по надеждността, която в крайна сметка стана GeekBye v2. За това докъде доведе този път, виж какво всъщност изисква една втора версия (v2.0.0) и цялата дъга в анатомията на пускането на софтуер до съвършенство.