
Проверка безопасности, из-за которой приложение стало невозможно закрыть
Автообновление оказалось самой трудной функцией, которую мы когда-либо выпускали, — шесть релизов за четыре дня, чтобы оно перестало ломать приложение. Худший баг мы внесли сами, пытаясь быть осторожными: 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) и всю серию в анатомии доведения софта до совершенства.