
Перевірка безпеки, через яку застосунок стало неможливо закрити
Автооновлення виявилося найважчою функцією, яку ми будь-коли випускали, — шість релізів за чотири дні, щоб воно перестало ламати застосунок. Найгіршу ваду ми внесли самі, намагаючись бути обережними: 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 вже почав розбирання — він закрив вікна і видалив слухачів закриття застосунку. Потім прокидається 500-мс таймер, перевіряє тепер-хибний прапорець, вирішує «виявлено помилку, лишити застосунок відкритим» і пропускає 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(). Закриття може відхилити обробник; знищення — ні. Коли ти зобов'язуєшся вимкнутися, ти не питаєш чемно. - Викликати
quitAndInstall. - Викликати
app.quit()безумовно.
Жодного прапорця, жодного таймера, жодного «лишимо відкритим про всяк випадок». Бо правда про гілку вимкнення така: наполовину завершене вимкнення гірше за будь-який із результатів. Повністю закритися — нормально. Повністю лишитися відкритим — нормально. Єдиний стан, якого не можна досягати ніколи, — це розібраний, але все ще працює, — і це саме той стан, у якому тебе може застрягти умовне закриття.
Ще два уроки того самого тижня
Невбивана вада — це заголовок, але шестирелізова гонка загартувала ще дві звички, які варто вкрасти.
Фільтруй телеметрію збоїв за точними сигнатурами, а не за широкими ключовими словами. У розпал гонки ми виявили, що наша звітність про помилки була налаштована відкидати все, що містило слова на кшталт permission, token чи microphone, — спроба зрізати шум, яка тихо ковтала справжні збої, що випадково згадували ці слова. Ми видерли ковдрові фільтри й замінили їх точними рядками відмови (конкретне повідомлення, яке macOS видає, коли дозвіл відхилено) та конкретними транзієнтними мережевими кодами на кшталт ERR_NETWORK_CHANGED. Зниження шуму й приховування багів — це та сама ручка, повернута в протилежні боки; якщо фільтруєш на відчуття, відфільтруєш саме те, що треба було побачити.
Кожній автоматичній гілці потрібен ручний запасний вихід. Автооновлення за своєю природою робиться в міру можливого — мережі збоять, встановлення падають. Тож кожен режим відмови отримав людський запасний варіант: ручна кнопка «Check for Updates», повтори з експоненційною витримкою, таймер повторної перевірки та — зарезервоване саме для випадку, коли ручна спроба користувача провалилася, — повідомлення простими словами «видали застосунок і перевстанови із сайту». Автоматична гілка — це зручність; ручна гілка — це гарантія.
Висновок
- Захист навколо незворотної дії небезпечніший за саму дію. Умовне закриття намагалося завадити поганому оновленню закрити застосунок, а натомість створило стан гірший, ніж і закриття, і незакриття. Гілки вимкнення й встановлення мають бути безумовними та ідемпотентними — ніколи не залежати від змінюваного прапорця, який інший обробник може висмикнути тобі з-під ніг.
- На macOS «немає вікон» — це не «немає застосунку». Будь-яка логіка розбирання зобов'язана враховувати платформу, де процес без вікон продовжує працювати. Тестуй гілку відмови на справжній ОС, а не лише щасливу гілку.
- Функцію, яку ти випускаєш через систему оновлення, неможливо протестувати через систему оновлення. Саме ця асиметрія і є причиною, чому автооновлення заслуговує на параноїдальний, безумовний, ретельно перевірений вручну код. Легкий спосіб полагодити ти отримуєш лише після того, як воно вже працює.
Це найраніший розділ роботи над надійністю, яка зрештою стала GeekBye v2. Про те, куди привела ця дорога, читай скільки насправді коштує версія 2 (v2.0.0) і всю серію в анатомії доведення софту до досконалості.