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

In Một Cuộc Họp Ra PDF Mà Không Có Thư Viện PDF
Steven
Steven11 phút đọc

In Một Cuộc Họp Ra PDF Mà Không Có Thư Viện PDF

GeekBye xuất một cuộc họp ra PDF, mà chẳng có một thư viện PDF nào ở bất cứ đâu trong code. Nó render HTML trong một cửa sổ trình duyệt vô hình rồi in nó ra. Lựa chọn đó chính là cả câu chuyện: nó làm cho tính năng dễ dựng và trao cho nó mọi kiểu hỏng mà một trình duyệt thật có — một cú lóe trắng, một giới hạn độ dài URL, và một chỗ ngắt trang cắt đôi các screenshot. Cách sửa cho con bug xấu xí nhất là một dòng CSS.

Kỹ thuật
Electron
Desktop
Từ OCR Sang Pixel Mà Không Đánh Mất Fallback
Steven
Steven9 phút đọc

Từ OCR Sang Pixel Mà Không Đánh Mất Fallback

GeekBye ngừng đọc các screenshot của bạn bằng OCR và bắt đầu gửi hình ảnh thật tới các vision model — đó là phần dễ. Phần đáng học là tối ưu đã bỏ qua OCR hoàn toàn cho người dùng vision, lặng lẽ làm hỏng cái fallback sang các model không-vision, và phải bị đưa trở lại một phiên bản nơi tấm lưới an toàn chạy song song và chẳng tốn gì cả.

Kỹ thuật
AI
Desktop
Hai Chế Độ Hỏng Của Một Lớp Phủ Cho Click Xuyên Qua
Steven
Steven11 phút đọc

Hai Chế Độ Hỏng Của Một Lớp Phủ Cho Click Xuyên Qua

Cửa sổ của GeekBye nổi lên trên mọi thứ và để các cú click của bạn xuyên qua nó — trừ những chỗ nó có nút. Đó là một hợp đồng hai mặt, và v1.8.5 cùng v1.8.14 chính là dáng vẻ của lúc mỗi mặt vỡ: một bản phát hành lớp phủ nuốt mất một dialog hệ thống, một bản khác nó cướp các phím gõ của bạn. Cách sửa thắng cuộc cho vấn đề thứ hai là xóa code.

Kỹ thuật
Electron
Desktop