
Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά
Το changelog του GeekBye v1.8.10 λέει ότι διόρθωσε ένα crash κατά την επεξεργασία των συντομεύσεων πληκτρολογίου. Όντως το διόρθωσε — αλλά το crash εισήχθη και διορθώθηκε στο ίδιο pull request, με δώδεκα λεπτά διαφορά, και δεν έφτασε ποτέ σε ούτε έναν χρήστη. Η πραγματική ιστορία είναι ο καταρράκτης αξιοπιστίας που το παρήγαγε: μια μικρή, σωστή αλλαγή στο πότε αναστέλλονται οι συντομεύσεις, και το bug αποδόμησης του React που ξεπήδησε από μία γραμμή που προοριζόταν να κάνει τον επεξεργαστή ασφαλέστερο.
Να η πιο ειλικρινής πρόταση που μπορώ να γράψω για το GeekBye v1.8.10: το crash για τη διόρθωση του οποίου είναι διάσημο δεν συνέβη ποτέ σε κανέναν. Το changelog λέει "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," που είναι αλήθεια, και διαβάζεται σαν ένα bug που κυκλοφόρησε, δάγκωσε χρήστες και μπαλώθηκε σε μια επόμενη έκδοση. Δεν ήταν. Το crash εισήχθη από ένα commit και διαγράφηκε από ένα άλλο στο ίδιο pull request, με δώδεκα λεπτά διαφορά, το ίδιο πρωινό της Παρασκευής. Έζησε εξ ολοκλήρου μέσα σε ένα branch. Οι σημειώσεις έκδοσης περιγράφουν μια ουλή που σχηματίστηκε και επουλώθηκε πριν μπορέσει να τη δει οποιοσδήποτε έξω από το repo.
Αυτό δεν είναι αποτυχία του changelog· είναι το ειλικρινές σχήμα της επαναληπτικής δουλειάς, και αξίζει να ειπωθεί επειδή τα δώδεκα λεπτά περιέχουν ένα πραγματικά διδακτικό bug του React. Αλλά για να καταλάβεις το crash πρέπει να καταλάβεις για τι πράγμα ήταν στην πραγματικότητα η έκδοση — επειδή το crash ήταν παράπλευρη ζημιά από τη διόρθωση κάποιου άλλου πράγματος.
Για τι πράγμα ήταν στην πραγματικότητα η έκδοση
Το GeekBye σου επιτρέπει να αλλάξεις τις συντομεύσεις πληκτρολογίου του στις Ρυθμίσεις. Ενώ καταγράφεις έναν νέο συνδυασμό, η εφαρμογή πρέπει να σταματήσει τις global συντομεύσεις της από το να ενεργοποιηθούν — αλλιώς το πάτημα του Cmd+B για να τον αναθέσεις απλώς θα εναλλασσόταν το παράθυρο αντί να καταγραφεί. Έτσι ο επεξεργαστής αναστέλλει τις global συντομεύσεις μέσω IPC: ο renderer καλεί το window.electronAPI.setShortcutsSuspended(true), και ο ShortcutsHelper της κύριας διεργασίας αποκρίνεται καλώντας το unregisterAll(), ρίχνοντας κάθε εγγραφή globalShortcut ώστε το πάτημα πλήκτρου να φτάσει στο input καταγραφής αντί να πυροδοτήσει μια ενέργεια. Στη συνέχιση καλεί το registerGlobalShortcuts() για να τις βάλει πίσω.
Το bug που ξεκίνησε όλα αυτά (issue #233) αφορούσε το εύρος αυτής της αναστολής. Ο παλιός κώδικας ανέστελλε τις συντομεύσεις για όλο το διάστημα που η σελίδα ρυθμίσεων των συντομεύσεων ήταν ανοιχτή — όχι μόνο όσο κατέγραφες ενεργά ένα πλήκτρο. Τις περισσότερες φορές αυτό είναι αόρατο. Αλλά αν άνοιγες τη σελίδα, άρχιζες να κάνεις κάτι άλλο και έφευγες αλλού χωρίς να τελειώσεις ποτέ μια επεξεργασία, η εφαρμογή μπορούσε να μείνει με όλες τις global συντομεύσεις της μη εγγεγραμμένες — σιωπηλά νεκρές — μέχρι να επέστρεφες. Το commit που το διορθώνει είναι ειλικρινές για το σύμπτωμα: η υπερβολικά ευρεία αναστολή ρίσκαρε "making the app appear broken if the user forgot they had the page open."
Η διόρθωση είναι μια στένεψη μιας γραμμής: ανάστειλε μόνο όταν μια σειρά επεξεργάζεται πραγματικά — με βάση το !!editingShortcut, την κατάσταση του hook που κρατά το id της σειράς που καταγράφεις — αντί για ολόκληρη την επίσκεψη. Καλή αλλαγή. Αλλά το «ανάστειλε μόνο όσο επεξεργάζεσαι» εγείρει ένα άμεσο ερώτημα: τι μετράει ως ολοκλήρωση μιας επεξεργασίας; Το πάτημα ενός πλήκτρου την ολοκληρώνει. Το πάτημα του Escape την ακυρώνει. Αλλά τι γίνεται αν απλώς… κάνεις κλικ αλλού, ή εναλλάσσεσαι εντελώς σε άλλη εφαρμογή, στη μέση της καταγραφής; Αν τίποτα δεν ακυρώσει την επεξεργασία, μένεις να καταγράφεις μια συντόμευση σε ένα παράθυρο που δεν είναι εστιασμένο, και οι συντομεύσεις παραμένουν σε αναστολή. Έτσι το ίδιο PR πρόσθεσε μια δικλείδα ασφαλείας: ακύρωσε την επεξεργασία όταν φύγει η εστίαση. Και αυτή είναι η γραμμή που έκανε crash.
Το bug των δώδεκα λεπτών
Η δικλείδα ασφαλείας ήταν ένα useEffect στο ShortcutsSettings.tsx που, ενώ μια σειρά επεξεργαζόταν, άκουγε για blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Δύο listeners. Ένας στο window — ενεργοποιείται όταν εναλλάσσεσαι σε άλλη εφαρμογή. Ένας προσαρτημένος απευθείας, ως raw DOM listener, στο <input> καταγραφής — προοριζόταν να ενεργοποιηθεί όταν κάνεις κλικ αλλού σε άλλο στοιχείο. Φαίνονται πλεονάζοντες και αβλαβείς. Δεν είναι.
Ακολούθησε την ακολουθία για το "switching windows mid-edit," που είναι ακριβώς η φράση του changelog:
- Καταγράφεις μια συντόμευση· το
<input>είναι εστιασμένο. Κάνεις Cmd+Tab σε άλλη εφαρμογή. - Το blur του παραθύρου ενεργοποιείται. Το
handleBlurτρέχει τοcancelEditing(), το οποίο επαναφέρει τοeditingShortcutσεnull. - Η ρύθμιση αυτής της κατάστασης κάνει unmount το
<input>— η σειρά φεύγει από τη λειτουργία επεξεργασίας, οπότε το input αφαιρείται από το DOM. - Η αφαίρεση ενός εστιασμένου στοιχείου από το DOM στέλνει σύγχρονα ένα συμβάν
blurσε αυτό το στοιχείο. Ο rawinputReflistener το πιάνει και καλεί τοcancelEditing()ξανά — αυτή τη φορά στη μέση της φάσης commit του React, ενώ το δέντρο των components αποδομείται. - Η κλήση ενός setter κατάστασης σε ένα fiber που βρίσκεται στη μέση της αποδόμησης σκοντάφτει σε έναν από τους εσωτερικούς invariants του React, και ρίχνει:
"Should have a queue". Η προβολή ρυθμίσεων κάνει crash.
Το bug δεν είναι ο listener του παραθύρου και δεν είναι πραγματικά η ιδέα της «ακύρωσης στο blur». Είναι ότι ένας από τους δύο listeners ήταν ένα raw addEventListener σε ένα στοιχείο που διαχειρίζεται το React. Το React δεν γνωρίζει για αυτόν τον listener, δεν τον καθαρίζει στο unmount, και — κρίσιμα — ο browser ενεργοποιεί το blur κατά την ίδια την αφαίρεση από το DOM που εκτελεί το React, οπότε ο handler ξαναμπαίνει στη λογική κατάστασής σου την χειρότερη δυνατή στιγμή. Το μήνυμα του commit της διόρθωσης το λέει πιο ακριβώς από ό,τι μπορώ εγώ: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Η διόρθωση
Το bf28a50, δώδεκα λεπτά αφότου γεννήθηκε το crash, κάνει δύο μικρά πράγματα.
Πρώτον, διαγράφει τον raw input listener και κρατά μόνο αυτόν του παραθύρου, που είναι ο μόνος που χρειαζόταν καθόλου να είναι χειροκίνητος DOM listener (δεν υπάρχει React onBlur για το «ολόκληρο το παράθυρο έχασε την εστίαση»):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Δεύτερον, μετακινεί την περίπτωση κλικ-αλλού — «ο χρήστης έκανε κλικ σε άλλο στοιχείο» — στο ίδιο το onBlur prop του React στο input, και την αναβάλλει κατά ένα tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Και τα δύο μέρη έχουν σημασία. Η χρήση του onBlur prop του React αντί για έναν raw listener σημαίνει ότι το React κατέχει τον κύκλο ζωής του handler και δεν θα τον αφήσει να κρέμεται. Και το setTimeout(…, 0) εγγυάται ότι ακόμα κι όταν αυτό το blur ενεργοποιείται από ένα unmount, η κλήση cancelEditing() προσγειώνεται στο επόμενο tick — αφού έχουν τελειώσει το τρέχον render και commit — οπότε δεν μπορεί ποτέ να ενημερώσει την κατάσταση σε ένα component που ακόμα αποδομείται. Αν το component έχει ήδη φύγει μέχρι τότε, η αναβεβλημένη κλήση είναι ένα αβλαβές no-op.
Αυτή είναι όλη η διόρθωση. Διάγραψε τον listener που το React δεν μπορούσε να δει· άσε το React να προγραμματίσει αυτόν που απέμεινε.
Γιατί αυτό είναι το καλό είδος βαρετού
Υπάρχει ένας πειρασμός να το κατατάξεις στο «τετριμμένο» — ένα setTimeout, μια διαγραμμένη γραμμή — και να προχωρήσεις. Αλλά το σχήμα του είναι ένας από τους πιο κοινούς τρόπους με τους οποίους οι React desktop εφαρμογές κάνουν crash, και αξίζει να εσωτερικευτεί:
- Το crash εμφανίζεται μόνο σε ένα όριο κύκλου ζωής (unmount), πυροδοτούμενο από ένα συμβάν που το framework δεν προγραμματίζει (ένα native blur κατά την αφαίρεση από το DOM). Δεν θα εμφανιστεί σε ένα click-through test· χρειάζεται τη συγκεκριμένη διαδρομή «η εστίαση φεύγει ενώ επεξεργάζεσαι».
- Η ριζική αιτία είναι η ανάμειξη δύο μοντέλων ιδιοκτησίας — raw DOM συμβάντα και τα συνθετικά, προγραμματισμένα συμβάντα του React — στο ίδιο στοιχείο. Τη στιγμή που ο handler σου μεταλλάσσει κατάσταση που μπορεί να κάνει unmount αυτό το στοιχείο, η raw διαδρομή γίνεται επανεισερχόμενη.
- Η διόρθωση δεν είναι «πρόσθεσε μια σημαία φύλαξης» (αν και ένα
isMountedref θα το είχε καλύψει)· είναι η πλήρης αφαίρεση του raw listener, που είναι η μικρότερη αλλαγή και η σωστή. Αφαίρεση ξανά.
Και το μετα-μάθημα, αυτό γύρω από το οποίο συνεχίζει να γυρίζει όλη αυτή η σειρά: διάβασε το diff, όχι τη σημείωση έκδοσης. Το "Fixed a crash while editing keyboard shortcuts" είναι ακριβές, αλλά κρύβει ότι το crash ήταν αυτο-προκληθέν, το ίδιο πρωί, και δεν κυκλοφόρησε ποτέ — και ότι η πραγματική έκδοση είναι μια ήσυχη νίκη ορθότητας για το πότε να αναστείλεις μια global συντόμευση.
Τι άλλο κυκλοφόρησε
Δύο ακόμα πράγματα ήρθαν μαζί στο v1.8.10, και τα δύο αξίζουν μια γραμμή. Το σύστημα συντομεύσεων απέκτησε επίσης αυτο-θεραπεία κατά το sleep/wake: το macOS μπορεί σιωπηλά να ρίξει τις εγγραφές global συντομεύσεων μιας εφαρμογής όταν το μηχάνημα κοιμάται, οπότε ένας handler συνέχισης τώρα τις επαληθεύει και τις ξαναδηλώνει, πράγμα που σημαίνει ότι οι συντομεύσεις σου εξακολουθούν να δουλεύουν αφού ξυπνήσει το laptop χωρίς επανεκκίνηση. Και από την πλευρά του AI, ο client έγινε εξυπνότερος σχετικά με τα rate limits — τα streaming αιτήματα που χτυπούν ένα 429 τώρα κάνουν exponential back off και μηδενίζουν τον μετρητή τους όταν σταματάς την εγγραφή, αντί να περιμένουν μια σταθερή καθυστέρηση. Τρεις διορθώσεις αξιοπιστίας σε διαφορετικά υποσυστήματα, μία από τις οποίες είναι το crash που δεν ήταν ποτέ crash.
Για το προηγούμενο κεφάλαιο της ιστορίας του v1, εκτύπωση μιας σύσκεψης σε PDF χωρίς βιβλιοθήκη PDF (v1.8.9)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.