Steven
Steven8 λεπτά ανάγνωσης

Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά

Το changelog του GeekBye v1.8.10 λέει ότι διόρθωσε ένα crash κατά την επεξεργασία των συντομεύσεων πληκτρολογίου. Όντως το διόρθωσε — αλλά το crash εισήχθη και διορθώθηκε στο ίδιο pull request, με δώδεκα λεπτά διαφορά, και δεν έφτασε ποτέ σε ούτε έναν χρήστη. Η πραγματική ιστορία είναι ο καταρράκτης αξιοπιστίας που το παρήγαγε: μια μικρή, σωστή αλλαγή στο πότε αναστέλλονται οι συντομεύσεις, και το bug αποδόμησης του React που ξεπήδησε από μία γραμμή που προοριζόταν να κάνει τον επεξεργαστή ασφαλέστερο.

Μηχανική
React
Desktop
Εκδόσεις GeekBye
Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά

Να η πιο ειλικρινής πρόταση που μπορώ να γράψω για το 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:

  1. Καταγράφεις μια συντόμευση· το <input> είναι εστιασμένο. Κάνεις Cmd+Tab σε άλλη εφαρμογή.
  2. Το blur του παραθύρου ενεργοποιείται. Το handleBlur τρέχει το cancelEditing(), το οποίο επαναφέρει το editingShortcut σε null.
  3. Η ρύθμιση αυτής της κατάστασης κάνει unmount το <input> — η σειρά φεύγει από τη λειτουργία επεξεργασίας, οπότε το input αφαιρείται από το DOM.
  4. Η αφαίρεση ενός εστιασμένου στοιχείου από το DOM στέλνει σύγχρονα ένα συμβάν blur σε αυτό το στοιχείο. Ο raw inputRef listener το πιάνει και καλεί το cancelEditing() ξανά — αυτή τη φορά στη μέση της φάσης commit του React, ενώ το δέντρο των components αποδομείται.
  5. Η κλήση ενός 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 διαδρομή γίνεται επανεισερχόμενη.
  • Η διόρθωση δεν είναι «πρόσθεσε μια σημαία φύλαξης» (αν και ένα isMounted ref θα το είχε καλύψει)· είναι η πλήρης αφαίρεση του 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)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.

Σχετικά Άρθρα

Εκτύπωση μιας σύσκεψης σε PDF χωρίς βιβλιοθήκη PDF
Steven
Steven9 λεπτά ανάγνωσης

Εκτύπωση μιας σύσκεψης σε PDF χωρίς βιβλιοθήκη PDF

Το GeekBye εξάγει μια σύσκεψη ως PDF, και δεν υπάρχει πουθενά στον κώδικα καμία βιβλιοθήκη PDF. Κάνει render HTML σε ένα αόρατο παράθυρο browser και το εκτυπώνει. Αυτή η επιλογή είναι όλη η ιστορία: έκανε τη λειτουργία εύκολη στην κατασκευή και της έδωσε κάθε αστοχία που έχει ένας πραγματικός browser — ένα λευκό τρεμόπαιγμα, ένα όριο μήκους URL και ένα σπάσιμο σελίδας που έκοβε τα στιγμιότυπα οθόνης στη μέση. Η διόρθωση για το πιο άσχημο bug ήταν μία γραμμή CSS.

Μηχανική
Electron
Desktop
Από το OCR στα pixels χωρίς να χάσουμε το fallback
Steven
Steven8 λεπτά ανάγνωσης

Από το OCR στα pixels χωρίς να χάσουμε το fallback

Το GeekBye σταμάτησε να διαβάζει τα στιγμιότυπα οθόνης σου με OCR και άρχισε να στέλνει την ίδια την εικόνα σε vision μοντέλα — που είναι το εύκολο κομμάτι. Το διδακτικό κομμάτι είναι η βελτιστοποίηση που παρέκαμψε το OCR εντελώς για τους vision χρήστες, έσπασε σιωπηλά το fallback προς τα μη-vision μοντέλα, και έπρεπε να αναιρεθεί σε μια έκδοση όπου το δίχτυ ασφαλείας τρέχει παράλληλα και δεν κοστίζει τίποτα.

Μηχανική
AI
Desktop
Οι δύο τρόποι αστοχίας ενός click-through overlay
Steven
Steven9 λεπτά ανάγνωσης

Οι δύο τρόποι αστοχίας ενός click-through overlay

Το παράθυρο του GeekBye επιπλέει πάνω από τα πάντα και αφήνει τα κλικ σου να περνούν μέσα από αυτό — εκτός από εκεί όπου έχει κουμπιά. Αυτό είναι ένα αμφίπλευρο συμβόλαιο, και το v1.8.5 και το v1.8.14 δείχνουν πώς μοιάζει όταν σπάει η κάθε πλευρά: μια έκδοση όπου το overlay κατάπιε έναν διάλογο συστήματος, μία όπου έκλεψε τα πλήκτρα σου. Η νικήτρια διόρθωση για τη δεύτερη ήταν η διαγραφή κώδικα.

Μηχανική
Electron
Desktop