Steven
Steven7 мин. чтения

Рождён и исправлен за двенадцать минут

Changelog GeekBye v1.8.10 говорит, что исправлена авария при редактировании клавиатурных сочетаний. Так и есть — но авария была внесена и исправлена в одном и том же пул-реквесте, с разницей в двенадцать минут, и не добралась ни до одного пользователя. Настоящая история — это каскад надёжности, что её породил: маленькое, верное изменение того, когда сочетания приостанавливаются, и баг разборки React, что выпал из одной строки, призванной сделать редактор безопаснее.

Инженерия
React
Десктоп
Релизы GeekBye
Рождён и исправлен за двенадцать минут

Вот самое честное предложение, что я могу написать о GeekBye v1.8.10: авария, знаменитая тем, что её исправили, ни с кем не случилась. Changelog говорит «Исправлена авария при редактировании клавиатурных сочетаний и переключении окон посреди правки», и это правда, и читается как баг, что вышел, укусил пользователей и был залатан в последующем патче. Не было такого. Аварию внёс один коммит и удалил другой в том же пул-реквесте, с разницей в двенадцать минут, тем же пятничным утром. Она жила целиком внутри ветки. Примечания к релизу описывают шрам, что образовался и зажил прежде, чем кто-либо за пределами репозитория мог его увидеть.

Это не провал changelog; это честная форма итеративной работы, и о ней стоит рассказать, потому что эти двенадцать минут содержат по-настоящему поучительный баг React. Но чтобы понять аварию, надо понять, ради чего вообще был этот релиз, — потому что авария была побочным уроном от исправления кое-чего другого.

О чём на самом деле был этот релиз

GeekBye позволяет переназначать свои клавиатурные сочетания в Настройках. Пока ты записываешь новую комбинацию, приложение должно не дать своим глобальным сочетаниям сработать — иначе нажатие Cmd+B, чтобы его назначить, просто переключило бы окно вместо того, чтобы быть захваченным. Так что редактор приостанавливает глобальные сочетания через IPC: рендерер вызывает window.electronAPI.setShortcutsSuspended(true), а ShortcutsHelper главного процесса отвечает вызовом unregisterAll(), сбрасывая каждую регистрацию globalShortcut, чтобы нажатие клавиши дошло до поля захвата, а не запустило действие. При возобновлении он вызывает registerGlobalShortcuts(), чтобы вернуть их на место.

Баг, что всё это начал (issue #233), был про область этой приостановки. Старый код приостанавливал сочетания на всё время, пока была открыта страница настроек сочетаний, — а не только пока ты активно записывал клавишу. Большую часть времени это незаметно. Но если ты открыл страницу, начал делать что-то другое и ушёл, так и не закончив правку, приложение могло остаться со всеми своими глобальными сочетаниями незарегистрированными — тихо мёртвыми — пока ты не вернёшься. Коммит, что это исправляет, откровенен насчёт симптома: слишком широкая приостановка рисковала «сделать так, что приложение выглядит сломанным, если пользователь забыл, что у него открыта страница».

Исправление — однострочное сужение: приостанавливай только тогда, когда строка действительно редактируется, — завязано на !!editingShortcut, состоянии хука, что держит id записываемой строки, — вместо всего визита. Хорошее изменение. Но «приостанавливай только во время редактирования» рождает немедленный вопрос: что считается завершением правки? Нажатие клавиши её завершает. Нажатие Escape её отменяет. Но что, если ты просто… кликнешь в сторону или переключишься на другое приложение целиком, посреди захвата? Если ничто не отменяет правку, ты остаёшься записывающим сочетание в окно, что не в фокусе, а сочетания остаются приостановленными. Так что тот же PR добавил предохранитель: отменяй редактирование, когда фокус уходит. И это та строка, что упала.

Двенадцатиминутный баг

Предохранителем был useEffect в ShortcutsSettings.tsx, что, пока строка редактировалась, слушал blur:

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

Два слушателя. Один на window — срабатывает, когда ты переключаешься на другое приложение. Один прицеплен напрямую, как сырой слушатель DOM, к полю захвата <input> — призван срабатывать, когда ты кликаешь в сторону на другой элемент. Они выглядят избыточными и безобидными. Это не так.

Пройди последовательность для «переключения окон посреди правки», что и есть точная фраза из changelog:

  1. Ты записываешь сочетание; <input> в фокусе. Ты жмёшь Cmd+Tab на другое приложение.
  2. Срабатывает blur окна. handleBlur запускает cancelEditing(), что ставит editingShortcut обратно в null.
  3. Установка этого состояния размонтирует <input> — строка выходит из режима правки, так что поле удаляется из DOM.
  4. Удаление сфокусированного элемента из DOM синхронно отправляет событие blur на этом элементе. Сырой слушатель inputRef ловит его и вызывает cancelEditing() снова — на этот раз посреди фазы коммита React, пока дерево компонентов разбирается.
  5. Вызов сеттера состояния на файбере, что в разгаре разборки, задевает один из внутренних инвариантов React, и он выбрасывает: "Should have a queue". Вид настроек падает.

Баг — не оконный слушатель и не сама по себе концепция «отмены по blur». Он в том, что один из двух слушателей был сырым addEventListener на управляемом React элементе. React не знает об этом слушателе, не чистит его при размонтировании и — что критично — браузер запускает blur во время того самого удаления из DOM, что выполняет React, так что обработчик заново входит в твою логику состояния в наихудший возможный миг. Сообщение исправляющего коммита говорит это точнее, чем могу я: «когда cancelEditing размонтирует поле, срабатывает blur и вызывает cancelEditing снова во время цикла рендеринга React».

Исправление

bf28a50, через двенадцать минут после рождения аварии, делает две маленькие вещи.

Во-первых, оно удаляет сырой слушатель поля и оставляет только оконный, что и был единственным, кому вообще нужно было быть ручным слушателем DOM (нет реактовского onBlur для «всё окно потеряло фокус»):

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

Во-вторых, оно переносит случай клика в сторону — «пользователь кликнул другой элемент» — на собственный проп onBlur React на поле и откладывает его на тик:

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

Обе части важны. Использование пропа onBlur React вместо сырого слушателя означает, что React владеет жизненным циклом обработчика и не оставит его болтаться. А setTimeout(…, 0) гарантирует, что даже когда этот blur вызван размонтированием, вызов cancelEditing() приземляется на следующий тик — после того как текущий рендер и коммит завершились, — так что он никогда не может обновить состояние на компоненте, что всё ещё разбирается. Если компонента к тому времени уже нет, отложенный вызов — безобидный no-op.

Вот и всё исправление. Удали слушатель, что React не мог видеть; дай React запланировать тот, что остался.

Почему это хороший сорт скуки

Есть соблазн отправить это в разряд «тривиального» — setTimeout, удалённая строка — и двигаться дальше. Но его форма — один из самых частых способов, какими падают десктопные приложения на React, и её стоит впитать:

  • Авария появляется только на границе жизненного цикла (размонтирование), запущенная событием, что фреймворк не планирует (нативный blur во время удаления из DOM). Она не всплывёт в прощёлкивающем тесте; ей нужен конкретный путь «фокус уходит во время редактирования».
  • Корневая причина — смешивание двух моделей владения — сырых событий DOM и синтетических, планируемых событий React — на одном элементе. В тот миг, когда твой обработчик меняет состояние, что может размонтировать этот элемент, сырой путь становится реентерабельным.
  • Исправление — не «добавь флаг-охранник» (хотя ref isMounted бы это замаскировал); это полное удаление сырого слушателя, что и есть меньшее изменение и верное. Снова вычитание.

И метаурок, тот, вокруг которого кружит вся эта серия: читай дифф, а не примечание к релизу. «Исправлена авария при редактировании клавиатурных сочетаний» — точно, но прячет, что авария была самонанесённой, тем же утром, и так и не вышла, — и что настоящий релиз это тихая победа корректности насчёт того, когда приостанавливать глобальное сочетание.

Что ещё вышло

Ещё две вещи проехались с v1.8.10, обе достойны строки. Система сочетаний также обрела самоисцеление через сон/пробуждение: macOS может тихо сбросить регистрации глобальных сочетаний приложения, когда машина засыпает, так что обработчик возобновления теперь заново их проверяет и регистрирует, а значит твои сочетания всё ещё работают после пробуждения ноутбука без перезапуска. А на стороне AI клиент поумнел насчёт лимитов запросов — потоковые запросы, что попадают на 429, теперь отступают экспоненциально и сбрасывают свой счётчик, когда ты останавливаешь запись, вместо того чтобы выжидать фиксированную задержку. Три исправления надёжности в разных подсистемах, одно из которых — авария, что никогда не была аварией.

Про предыдущую главу истории v1 — печать встречи в PDF без библиотеки PDF (v1.8.9); а про всю дугу — анатомию доведения софта до совершенства.

Похожие статьи

Три глагола, что держат Web Audio живым
Steven
Steven8 мин. чтения

Три глагола, что держат Web Audio живым

Два точечных релиза GeekBye, разделённые двумя месяцами и в двух разных файлах, преподали нашему аудиокоду один и тот же урок с противоположных концов: перестань обращаться с браузерным AudioContext как с одноразовым. Один релиз научился делать resume() контексту, который macOS тихо приостановил посреди записи; другой научился suspend() вместо close(), чтобы идущие подряд сессии перестали врезаться в навязанный Chromium потолок примерно в шесть контекстов. Resume, suspend, close — вот и весь сюжет.

Инженерия
Аудио
Десктоп
Отличить звонок от открытого приложения
Steven
Steven8 мин. чтения

Отличить звонок от открытого приложения

GeekBye умеет заметить, что ты присоединился к видеовстрече, и предложить её записать. Обнаружение оказывается лёгкой половиной — нативный Swift-бинарник читает заголовки окон каждые десять секунд. Трудная половина — точность: не срабатывать, когда Zoom просто открыт, не предлагать записать встречу, которую ты уже записываешь, и не заглушать микрофон в звонке, в котором ты на самом деле сидишь. Три релиза, и каждый из них — предохранитель, которому пришлось научиться не побеждать самого себя.

Инженерия
macOS
Десктоп
Убираем backend с пути загрузки
Steven
Steven7 мин. чтения

Убираем backend с пути загрузки

GeekBye записывает твой экран и сохраняет видео в твой Google Drive. Первая версия прогоняла каждую запись через собственные серверы GeekBye по пути туда; релизом позже файл шёл прямо с твоей машины на Drive, а backend был понижен до хранения единственного указателя. Интересная часть в том, как мало кода на самом деле содержит «прямая, возобновляемая» версия — потому что возобновляемость пришла из удаления proxy, а не из его написания.

Инженерия
Архитектура
Десктоп