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)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.

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

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό
Steven
Steven9 λεπτά ανάγνωσης

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό

Δύο point releases του GeekBye, με δύο μήνες απόσταση και σε δύο διαφορετικά αρχεία, δίδαξαν στον κώδικα ήχου μας το ίδιο μάθημα από αντίθετα άκρα: σταμάτα να αντιμετωπίζεις το AudioContext του browser ως αναλώσιμο. Μία έκδοση έμαθε να κάνει resume() ένα context που το macOS είχε σιωπηλά suspend στη μέση της καταγραφής· η άλλη έμαθε να κάνει suspend() αντί για close(), ώστε οι διαδοχικές sessions να σταματήσουν να χτυπούν στο ταβάνι των περίπου έξι context του Chromium. Resume, suspend, close — αυτή είναι όλη η πλοκή.

Μηχανική
Audio
Desktop
Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή
Steven
Steven9 λεπτά ανάγνωσης

Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή

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

Μηχανική
macOS
Desktop
Βγάζοντας το backend από τη διαδρομή του upload
Steven
Steven9 λεπτά ανάγνωσης

Βγάζοντας το backend από τη διαδρομή του upload

Το GeekBye καταγράφει την οθόνη σου και αποθηκεύει το βίντεο στο Google Drive σου. Η πρώτη έκδοση έστελνε κάθε καταγραφή μέσα από τους ίδιους τους διακομιστές του GeekBye στη διαδρομή προς τα εκεί· μια έκδοση αργότερα, το αρχείο πήγαινε κατευθείαν από το μηχάνημά σου στο Drive, και το backend υποβιβάστηκε στο να κρατά έναν μοναδικό δείκτη. Το ενδιαφέρον κομμάτι είναι πόσο λίγο κώδικα περιέχει στην πραγματικότητα η «άμεση, resumable» έκδοση — επειδή η resumability προήλθε από τη διαγραφή ενός proxy, όχι από τη συγγραφή ενός.

Μηχανική
Αρχιτεκτονική
Desktop