Steven
Steven8 min di lettura

Nato e corretto in dodici minuti

Il changelog di GeekBye v1.8.10 dice che ha corretto un crash durante la modifica delle scorciatoie da tastiera. L'ha fatto — ma il crash è stato introdotto e corretto nella stessa pull request, a dodici minuti di distanza, e non ha mai raggiunto un solo utente. La vera storia è la cascata di affidabilità che l'ha prodotto: un piccolo cambiamento corretto su quando le scorciatoie vengono sospese, e il bug di teardown di React caduto da una singola riga pensata per rendere l'editor più sicuro.

Ingegneria
React
Desktop
Release di GeekBye
Nato e corretto in dodici minuti

Ecco la frase più onesta che posso scrivere su GeekBye v1.8.10: il crash per la cui correzione è famosa non è mai capitato a nessuno. Il changelog dice "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", il che è vero, e si legge come un bug che è stato pubblicato, ha morso gli utenti, ed è stato patchato in un follow-up. Non lo era. Il crash è stato introdotto da un commit e cancellato da un altro nella stessa pull request, a dodici minuti di distanza, la stessa mattina di venerdì. È vissuto interamente dentro un branch. Le note di rilascio descrivono una cicatrice che si è formata ed è guarita prima che chiunque fuori dal repo potesse vederla.

Non è un fallimento del changelog; è la forma onesta del lavoro iterativo, e vale la pena raccontarla perché quei dodici minuti contengono un bug di React genuinamente istruttivo. Ma per capire il crash devi capire a cosa serviva davvero la release — perché il crash è stato un danno collaterale della correzione di qualcos'altro.

Di cosa parlava davvero la release

GeekBye ti lascia riassegnare le sue scorciatoie da tastiera nelle Impostazioni. Mentre registri una nuova combinazione, l'app deve impedire che le sue scorciatoie globali scattino — altrimenti premere Cmd+B per assegnarlo alternerebbe soltanto la finestra invece di essere catturato. Così l'editor sospende le scorciatoie globali via IPC: il renderer chiama window.electronAPI.setShortcutsSuspended(true), e il ShortcutsHelper del processo principale risponde chiamando unregisterAll(), lasciando cadere ogni registrazione di globalShortcut così che la pressione del tasto raggiunga l'input di cattura invece di scatenare un'azione. Alla ripresa chiama registerGlobalShortcuts() per rimetterle.

Il bug che ha iniziato tutto questo (issue #233) riguardava l'ambito di quella sospensione. Il vecchio codice sospendeva le scorciatoie per tutto il tempo in cui la pagina delle impostazioni delle scorciatoie era aperta — non solo mentre registravi attivamente un tasto. La maggior parte del tempo è invisibile. Ma se aprivi la pagina, iniziavi a fare qualcos'altro, e navigavi altrove senza mai finire una modifica, l'app poteva restare con tutte le sue scorciatoie globali non registrate — morte in silenzio — finché non tornavi. Il commit che lo corregge è franco sul sintomo: sospendere in modo troppo ampio rischiava di "making the app appear broken if the user forgot they had the page open."

Il fix è un restringimento di una riga: sospendere solo quando una riga viene effettivamente modificata — chiavato su !!editingShortcut, lo stato dell'hook che tiene l'id della riga che stai registrando — invece che per tutta la visita. Buon cambiamento. Ma «sospendere solo mentre si modifica» solleva una domanda immediata: cosa conta come finire una modifica? Premere un tasto la finisce. Premere Escape la annulla. Ma se semplicemente… clicchi fuori, o passi a un'altra app del tutto, a metà cattura? Se niente annulla la modifica, resti a registrare una scorciatoia in una finestra che non ha il focus, e le scorciatoie restano sospese. Così la stessa PR ha aggiunto una valvola di sicurezza: annullare la modifica quando il focus se ne va. Ed è quella la riga che è crashata.

Il bug di dodici minuti

La valvola di sicurezza era un useEffect in ShortcutsSettings.tsx che, mentre una riga veniva modificata, ascoltava il blur:

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

Due listener. Uno sulla window — scatta quando passi a un'altra app. Uno attaccato direttamente, come listener del DOM grezzo, all'<input> di cattura — pensato per scattare quando clicchi fuori verso un altro elemento. Sembrano ridondanti e innocui. Non lo sono.

Percorri la sequenza di «switching windows mid-edit», che è esattamente la frase del changelog:

  1. Stai registrando una scorciatoia; l'<input> ha il focus. Fai Cmd+Tab verso un'altra app.
  2. Il blur della finestra scatta. handleBlur esegue cancelEditing(), che riporta editingShortcut a null.
  3. Impostare quello stato smonta l'<input> — la riga esce dalla modalità modifica, quindi l'input viene rimosso dal DOM.
  4. Rimuovere dal DOM un elemento che ha il focus dispaccia in modo sincrono un evento blur su quell'elemento. Il listener grezzo di inputRef lo intercetta e chiama cancelEditing() di nuovo — stavolta nel mezzo della fase di commit di React, mentre l'albero dei componenti viene smontato.
  5. Chiamare un setter di stato su un fiber a metà teardown inciampa in uno degli invarianti interni di React, e lancia: "Should have a queue". La vista delle impostazioni crasha.

Il bug non è il listener della finestra e non è davvero il concetto di «annullare al blur». È che uno dei due listener era un addEventListener grezzo su un elemento gestito da React. React non sa di quel listener, non lo pulisce allo smontaggio, e — cosa cruciale — il browser scatena il blur durante la stessa rimozione dal DOM che React sta eseguendo, così il handler rientra nella tua logica di stato nell'istante peggiore possibile. Il messaggio del commit del fix lo dice più precisamente di quanto possa io: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Il fix

bf28a50, dodici minuti dopo la nascita del crash, fa due piccole cose.

Primo, cancella il listener grezzo dell'input e tiene solo quello della finestra, che è l'unico che aveva davvero bisogno di essere un listener manuale del DOM (non c'è un onBlur di React per «l'intera finestra ha perso il focus»):

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

Secondo, sposta il caso del clic-fuori — «l'utente ha cliccato su un altro elemento» — sulla prop onBlur di React stessa sull'input, e lo differisce di un tick:

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

Entrambe le parti contano. Usare la prop onBlur di React invece di un listener grezzo significa che React possiede il ciclo di vita del handler e non lo lascerà penzolare. E il setTimeout(…, 0) garantisce che anche quando questo blur è scatenato da uno smontaggio, la chiamata a cancelEditing() atterra al tick successivo — dopo che il render e il commit attuali sono finiti — così non può mai aggiornare lo stato di un componente che si sta ancora smontando. Se il componente è già sparito per allora, la chiamata differita è un no-op innocuo.

È tutto qui il fix. Cancella il listener che React non poteva vedere; lascia che React pianifichi quello che resta.

Perché questo è il buon tipo di noioso

C'è una tentazione di archiviare questo come «banale» — un setTimeout, una riga cancellata — e andare avanti. Ma la sua forma è uno dei modi più comuni in cui le app desktop in React crashano, e vale la pena interiorizzarla:

  • Il crash appare solo a un confine del ciclo di vita (smontaggio), scatenato da un evento che il framework non pianifica (un blur nativo durante la rimozione dal DOM). Non comparirà in un test di clic; serve il percorso specifico «il focus se ne va mentre si modifica».
  • La causa radice è mescolare due modelli di proprietà — eventi del DOM grezzi e gli eventi sintetici e pianificati di React — sullo stesso elemento. Nel momento in cui il tuo handler muta uno stato che può smontare quell'elemento, il percorso grezzo diventa rientrante.
  • Il fix non è «aggiungere un flag di guardia» (anche se un ref isMounted l'avrebbe mascherato); è rimuovere del tutto il listener grezzo, che è il cambiamento più piccolo e quello corretto. Sottrazione, di nuovo.

E la meta-lezione, quella attorno a cui questa intera serie continua a girare: leggi il diff, non la nota di rilascio. "Fixed a crash while editing keyboard shortcuts" è accurato, ma nasconde che il crash era autoinflitto, della stessa mattina, e mai pubblicato — e che la release vera è una vittoria silenziosa di correttezza su quando sospendere una scorciatoia globale.

Cos'altro è stato pubblicato

Altre due cose hanno viaggiato insieme nella v1.8.10, entrambe degne di una riga. Il sistema di scorciatoie ha anche guadagnato auto-riparazione attraverso sospensione/risveglio: macOS può lasciare cadere in silenzio le registrazioni di scorciatoie globali di un'app quando la macchina va in sospensione, così un handler di ripresa ora le riverifica e le riregistra, il che significa che le tue scorciatoie funzionano ancora dopo che il portatile si sveglia senza riavviare. E sul lato IA, il client è diventato più furbo sui limiti di frequenza — le richieste in streaming che colpiscono un 429 ora fanno back off esponenziale e reimpostano il loro contatore quando smetti di registrare, invece di aspettare un ritardo fisso. Tre fix di affidabilità a sottosistemi diversi, uno dei quali è il crash che non è mai stato un crash.

Per il capitolo precedente della storia v1, stampare una riunione in PDF senza una libreria PDF (v1.8.9); e per tutto l'arco, l'anatomia del pubblicare software fino alla perfezione.

Articoli Correlati

Stampare una riunione in PDF senza una libreria PDF
Steven
Steven9 min di lettura

Stampare una riunione in PDF senza una libreria PDF

GeekBye esporta una riunione come PDF, e non c'è nessuna libreria PDF da nessuna parte nel codice. Rende dell'HTML in una finestra di browser invisibile e la stampa. Quella scelta è tutta la storia: ha reso la funzionalità facile da costruire e le ha dato ogni guasto che ha un browser vero — un lampo bianco, un limite di lunghezza dell'URL, e un'interruzione di pagina che tagliava gli screenshot a metà. La correzione del bug più brutto è stata una sola riga di CSS.

Ingegneria
Electron
Desktop
Da OCR ai pixel senza perdere il fallback
Steven
Steven8 min di lettura

Da OCR ai pixel senza perdere il fallback

GeekBye ha smesso di leggere i tuoi screenshot con l'OCR e ha iniziato a inviare l'immagine reale ai modelli di visione — che è la parte facile. La parte istruttiva è l'ottimizzazione che ha saltato l'OCR del tutto per gli utenti con visione, ha rotto senza rumore il fallback verso i modelli senza visione, ed è dovuta essere disfatta fino a una versione dove la rete di sicurezza gira in parallelo e non costa nulla.

Ingegneria
AI
Desktop
I due modi di fallimento di un overlay click-through
Steven
Steven9 min di lettura

I due modi di fallimento di un overlay click-through

La finestra di GeekBye galleggia sopra ogni cosa e lascia che i tuoi clic la attraversino — tranne dove ha dei pulsanti. È un contratto a due facce, e la v1.8.5 e la v1.8.14 sono ciò che si vede quando ciascuna faccia si rompe: una release dove l'overlay ha inghiottito una finestra di dialogo di sistema, un'altra dove ti ha rubato le battute sulla tastiera. La correzione vincente per la seconda è stata cancellare codice.

Ingegneria
Electron
Desktop