Steven
Steven9 phút đọc

Ra Đời Và Được Sửa Trong Mười Hai Phút

Changelog của GeekBye v1.8.10 nói nó đã sửa một crash khi chỉnh sửa phím tắt bàn phím. Nó có sửa thật — nhưng con crash đó được đưa vào rồi được sửa trong cùng một pull request, cách nhau mười hai phút, và chẳng bao giờ chạm tới một người dùng nào. Câu chuyện thật là chuỗi phản ứng dây chuyền về độ tin cậy đã sinh ra nó: một thay đổi nhỏ, đúng đắn về việc khi nào thì tạm ngưng các phím tắt, và con bug teardown của React rơi ra từ một dòng vốn định làm cho editor an toàn hơn.

Kỹ thuật
React
Desktop
Bản phát hành GeekBye
Ra Đời Và Được Sửa Trong Mười Hai Phút

Đây là câu trung thực nhất tôi có thể viết về GeekBye v1.8.10: con crash mà nó nổi tiếng vì đã sửa chưa bao giờ xảy ra với bất kỳ ai. Changelog nói "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," điều đó đúng, và đọc như một bug đã ship, cắn người dùng, rồi được vá trong một bản tiếp theo. Nó không phải vậy. Con crash được đưa vào bởi một commit và bị xóa bởi một commit khác trong cùng một pull request, cách nhau mười hai phút, trong cùng một sáng thứ Sáu. Nó sống trọn vẹn bên trong một branch. Release notes đang mô tả một vết sẹo hình thành rồi lành trước khi bất cứ ai ngoài repo có thể nhìn thấy nó.

Đó không phải một thất bại của changelog; nó là hình hài trung thực của công việc lặp đi lặp lại, và đáng kể lại vì mười hai phút đó chứa một con bug React thực sự đầy tính giáo huấn. Nhưng để hiểu con crash, bạn phải hiểu bản phát hành này thực ra để làm gì — vì con crash là thiệt hại phụ từ việc sửa một thứ khác.

Bản phát hành này thực ra nói về điều gì

GeekBye cho bạn gán lại các phím tắt bàn phím của nó trong Settings. Trong lúc bạn đang ghi một tổ hợp mới, ứng dụng phải chặn các phím tắt toàn cục của nó khỏi kích hoạt — nếu không, nhấn Cmd+B để gán nó sẽ chỉ bật/tắt cửa sổ thay vì được bắt lấy. Nên editor tạm ngưng các global shortcut qua IPC: renderer gọi window.electronAPI.setShortcutsSuspended(true), và ShortcutsHelper của main process đáp lại bằng cách gọi unregisterAll(), bỏ mọi đăng ký globalShortcut để lần nhấn phím chạm tới ô bắt phím thay vì kích hoạt một hành động. Khi tiếp tục nó gọi registerGlobalShortcuts() để đặt chúng trở lại.

Con bug khởi đầu tất cả chuyện này (issue #233) là về phạm vi của sự tạm ngưng đó. Code cũ tạm ngưng các phím tắt suốt cả thời gian trang settings phím tắt mở — không chỉ khi bạn đang ghi một phím một cách chủ động. Phần lớn thời gian điều đó vô hình. Nhưng nếu bạn mở trang, bắt đầu làm việc khác, rồi điều hướng đi mà chưa từng hoàn tất một lần chỉnh sửa, ứng dụng có thể bị bỏ lại với toàn bộ global shortcut chưa được đăng ký — chết lặng lẽ — cho tới khi bạn quay lại. Commit sửa nó thẳng thắn về triệu chứng: tạm ngưng quá rộng có nguy cơ "making the app appear broken if the user forgot they had the page open."

Bản sửa là một sự thu hẹp một dòng: chỉ tạm ngưng khi một hàng thực sự đang được chỉnh sửa — khóa theo !!editingShortcut, cái hook state giữ id của hàng bạn đang ghi — thay vì suốt cả lượt vào. Một thay đổi tốt. Nhưng «chỉ tạm ngưng khi đang chỉnh sửa» lập tức nêu một câu hỏi: cái gì được tính là hoàn tất một lần chỉnh sửa? Nhấn một phím hoàn tất nó. Nhấn Escape hủy nó. Nhưng nếu bạn chỉ… click ra ngoài, hoặc chuyển hẳn sang một ứng dụng khác, giữa lúc bắt phím thì sao? Nếu không có gì hủy việc chỉnh sửa, bạn bị bỏ lại đang ghi một phím tắt vào một cửa sổ không có focus, và các phím tắt cứ ở trạng thái tạm ngưng. Nên cùng PR đó thêm một cái van an toàn: hủy chỉnh sửa khi focus rời đi. Và đó là dòng đã crash.

Con bug mười hai phút

Cái van an toàn là một useEffect trong ShortcutsSettings.tsx mà, trong lúc một hàng đang được chỉnh sửa, lắng nghe blur:

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

Hai listener. Một trên window — kích hoạt khi bạn chuyển sang ứng dụng khác. Một gắn trực tiếp, như một raw DOM listener, vào <input> bắt phím — định kích hoạt khi bạn click ra một phần tử khác. Chúng trông thừa và vô hại. Chúng không phải vậy.

Đi qua trình tự cho "switching windows mid-edit," đúng y cụm từ của changelog:

  1. Bạn đang ghi một phím tắt; <input> đang có focus. Bạn Cmd+Tab sang một ứng dụng khác.
  2. Blur của window kích hoạt. handleBlur chạy cancelEditing(), đặt editingShortcut trở lại null.
  3. Đặt state đó unmount <input> — hàng rời khỏi chế độ chỉnh sửa, nên ô input bị gỡ khỏi DOM.
  4. Gỡ một phần tử đang có focus khỏi DOM sẽ đồng bộ phát một sự kiện blur trên chính phần tử đó. Listener inputRef raw bắt được nó và gọi cancelEditing() một lần nữa — lần này ngay giữa commit phase của React, trong lúc cây component đang bị tháo dỡ.
  5. Gọi một state setter trên một fiber đang giữa lúc teardown vấp vào một trong các bất biến nội bộ của React, và nó ném: "Should have a queue". Màn hình settings crash.

Con bug không phải là window listener và cũng không thật sự là khái niệm «hủy khi blur». Nó là chuyện một trong hai listener là một raw addEventListener trên một phần tử do React quản lý. React không biết về listener đó, không dọn dẹp nó khi unmount, và — mấu chốt là — trình duyệt phát blur ngay giữa chính lần gỡ DOM mà React đang thực hiện, nên handler tái nhập logic state của bạn vào đúng khoảnh khắc tệ nhất có thể. Thông điệp của commit sửa nói chính xác hơn tôi có thể: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Bản sửa

bf28a50, mười hai phút sau khi con crash ra đời, làm hai việc nhỏ.

Thứ nhất, nó xóa raw input listener và chỉ giữ cái window, cái duy nhất từ đầu chí cuối cần phải là một manual DOM listener (không có onBlur của React cho «cả cửa sổ mất focus»):

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

Thứ hai, nó chuyển trường hợp click-ra-ngoài — «người dùng đã click một phần tử khác» — lên chính onBlur prop của React trên ô input, và hoãn nó một tick:

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

Cả hai phần đều quan trọng. Dùng onBlur prop của React thay cho một raw listener nghĩa là React sở hữu vòng đời của handler và sẽ không bỏ nó lơ lửng. Và setTimeout(…, 0) bảo đảm rằng ngay cả khi blur này thật sự bị kích hoạt bởi một unmount, lời gọi cancelEditing() cũng rơi vào tick kế tiếp — sau khi render và commit hiện tại đã xong — nên nó không bao giờ có thể cập nhật state trên một component vẫn đang tháo dỡ. Nếu component đã biến mất tới lúc đó, lời gọi bị hoãn là một no-op vô hại.

Đó là toàn bộ bản sửa. Xóa listener mà React không thể thấy; để React lên lịch cho cái còn lại.

Vì sao đây là kiểu nhàm chán tốt

Có một cám dỗ xếp cái này vào ngăn «tầm phào» — một setTimeout, một dòng bị xóa — rồi đi tiếp. Nhưng hình hài của nó là một trong những cách phổ biến nhất khiến các app desktop React crash, và đáng để thấm vào người:

  • Con crash chỉ xuất hiện tại một ranh giới vòng đời (unmount), bị kích hoạt bởi một sự kiện mà framework không lên lịch (một native blur trong lúc gỡ DOM). Nó sẽ không lộ ra trong một bài test click-qua; nó cần đúng đường «focus rời đi trong lúc chỉnh sửa».
  • Nguyên nhân gốc là trộn hai mô hình sở hữu — các sự kiện raw DOM và các sự kiện tổng hợp, được lên lịch của React — trên cùng một phần tử. Khoảnh khắc handler của bạn thay đổi state có thể unmount phần tử đó, đường raw trở nên tái nhập (re-entrant).
  • Bản sửa không phải là «thêm một cờ bảo vệ» (dù một ref isMounted hẳn đã che nó đi); nó là gỡ raw listener đi hoàn toàn, đó là thay đổi nhỏ hơn và là cái đúng. Lại là phép trừ.

Và bài học meta, cái mà cả loạt bài này cứ vòng quanh: đọc diff, đừng đọc release note. "Fixed a crash while editing keyboard shortcuts" là chính xác, nhưng nó giấu đi rằng con crash là tự gây ra, cùng một sáng, và chưa bao giờ được ship — và rằng bản phát hành thật là một chiến thắng lặng lẽ về tính đúng đắn quanh chuyện khi nào thì tạm ngưng một global shortcut.

Còn gì được ship nữa

Hai thứ nữa đi ké theo v1.8.10, cả hai đáng một dòng. Hệ thống phím tắt cũng có thêm tự lành qua ngủ/thức: macOS có thể lặng lẽ làm rơi các đăng ký global shortcut của một app khi máy ngủ, nên giờ một resume handler xác minh lại và đăng ký lại chúng, nghĩa là phím tắt của bạn vẫn hoạt động sau khi laptop thức mà không cần khởi động lại. Và về phía AI, client thông minh hơn về rate limit — các streaming request đụng phải 429 giờ back off theo cấp số nhân và reset bộ đếm của chúng khi bạn ngừng ghi, thay vì chờ hết một độ trễ cố định. Ba bản sửa độ tin cậy cho các hệ thống con khác nhau, một trong số đó là con crash chưa bao giờ là một crash.

Về chương trước trong câu chuyện v1, xem In Một Cuộc Họp Ra PDF Mà Không Có Thư Viện PDF (v1.8.9); và cho toàn bộ vòng cung, xem giải phẫu việc xuất xưởng phần mềm đến độ hoàn hảo.

Bài Viết Liên Quan

Ba Động Từ Giữ Cho Web Audio Sống
Steven
Steven11 phút đọc

Ba Động Từ Giữ Cho Web Audio Sống

Hai bản point release của GeekBye, cách nhau hai tháng và nằm trong hai file khác nhau, đã dạy code âm thanh của chúng tôi cùng một bài học từ hai đầu đối nghịch: thôi coi AudioContext của browser như một thứ dùng-rồi-bỏ. Một bản học được cách resume() một context mà macOS đã lặng lẽ suspend giữa lúc ghi; bản kia học được cách suspend() thay vì close() để những session nối tiếp nhau thôi đâm sầm vào cái trần khoảng-sáu-context của Chromium. resume, suspend, close — đó là toàn bộ cốt truyện.

Kỹ thuật
Audio
Desktop
Phân Biệt Một Cuộc Gọi Với Một App Đang Mở
Steven
Steven11 phút đọc

Phân Biệt Một Cuộc Gọi Với Một App Đang Mở

GeekBye có thể nhận ra bạn vừa vào một cuộc họp video và đề nghị ghi lại nó chỉ bằng một cú nhấp. Việc phát hiện hóa ra là nửa dễ — một Swift binary đọc tiêu đề cửa sổ mỗi mười giây. Nửa khó là độ chính xác: không phát hỏa khi Zoom chỉ đang mở, không nhắc cho một cuộc họp bạn đã đang ghi, và không tắt tiếng cái mic trong cuộc gọi bạn thực sự đang ngồi. Ba bản phát hành, và mỗi bản là một guard phải học cách không tự đánh bại chính mình.

Kỹ thuật
macOS
Desktop
Gỡ Backend Ra Khỏi Đường Đi Của Upload
Steven
Steven10 phút đọc

Gỡ Backend Ra Khỏi Đường Đi Của Upload

GeekBye quay lại màn hình của bạn và lưu video vào Google Drive của bạn. Phiên bản đầu tiên đẩy mọi bản ghi qua chính máy chủ của GeekBye trên đường tới đó; một bản phát hành sau, tập tin đi thẳng từ máy của bạn tới Drive, và backend bị giáng xuống thành thứ chỉ giữ một con trỏ. Phần thú vị là cái phiên bản «trực tiếp, có thể tiếp tục» ấy thực ra chứa ít code đến mức nào — vì khả năng tiếp tục đến từ việc xóa đi một proxy, chứ không phải từ việc viết ra một cái.

Kỹ thuật
Kiến trúc
Desktop