Steven
Steven6 분 소요

십이 분 만에 태어나고 고쳐지다

GeekBye v1.8.10의 changelog는 키보드 단축키를 편집하는 중의 크래시를 고쳤다고 말합니다. 고친 것은 사실입니다 — 하지만 그 크래시는 같은 풀 리퀘스트 안에서, 십이 분 간격을 두고, 도입되고 또 고쳐졌으며, 단 한 명의 사용자에게도 닿지 않았습니다. 진짜 이야기는 그것을 낳은 신뢰성의 연쇄입니다: 단축키를 언제 일시 중단할지에 대한 작고 올바른 변경과, 에디터를 더 안전하게 만들려던 한 줄에서 굴러떨어진 React 해체(teardown) 버그.

엔지니어링
React
데스크톱
GeekBye 릴리스
십이 분 만에 태어나고 고쳐지다

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)를 부르고, 메인 프로세스의 ShortcutsHelperunregisterAll()을 불러 응답해 모든 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," 의 순서를 따라가 봅시다:

  1. 당신은 단축키를 기록하고 있다; <input>은 포커스되어 있다. 당신은 Cmd+Tab으로 다른 앱으로 간다.
  2. 의 blur가 발생한다. handleBlurcancelEditing()을 돌리고, 그것이 editingShortcut을 다시 null로 설정한다.
  3. 그 상태를 설정하면 <input>이 언마운트된다 — 행이 편집 모드를 벗어나므로, 입력이 DOM에서 제거된다.
  4. 포커스된 요소를 DOM에서 제거하면 동기적으로 그 요소에서 blur 이벤트가 디스패치된다. inputRef 리스너가 그것을 잡아 cancelEditing()다시 부른다 — 이번에는 React의 commit 단계 한가운데, 컴포넌트 트리가 해체되고 있는 동안에.
  5. 해체 중인 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)해집니다.
  • 수정은 「가드 플래그를 더하는 것」이 아닙니다(isMounted ref가 그것을 가려 주기는 했겠지만); 그것은 생 리스너를 통째로 없애는 것이고, 그것이 더 작은 변경이며 올바른 것입니다. 또다시 뺄셈입니다.

그리고 메타 교훈, 이 시리즈 전체가 계속 맴도는 그것: 릴리스 노트가 아니라 diff를 읽어라. "Fixed a crash while editing keyboard shortcuts" 는 정확하지만, 그것은 그 크래시가 스스로 자초한 것이고, 같은 아침의 일이고, 결코 출시되지 않았다는 것 — 그리고 진짜 릴리스는 글로벌 단축키를 언제 일시 중단할지에 대한 조용한 정확성의 승리라는 것 — 을 숨깁니다.

그 밖에 무엇이 출시되었나

두 가지가 더 v1.8.10에 얹혀 왔습니다, 둘 다 한 줄의 값어치가 있습니다. 단축키 시스템은 또한 잠들기/깨어나기를 가로지르는 자가 치유를 얻었습니다: macOS는 기계가 잠들 때 앱의 글로벌 단축키 등록을 조용히 떨어뜨릴 수 있어, 이제 재개 핸들러가 그것들을 재검증하고 재등록하므로, 노트북이 깨어난 뒤에도 재실행 없이 당신의 단축키가 여전히 동작한다는 뜻입니다. 그리고 AI 쪽에서는, 클라이언트가 속도 제한에 대해 똑똑해졌습니다 — 429에 부딪힌 스트리밍 요청은 이제 고정된 지연을 기다려 넘기는 대신, 지수적으로 백오프하고 당신이 기록을 멈추면 카운터를 리셋합니다. 서로 다른 서브시스템에 대한 세 개의 신뢰성 수정, 그중 하나가 결코 크래시가 아니었던 크래시입니다.

v1 이야기의 이전 장은 PDF 라이브러리 없이 회의를 PDF로 인쇄하기(v1.8.9)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.

관련 글

PDF 라이브러리 없이 회의를 PDF로 인쇄하기
Steven
Steven7 분 소요

PDF 라이브러리 없이 회의를 PDF로 인쇄하기

GeekBye는 회의를 PDF로 내보내는데, 코드 어디에도 PDF 라이브러리가 없습니다. 보이지 않는 브라우저 창에서 HTML을 렌더링하고 그것을 인쇄합니다. 그 선택이 이 이야기의 전부입니다: 그것은 기능을 만들기 쉽게 했고, 동시에 진짜 브라우저가 가진 온갖 고장을 안겨 주었습니다 — 흰 깜빡임, URL 길이 제한, 그리고 스크린샷을 반으로 자른 페이지 나눔. 가장 못생긴 버그의 해결책은 CSS 한 줄이었습니다.

엔지니어링
Electron
데스크톱
OCR에서 픽셀로, 폴백을 잃지 않으면서
Steven
Steven6 분 소요

OCR에서 픽셀로, 폴백을 잃지 않으면서

GeekBye는 스크린샷을 OCR로 읽는 것을 멈추고 실제 이미지를 vision 모델로 보내기 시작했습니다 — 그건 쉬운 부분입니다. 배울 만한 부분은 vision 사용자에 대해 OCR을 통째로 건너뛴 최적화입니다. 그것은 vision 비지원 모델로의 폴백을 조용히 망가뜨렸고, 안전망이 병렬로 돌면서 아무 비용도 들지 않는 버전으로 되돌려야 했습니다.

엔지니어링
AI
데스크톱
클릭 통과 오버레이의 두 가지 실패 모드
Steven
Steven7 분 소요

클릭 통과 오버레이의 두 가지 실패 모드

GeekBye의 창은 모든 것 위에 떠 있으면서, 버튼이 있는 곳을 빼고는 당신의 클릭이 그것을 그대로 통과하게 둡니다. 그것은 양면 계약이고, v1.8.5와 v1.8.14는 각 면이 깨질 때 어떤 모습인지를 보여 줍니다: 한 릴리스에서는 오버레이가 시스템 대화상자를 삼켰고, 다른 하나에서는 당신의 키 입력을 훔쳤습니다. 두 번째를 이긴 해결책은 코드를 지우는 것이었습니다.

엔지니어링
Electron
데스크톱