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); а про всю дугу — анатомию доведения софта до совершенства.

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

Печать встречи в PDF без библиотеки PDF
Steven
Steven8 мин. чтения

Печать встречи в PDF без библиотеки PDF

GeekBye экспортирует встречу как PDF, и нигде в коде нет библиотеки PDF. Он рендерит HTML в невидимом окне браузера и печатает его. Этот выбор — вся история: он сделал функцию лёгкой в сборке и наделил её каждым сбоем, какой есть у настоящего браузера, — белой вспышкой, лимитом длины URL и разрывом страницы, что разрезал скриншоты пополам. Лекарством от самого уродливого бага стала одна строка CSS.

Инженерия
Electron
Десктоп
От OCR к пикселям, не теряя запасной путь
Steven
Steven7 мин. чтения

От OCR к пикселям, не теряя запасной путь

GeekBye перестал читать твои скриншоты через OCR и начал отправлять само изображение зрячим моделям — и это лёгкая часть. Поучительная часть — оптимизация, которая пропускала OCR целиком для пользователей со зрячей моделью, тихо ломала запасной путь к моделям без зрения и её пришлось откатить до версии, где страховочная сетка бежит параллельно и не стоит ничего.

Инженерия
AI
Десктоп
Два режима отказа накладки, пропускающей клики
Steven
Steven8 мин. чтения

Два режима отказа накладки, пропускающей клики

Окно GeekBye плавает поверх всего и пропускает твои клики насквозь — кроме тех мест, где у него есть кнопки. Это двусторонний контракт, а v1.8.5 и v1.8.14 показывают, как выглядит поломка каждой из сторон: один релиз, где накладка проглотила системный диалог, и другой, где она украла твои нажатия клавиш. Победившим лекарством для второго стало удаление кода.

Инженерия
Electron
Десктоп