
십이 분 만에 태어나고 고쳐지다
GeekBye v1.8.10의 changelog는 키보드 단축키를 편집하는 중의 크래시를 고쳤다고 말합니다. 고친 것은 사실입니다 — 하지만 그 크래시는 같은 풀 리퀘스트 안에서, 십이 분 간격을 두고, 도입되고 또 고쳐졌으며, 단 한 명의 사용자에게도 닿지 않았습니다. 진짜 이야기는 그것을 낳은 신뢰성의 연쇄입니다: 단축키를 언제 일시 중단할지에 대한 작고 올바른 변경과, 에디터를 더 안전하게 만들려던 한 줄에서 굴러떨어진 React 해체(teardown) 버그.
GeekBye v1.8.10에 대해 내가 쓸 수 있는 가장 정직한 한 문장은 이것입니다: 그것이 고친 것으로 이름난 크래시는 그 누구에게도 결코 일어나지 않았습니다. changelog는 "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," 라고 말하고, 그것은 사실이며, 출시되어 사용자를 물고 뒤이어 패치된 버그처럼 읽힙니다. 그렇지 않았습니다. 그 크래시는 같은 금요일 아침, 같은 풀 리퀘스트 안에서, 십이 분 간격을 두고, 한 커밋에 의해 도입되고 다른 커밋에 의해 삭제되었습니다. 그것은 통째로 한 브랜치 안에서 살았습니다. 릴리스 노트는 리포지토리 바깥의 누구도 볼 수 있기 전에 형성되고 아문 흉터를 기술하고 있는 것입니다.
그것은 changelog의 실패가 아닙니다; 그것은 반복적 작업의 정직한 모습이고, 말할 값어치가 있는 이유는 그 십이 분이 진정으로 교훈적인 React 버그를 품고 있기 때문입니다. 하지만 그 크래시를 이해하려면 이 릴리스가 실제로 무엇을 위한 것이었는지를 이해해야 합니다 — 왜냐하면 그 크래시는 다른 무언가를 고친 것의 부수 피해였기 때문입니다.
이 릴리스가 실제로 다룬 것
GeekBye는 설정에서 키보드 단축키를 다시 지정하게 해 줍니다. 새 조합을 기록하는 동안, 앱은 그 글로벌 단축키가 발동하는 것을 멈춰야 합니다 — 그렇지 않으면 Cmd+B를 눌러 그것을 할당하려 해도, 그것은 캡처되는 대신 그저 창을 토글할 뿐이 됩니다. 그래서 에디터는 IPC 너머로 글로벌 단축키를 일시 중단합니다: 렌더러가 window.electronAPI.setShortcutsSuspended(true)를 부르고, 메인 프로세스의 ShortcutsHelper가 unregisterAll()을 불러 응답해 모든 globalShortcut 등록을 떨어뜨리므로, 키 입력이 액션을 일으키는 대신 캡처 입력에 닿습니다. 재개 시에는 그것들을 되돌리려 registerGlobalShortcuts()를 부릅니다.
이 모든 것을 시작한 버그(issue #233)는 그 일시 중단의 _범위_에 관한 것이었습니다. 옛 코드는 당신이 실제로 키를 기록하는 동안만이 아니라 — 단축키 설정 페이지가 열려 있는 내내 단축키를 일시 중단했습니다. 대개는 그것이 보이지 않습니다. 하지만 페이지를 열고, 다른 일을 하기 시작하고, 편집을 한 번도 끝내지 않은 채 벗어나 버리면, 당신이 돌아갈 때까지 앱은 글로벌 단축키가 모두 등록 해제된 채 — 조용히 죽은 채 — 남겨질 수 있었습니다. 그것을 고치는 커밋은 증상에 대해 솔직합니다: 너무 넓게 일시 중단하는 것은 "making the app appear broken if the user forgot they had the page open." 의 위험을 무릅쓰고 있었다고.
수정은 한 줄의 좁힘입니다: 방문 전체 동안이 아니라, 한 행이 실제로 편집되고 있을 때만 일시 중단하기 — 당신이 기록 중인 행의 id를 담은 훅 상태, !!editingShortcut을 키로 삼아. 좋은 변경입니다. 하지만 「편집 중일 때만 일시 중단하기」는 즉각 물음을 일으킵니다: 무엇이 편집의 _완료_로 치는가? 키를 누르면 그것을 완료합니다. Escape를 누르면 그것을 취소합니다. 그런데 만약 당신이 그냥… 딴 데를 클릭해 벗어나거나, 캡처 도중에 통째로 다른 앱으로 바꿔 버리면? 아무것도 편집을 취소하지 않으면, 당신은 포커스되지 않은 창에 단축키를 기록하고 있게 되고, 단축키는 일시 중단된 채 남습니다. 그래서 같은 PR은 안전 밸브를 더했습니다: 포커스가 떠나면 편집을 취소하기. 그리고 그것이 크래시한 그 줄입니다.
십이 분짜리 버그
안전 밸브는 ShortcutsSettings.tsx 안의 useEffect로, 한 행이 편집되는 동안 blur를 듣고 있었습니다:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
리스너 둘. 하나는 window에 — 다른 앱으로 바꿀 때 발생합니다. 하나는 직접, 생 DOM 리스너로서, 캡처용 <input>에 붙었고 — 다른 요소로 클릭해 벗어날 때 발생하게 되어 있었습니다. 그것들은 중복되고 무해해 보입니다. 그렇지 않습니다.
바로 changelog의 그 문구인 "switching windows mid-edit," 의 순서를 따라가 봅시다:
- 당신은 단축키를 기록하고 있다;
<input>은 포커스되어 있다. 당신은 Cmd+Tab으로 다른 앱으로 간다. - 창의 blur가 발생한다.
handleBlur가cancelEditing()을 돌리고, 그것이editingShortcut을 다시null로 설정한다. - 그 상태를 설정하면
<input>이 언마운트된다 — 행이 편집 모드를 벗어나므로, 입력이 DOM에서 제거된다. - 포커스된 요소를 DOM에서 제거하면 동기적으로 그 요소에서
blur이벤트가 디스패치된다. 생inputRef리스너가 그것을 잡아cancelEditing()을 다시 부른다 — 이번에는 React의 commit 단계 한가운데, 컴포넌트 트리가 해체되고 있는 동안에. - 해체 중인 fiber에서 상태 세터를 부르면 React의 내부 불변식 중 하나에 걸려, 던진다:
"Should have a queue". 설정 뷰가 크래시한다.
버그는 창 리스너도 아니고 사실 「blur에서 취소하기」라는 개념도 아닙니다. 두 리스너 중 하나가 **React가 관리하는 요소에 붙은 생 addEventListener**였다는 것, 그것이 버그입니다. React는 그 리스너를 알지 못하고, 언마운트 시 그것을 정리하지 않으며, 그리고 — 결정적으로 — 브라우저는 React가 바로 수행하고 있는 DOM 제거 도중에 blur를 발생시키므로, 핸들러는 가능한 최악의 순간에 당신의 상태 로직으로 재진입합니다. 수정 커밋의 메시지는 내가 말할 수 있는 것보다 정확히 그것을 말합니다: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
수정
bf28a50은, 크래시가 태어난 지 십이 분 뒤에, 두 가지 작은 일을 합니다.
첫째, 그것은 생 입력 리스너를 지우고 창의 것만 남깁니다. 그것이 애당초 수동 DOM 리스너여야 했던 유일한 것입니다(「창 전체가 포커스를 잃었다」에 대응하는 React onBlur는 없습니다):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
둘째, 그것은 클릭해 벗어나는 경우 — 「사용자가 다른 요소를 클릭했다」 — 를 입력에 대한 React 자신의 onBlur prop으로 옮기고, 그것을 한 tick 지연시킵니다:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
두 부분 다 중요합니다. 생 리스너 대신 React의 onBlur prop을 쓴다는 것은 React가 핸들러의 생명주기를 소유하고 그것을 매달린 채 두지 않는다는 뜻입니다. 그리고 setTimeout(…, 0)은 이 blur가 언마운트에 의해 촉발된 경우조차, cancelEditing() 호출이 다음 tick — 현재의 렌더와 commit이 끝난 뒤 — 에 착지함을 보장하므로, 그것은 아직 해체 중인 컴포넌트에서 상태를 갱신하는 일이 결코 없습니다. 만약 그때까지 컴포넌트가 이미 사라졌다면, 지연된 호출은 무해한 no-op입니다.
그것이 수정의 전부입니다. React가 볼 수 없던 리스너를 지운다; 남은 하나는 React가 스케줄하게 둔다.
왜 이것이 좋은 종류의 지루함인가
이것을 「사소함」 아래로 — 하나의 setTimeout, 지워진 한 줄 — 분류하고 넘어가고 싶은 유혹이 있습니다. 하지만 그 모양은 React 데스크톱 앱이 크래시하는 가장 흔한 방식 중 하나이고, 몸에 배게 할 값어치가 있습니다:
- 크래시는 오직 생명주기 경계(언마운트)에서만 나타나고, 프레임워크가 스케줄하지 않는 이벤트(DOM 제거 도중의 네이티브 blur)에 의해 촉발됩니다. 그것은 클릭 스루 테스트에는 나타나지 않습니다; 그것은 「편집 중에 포커스가 떠난다」는 특정 경로를 필요로 합니다.
- 근본 원인은 같은 요소 위에서 두 소유 모델을 섞은 것 — 생 DOM 이벤트와, React의 합성된, 스케줄된 이벤트 — 입니다. 당신의 핸들러가 그 요소를 언마운트할 수 있는 상태를 변경하는 순간, 생 경로는 재진입 가능(re-entrant)해집니다.
- 수정은 「가드 플래그를 더하는 것」이 아닙니다(
isMountedref가 그것을 가려 주기는 했겠지만); 그것은 생 리스너를 통째로 없애는 것이고, 그것이 더 작은 변경이며 올바른 것입니다. 또다시 뺄셈입니다.
그리고 메타 교훈, 이 시리즈 전체가 계속 맴도는 그것: 릴리스 노트가 아니라 diff를 읽어라. "Fixed a crash while editing keyboard shortcuts" 는 정확하지만, 그것은 그 크래시가 스스로 자초한 것이고, 같은 아침의 일이고, 결코 출시되지 않았다는 것 — 그리고 진짜 릴리스는 글로벌 단축키를 언제 일시 중단할지에 대한 조용한 정확성의 승리라는 것 — 을 숨깁니다.
그 밖에 무엇이 출시되었나
두 가지가 더 v1.8.10에 얹혀 왔습니다, 둘 다 한 줄의 값어치가 있습니다. 단축키 시스템은 또한 잠들기/깨어나기를 가로지르는 자가 치유를 얻었습니다: macOS는 기계가 잠들 때 앱의 글로벌 단축키 등록을 조용히 떨어뜨릴 수 있어, 이제 재개 핸들러가 그것들을 재검증하고 재등록하므로, 노트북이 깨어난 뒤에도 재실행 없이 당신의 단축키가 여전히 동작한다는 뜻입니다. 그리고 AI 쪽에서는, 클라이언트가 속도 제한에 대해 똑똑해졌습니다 — 429에 부딪힌 스트리밍 요청은 이제 고정된 지연을 기다려 넘기는 대신, 지수적으로 백오프하고 당신이 기록을 멈추면 카운터를 리셋합니다. 서로 다른 서브시스템에 대한 세 개의 신뢰성 수정, 그중 하나가 결코 크래시가 아니었던 크래시입니다.
v1 이야기의 이전 장은 PDF 라이브러리 없이 회의를 PDF로 인쇄하기(v1.8.9)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.