Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Nei capitoli precedenti abbiamo incontrato continuamente istruzioni come:
await requestLessonWithRetry(...);
setTimeout(runCommissionCheck, delay);
chrome.runtime.sendMessage(...);
window.postMessage(...);
new MutationObserver(...);
A prima vista sembrano soltanto modi diversi di dire:
“fai questa cosa più tardi”.
Ma sotto questa frase apparentemente semplice si nasconde uno dei modelli mentali più importanti di JavaScript.
PlumePilot è un buon caso di studio perché l’asincronia non viene usata soltanto per rendere l’interfaccia più fluida. Determina direttamente la correttezza del programma.
Un export deve poter essere annullato mentre aspetta.
Due comandi non devono acquisire contemporaneamente la stessa operazione globale.
Un video può terminare mentre la sidebar si aggiorna.
Una risposta API può arrivare dopo che il contesto che l’ha richiesta non è più valido.
Una scheda può essere nascosta durante un’attesa.
Un messaggio deve ricevere una risposta anche se l’handler la produrrà soltanto dopo diversi await.
Per capire questi problemi dobbiamo rispondere a una domanda fondamentale:
Se JavaScript esegue una sola porzione di codice alla volta, come fanno tutte queste cose a sembrare contemporanee?
Nel normale JavaScript eseguito sul main thread del browser, una funzione non viene interrotta a metà per eseguire arbitrariamente un’altra funzione JavaScript nello stesso agent.
Il modello è detto run-to-completion.
Se stiamo eseguendo:
counter++;
console.log(counter);
un altro callback JavaScript non si inserisce magicamente tra le due istruzioni.
Questo è rassicurante.
Ma non significa:
una funzione async esegue dall'inizio alla fine senza interferenze
Perché una funzione async può volontariamente restituire il controllo all’event loop quando incontra un await.
Per esempio:
async function updateSomething() {
const value = await readState();
await writeState(value + 1);
}
Dal punto di vista logico vediamo:
leggi
incrementa
scrivi
ma dal punto di vista temporale accade qualcosa di più simile a:
Task A: legge 10
↓ await
controllo restituito al runtime
Task B: legge 10
↓ await
Task A: scrive 11
Task B: scrive 11
Il risultato finale è 11, anche se due operazioni volevano incrementare il valore.
Abbiamo appena costruito una race condition senza usare due thread JavaScript in parallelo.
Questa distinzione sarà centrale in tutto il capitolo.
Conviene separare subito due termini.
Più operazioni possono essere in corso nello stesso intervallo temporale, alternando momenti di esecuzione e attesa.
Più operazioni vengono realmente eseguite nello stesso istante da unità di esecuzione differenti.
Il main thread JavaScript del browser ci offre normalmente molta concorrenza senza richiedere che due callback JavaScript vengano eseguiti contemporaneamente sullo stesso thread.
Per esempio:
JavaScript avvia fetch A
JavaScript avvia fetch B
browser attende entrambe le reti
utente clicca un pulsante
JavaScript gestisce il click
risposta B arriva
JavaScript gestisce B
risposta A arriva
JavaScript gestisce A
A livello applicativo A e B sono state concorrenti.
Il loro codice di completamento, invece, viene eseguito secondo il modello dell’event loop.
Da backend developer
È utile pensare alla differenza tra molte richieste I/O in-flight gestite da un runtime event-driven e molti thread che stanno eseguendo simultaneamente codice CPU. Il comportamento osservabile può sembrare simile, ma il modello di sincronizzazione è diverso.
Quando JavaScript chiama una funzione, il runtime aggiunge un nuovo frame allo stack di esecuzione.
Prendiamo:
function a() {
b();
}
function b() {
console.log("ciao");
}
a();
Possiamo rappresentarlo così:
main
↓
a()
↓
b()
↓
console.log()
Quando console.log() termina, il suo frame viene rimosso.
Poi termina b().
Poi a().
Finché lo stack contiene codice sincrono da eseguire, quel codice procede secondo run-to-completion.
fetch() non può tenere occupato lo stackConsideriamo:
const response = await fetch(url);
Una richiesta HTTP potrebbe durare:
20 ms
200 ms
2 s
15 s
Se JavaScript bloccasse il main thread per tutto quel tempo, il browser non potrebbe gestire normalmente:
Il runtime fa invece qualcosa concettualmente simile a:
JavaScript: avvia operazione asincrona
↓
piattaforma browser: continua il lavoro esterno
↓
JavaScript: libera lo stack
↓
... altre cose possono accadere ...
↓
operazione completata
↓
continuazione programmata
Questa è la base del modello event-driven del browser.
Una versione semplificata del modello può essere rappresentata così:
┌─────────────┐
│ call stack │
└──────┬──────┘
│ termina
▼
┌─────────────┐
│ microtasks │
└──────┬──────┘
│ svuota
▼
┌─────────────┐
│ rendering │
└──────┬──────┘
▼
┌─────────────┐
│ next task │
└─────────────┘
È una semplificazione, ma sufficiente per costruire un modello utile.
Il browser dispone di meccanismi esterni che possono produrre lavoro futuro:
network
setTimeout
click
postMessage
MutationObserver
WebExtension messaging
Quando quel lavoro deve tornare nel JavaScript, viene programmato secondo le regole del runtime.
Una distinzione particolarmente importante è quella tra:
task
microtask
In modo semplificato:
MutationObserver usa anch’esso il meccanismo delle microtask.Dopo la fine del task corrente, il runtime esegue le microtask in attesa prima di passare al task successivo.
Questo spiega alcuni ordini di esecuzione che inizialmente possono sembrare strani.
Considera:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
Un modello mentale corretto porta a:
A
D
C
B
Perché:
A è sincrono;setTimeout programma lavoro futuro;.then() programma una microtask;D è ancora sincrono;C;B.Non serve memorizzare ogni dettaglio dell’HTML specification per lavorare bene su PlumePilot.
Serve però capire questo principio:
“asincrono” non significa “ordine casuale”. Esistono regole di scheduling precise.
setTimeout(fn, 0) non significa “adesso”Un errore molto comune è leggere:
setTimeout(callback, 0);
come:
esegui immediatamente callback
In realtà significa più correttamente:
non eseguire prima che sia trascorso il delay minimo
poi rendi callback eleggibile per una futura esecuzione
Il callback può quindi eseguire più tardi.
In PlumePilot troviamo, per esempio:
setTimeout(() => {
const selected = currentLesson();
if (selected) rememberPlaybackLesson(selected);
}, 0);
Riferimento: content.js L7080-L7088.
Qui il significato non è:
aspetta zero millisecondi
ma:
lascia terminare il click corrente
lascia che la pagina aggiorni la propria selezione
poi rileggi il DOM in un task successivo
È un uso temporale deliberato.
Una Promise rappresenta un valore che può diventare disponibile in futuro.
Può trovarsi in uno di tre stati concettuali:
pending
fulfilled
rejected
Per esempio:
const promise = fetch(url);
promise non contiene ancora la risposta HTTP.
Contiene una rappresentazione dell’operazione che produrrà quella risposta oppure un errore.
Molte API Web moderne restituiscono già Promise.
Ma diverse API WebExtension usate dal progetto adottano ancora una forma callback-based.
Per esempio:
chrome.storage.local.get(key, callback);
Nel background PlumePilot costruisce piccoli wrapper:
const storageGet = (key) =>
new Promise((resolve, reject) =>
chrome.storage.local.get(key, (result) =>
chrome.runtime.lastError
? reject(new Error(chrome.runtime.lastError.message))
: resolve(result[key] || null)
)
);
Riferimento: background.js L40-L43.
La trasformazione è:
callback API
↓
Promise wrapper
↓
async / await
Questo evita di propagare callback annidate in tutta l’architettura.
Concetto generale
Se un sistema contiene un boundary asincrono legacy, spesso conviene convertirlo in Promise vicino al boundary e usare un solo modello asincrono nel resto del codice.
async e awaitasyncUna funzione dichiarata:
async function example() {
return 42;
}
restituisce una Promise.
Concettualmente:
example();
produce qualcosa equivalente a:
Promise.resolve(42);
Se invece la funzione lancia un errore:
async function example() {
throw new Error("boom");
}
la Promise viene rejected.
await non blocca il browserQuesta riga:
const operation = await currentOperation();
non significa:
ferma tutto JavaScript finché currentOperation non termina
Significa concettualmente:
sospendi questa funzione async
restituisci il controllo al runtime
quando la Promise termina, pianifica la continuazione
Questa distinzione spiega perché durante un export l’utente può ancora:
await divide una funzione in segmenti temporaliConsideriamo:
async function acquire(kind, sourceTabId) {
return serialized(async () => {
let existing = await currentOperation();
// ...
await storageSet({ [OPERATION_KEY]: operation });
return { accepted: true, operation };
});
}
Riferimento: background.js L620-L641.
Possiamo immaginarla come segmenti:
segmento A
↓ await currentOperation()
--- sospensione ---
segmento B
↓ await tabExists(...)
--- sospensione ---
segmento C
↓ await storageSet(...)
--- sospensione ---
segmento D
Tra due segmenti, altro codice può essere eseguito.
È precisamente qui che nasce la necessità di ragionare sulle invarianti condivise.
acquire() senza serializzazioneImmaginiamo temporaneamente che acquire() non sia protetta da serialized().
Due contesti inviano quasi nello stesso momento:
A: voglio iniziare export dispense
B: voglio iniziare Turbo Test
Entrambi eseguono:
const existing = await currentOperation();
Supponiamo che non esista ancora nessuna operazione.
Un’interleaving possibile sarebbe:
A legge: null
↓ A sospeso
B legge: null
↓ B sospeso
A crea operation-A
B crea operation-B
Entrambe le richieste crederebbero legittimamente di aver acquisito la risorsa.
Abbiamo violato la regola di dominio:
una sola operazione PlumePilot globale alla volta
Nessuna istruzione JavaScript è stata interrotta a metà.
Il problema si trova tra due await.
Ogni segmento sincrono è stato eseguito completamente.
Ma l’operazione logica:
leggi stato
verifica disponibilità
scrivi nuovo stato
non era atomica.
Questa è una race condition applicativa.
Da backend developer
È molto simile a un classico read-modify-write su database privo di transazione o lock. Non importa che ciascuna singola query sia atomica: la sequenza complessiva può comunque essere vulnerabile all’interleaving.
serialized(): una coda Promise come lock logicoIn background.js troviamo:
function serialized(task) {
const next = operationQueue.then(task, task);
operationQueue = next.catch(() => {});
return next;
}
Riferimento: background.js L44-L49.
Il progetto mantiene code separate per:
operazioni
builder
commissione
soglia corso
achievement
suoni
Questo è molto interessante.
Non esiste un mutex del sistema operativo.
Esiste invece una catena di Promise.
Supponiamo:
operationQueue = Promise già risolta
Arriva il task A:
const nextA = operationQueue.then(A, A);
operationQueue = nextA.catch(() => {});
Poi arriva B prima che A termini:
const nextB = operationQueue.then(B, B);
Ma operationQueue ora rappresenta la conclusione di A.
Quindi B non parte fino a quando la catena precedente non si è conclusa.
Il flusso diventa:
A
↓
B
↓
C
↓
D
anziché:
A ─┐
B ─┼─ interleaving
C ─┘
then(task, task)?La forma:
operationQueue.then(task, task)
significa:
esegui il task successivo
sia se la coda precedente è fulfilled
sia se è rejected
Altrimenti un errore precedente potrebbe “avvelenare” permanentemente la coda.
Poi:
operationQueue = next.catch(() => {});
assicura che la Promise usata come coda interna torni in uno stato risolto per i task futuri.
Importante: il chiamante riceve comunque next, quindi il suo task può ancora risultare rejected.
La coda assorbe l’errore per la continuità della serializzazione, non per nascondere l’errore al chiamante.
Possiamo ora definire meglio il concetto.
Una critical section è una regione in cui accediamo a uno stato condiviso e dobbiamo impedire interleaving incompatibili.
Per PlumePilot:
currentOperation()
↓
verifica owner
↓
crea / aggiorna / rilascia operation
è una critical section logica.
serialized() fa sì che le mutazioni relative a quello stato attraversino una corsia unica.
PlumePilot non usa:
oneGlobalQueue
per qualsiasi attività.
Usa, per esempio:
operationQueue
commissionQueue
achievementQueue
soundQueue
Questa è una scelta architetturale importante.
Se due operazioni non condividono la stessa invariante, serializzarle artificialmente riduce la concorrenza senza aumentare la correttezza.
Per esempio:
aggiornare un achievement
non dovrebbe necessariamente impedire:
riprodurre un suono
La domanda corretta non è:
“Come impedisco qualsiasi concorrenza?”
ma:
“Quale stato condiviso deve essere protetto dallo stesso ordine totale?”
busyL’autoplay usa anche una strategia più locale:
if (busy || v !== lastVideo) {
return;
}
busy = true;
try {
// avanzamento asincrono
} finally {
busy = false;
}
Riferimento: content.js L6665-L6671 e content.js L6812-L6815.
busy impedisce che due tentativi di advance() procedano contemporaneamente nello stesso contesto.
È un piccolo reentrancy guard.
Una funzione è vulnerabile alla reentrancy quando può essere richiamata nuovamente prima che la precedente esecuzione logica sia terminata.
Nel nostro caso diversi segnali potrebbero teoricamente provocare un nuovo tentativo di avanzamento mentre il precedente sta ancora:
aspettando il 100%
riaprendo un capitolo
completando un test
cercando il prossimo video
Il flag:
busy = true
stabilisce un’invariante locale:
massimo un workflow advance attivo per volta
finally è fondamentaleImmagina:
busy = true;
await something();
busy = false;
Se something() lancia un errore oppure una delle numerose condizioni produce un return anticipato, rischiamo di non arrivare mai al reset.
Con:
try {
...
} finally {
busy = false;
}
il cleanup viene eseguito anche in presenza di:
return;throw;Questo pattern compare molte volte nel progetto.
Regola pratica
Quando una funzione acquisisce uno stato temporaneo — lock, flag, timer, controller, lease, risorsa — chiediti subito dove viene garantito il rilascio in ogni exit path.
await invalidano le nostre assunzioniawait il mondo è così. Dopo potrebbe non esserlo più.Consideriamo l’autoplay:
let cur = await recoverPlaybackLesson(v);
if (!enabled || v !== lastVideo) return;
Perché controllare di nuovo enabled?
Non lo avevamo già verificato prima?
Sì.
Ma durante:
await recoverPlaybackLesson(v)
l’utente potrebbe aver disattivato l’autoplay.
Oppure il player potrebbe essere cambiato.
Quindi una condizione vera prima dell’await non è automaticamente ancora vera dopo.
Dopo aver aspettato il completamento della lezione, PlumePilot verifica di nuovo:
if (
!enabled ||
v !== lastVideo ||
video() !== v ||
!stableCur ||
playbackLessonRow(completedLesson) !== stableCur
) {
return;
}
Riferimento: content.js L6694-L6699.
Questa non è paranoia.
È una conseguenza diretta dell’asincronia.
Ogni await è un potenziale punto in cui:
utente cambia impostazioni
DOM cambia
pagina cambia route
operazione viene annullata
un altro workflow modifica lo stato
Uno schema molto generale è:
controlla precondizione
↓
await lavoro asincrono
↓
ricontrolla l'invariante
↓
continua soltanto se è ancora valida
In codice:
if (!enabled) return;
await slowOperation();
if (!enabled) return;
È uno dei pattern più importanti da ricordare quando lavori con UI e stato mutabile.
sleep()PlumePilot definisce:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
Questo trasforma un timer in una Promise e permette:
await sleep(250);
Ma un sleep() puro risponde soltanto alla domanda:
è passato abbastanza tempo?
Non risponde a:
la pagina è pronta?
waitFor()Per questo il progetto usa:
async function waitFor(fn, timeout = WAIT_MS) {
let start = Date.now();
while (Date.now() - start < timeout) {
const x = fn();
if (x) return x;
await sleep(100);
}
return null;
}
La versione reale gestisce anche schede nascoste e una seconda finestra di attesa.
Riferimento: content.js L1577-L1609.
Qui il tempo non determina il successo.
Il tempo determina soltanto una deadline.
Il successo dipende da una readiness condition.
waitFor() è una forma di polling:
controlla
↓ no
aspetta 100 ms
↓
controlla
↓ no
aspetta
↓
...
↓ sì
ritorna
È importante notare che:
await sleep(100)
non blocca il thread per 100 ms.
Durante quei 100 ms il browser può fare altro.
In bridge.js troviamo:
function scheduleCommissionCheck(delayMs = 0) {
clearTimeout(commissionCheckTimer);
commissionCheckTimer = null;
if (!commissionCheckEnabled || !pageIsVisible()) return;
commissionCheckTimer = setTimeout(
runCommissionCheck,
Math.max(0, Number(delayMs) || 0),
);
}
Riferimento: bridge.js L112-L117.
Questo codice insegna almeno tre cose.
Prima di crearne uno nuovo:
clearTimeout(commissionCheckTimer);
Così il sistema mantiene concettualmente:
al massimo un check futuro pianificato
Senza questa precauzione, eventi diversi potrebbero accumulare timer duplicati.
Il timer viene creato soltanto se:
commissionCheckEnabled && pageIsVisible()
Lo scheduling temporale viene quindi subordinato allo stato corrente.
delayMs non è un orario garantitosetTimeout(fn, 1000) non promette:
fn partirà esattamente fra 1000 ms
Promette, semplificando:
fn non sarà resa eseguibile prima del delay previsto
Se il main thread è occupato, se la scheda è throttled o se esistono altri task, il callback può arrivare dopo.
Questo è uno dei motivi per cui PlumePilot usa spesso timestamp reali come:
Date.now()
anziché assumere che ogni timer abbia scandito il tempo perfettamente.
I browser possono ridurre la frequenza dei timer delle schede in background.
Per un’estensione che lavora dentro una pagina host questo è importante.
PlumePilot tiene conto della visibility durante diverse attese.
La funzione waitFor() conserva:
hiddenEpochAtStart
startedHidden
poi, se il timeout è maturato mentre la pagina era nascosta, può attendere il ritorno alla visibilità prima di concedere un secondo tentativo.
Questo evita di interpretare:
la scheda non ha avuto opportunità di aggiornarsi
come:
la piattaforma è definitivamente rotta
sleepturboApiRequest()Nel Capitolo 04 abbiamo visto la mini-RPC tra content.js e commission-interceptor.js.
Ora guardiamola dal punto di vista temporale:
function turboApiRequest(action, payload = {}) {
return new Promise((resolve) => {
const requestId = `studywing-${Date.now()}-${++turboApiRequestSequence}`;
const timeout = setTimeout(() => {
pendingTurboApiRequests.delete(requestId);
resolve({ ok: false, error: "RESPONSE_TIMEOUT" });
}, TURBO_API_RESPONSE_TIMEOUT_MS);
pendingTurboApiRequests.set(requestId, { resolve, timeout });
window.postMessage({
type: TURBO_API_REQUEST,
requestId,
action,
...payload,
}, "*");
});
}
Riferimento: content.js L4904-L4923.
Il timeout qui non dice:
aspetta 17 secondi prima di procedere
Dice:
questa Promise non può restare pending indefinitamente
È una deadline applicativa.
Immaginiamo che il MAIN world non risponda più perché:
Senza timeout:
pendingTurboApiRequests
potrebbe conservare per sempre:
requestId → resolve
E il chiamante rimarrebbe sospeso per sempre su:
await turboApiRequest(...)
La deadline trasforma un fallimento silenzioso in un risultato esplicito:
{ ok: false, error: "RESPONSE_TIMEOUT" }
requestIdPiù richieste possono essere in-flight:
request A → lesson 1
request B → lesson 2
request C → outline
Le risposte possono arrivare in ordine diverso:
B
A
C
Non possiamo quindi usare semplicemente:
"la prossima risposta appartiene all'ultima richiesta"
Serve correlazione esplicita.
Il Map:
pendingTurboApiRequests
funge da registro:
requestId-A → resolver-A
requestId-B → resolver-B
requestId-C → resolver-C
Quando arriva una risposta, il sistema risolve esattamente la Promise corrispondente.
Possiamo descrivere questo pattern come un piccolo multiplexer RPC:
┌─ request A ─────────┐
content.js ─────┼─ request B ─────────┼──► MAIN world
└─ request C ─────────┘
┌─ response B ◄───────┐
content.js ◄────┼─ response A ◄───────┼── MAIN world
└─ response C ◄───────┘
Il tempo di completamento non deve coincidere con l’ordine di invio.
Il requestId separa ordine temporale e identità logica.
Se abbiamo:
const p = fetch(url);
non possiamo semplicemente “cancellare la Promise” come concetto generale.
Una Promise rappresenta il risultato.
Per interrompere davvero il lavoro dobbiamo cancellare, quando possibile, l’operazione sottostante.
Per fetch(), il browser offre AbortController.
AbortController nelle API PlumePilotIn commission-interceptor.js:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await originalFetch.call(window, url, {
...options,
signal: controller.signal,
});
// ...
} finally {
clearTimeout(timeout);
turboControllers.delete(message.requestId);
}
Riferimento: commission-interceptor.js L500-L529.
Il controller collega due mondi:
nostra intenzione: annulla
↓
AbortSignal
↓
operazione fetch
Notiamo una cosa elegante:
setTimeout(() => controller.abort(), API_TIMEOUT_MS)
Il timeout non produce direttamente il fallimento.
Produce invece un segnale di abort verso l’operazione reale.
Il flusso è:
request parte
↓
timer parte
↓
request termina prima?
├─ sì → clearTimeout
└─ no → abort()
↓
fetch termina con AbortError
Questo evita di avere una request reale che continua silenziosamente dopo che il chiamante l’ha già considerata fallita.
turboControllersOgni request attiva viene associata al proprio controller:
turboControllers.set(message.requestId, controller);
Quando arriva:
STUDYWING_TURBO_API_CANCEL
l’interceptor esegue:
for (const controller of turboControllers.values()) {
controller.abort();
}
turboControllers.clear();
Riferimento: commission-interceptor.js L792-L795.
Il Map non serve quindi soltanto a tracciare richieste.
È anche un registry delle operazioni cancellabili in-flight.
AbortSignalMolte operazioni di PlumePilot non sono una singola fetch().
Un export può essere:
apri sezione
aspetta rendering
leggi capitolo
richiedi API
aspetta pacing
apri capitolo successivo
...
Non esiste necessariamente un’unica API nativa che possiamo abortire.
Qui entra la cancellazione cooperativa.
ensureExportNotCancelled()PlumePilot controlla periodicamente:
function ensureExportNotCancelled(operationId) {
if (
exportCancelRequested &&
operationId &&
operationId === activeExportOperationId
) {
throw exportAbortError();
}
}
Riferimento: content.js L1611-L1625.
Il workflow accetta quindi implicitamente un contratto:
tra un passo e l'altro controllerò se devo fermarmi
sleep diventa cancellabileUn semplice:
await sleep(3000);
non reagirebbe alla cancellazione fino al termine dei tre secondi.
Per questo esiste:
async function exportSleep(ms, operationId) {
const end = Date.now() + ms;
while (Date.now() < end) {
ensureExportNotCancelled(operationId);
await sleep(Math.min(100, end - Date.now()));
}
ensureExportNotCancelled(operationId);
}
Riferimento: content.js L1627-L1634.
Il delay lungo viene spezzato in piccole finestre di cooperazione.
Questa struttura stabilisce una proprietà misurabile:
latenza massima teorica di reazione durante quel sleep ≈ 100 ms
Non è un dettaglio cosmetico.
È parte dell’UX.
Un pulsante “Annulla” che reagisce dopo 10 secondi sembra rotto anche se tecnicamente funziona.
AbortController nel builder EPUBNel builder:
buildController = format === "epub"
? new AbortController()
: null;
Il signal viene passato a:
buildCourseEpub(..., {
signal: buildController.signal,
});
E il pulsante esegue:
buildController.abort();
Riferimento: materials-builder.js L134-L175 e materials-builder.js L179-L185.
Qui il signal non cancella una fetch specifica.
Diventa un protocollo generico di cancellazione tra builder UI e motore EPUB.
try/catch/finally nei workflow asincroniIl cuore del builder segue:
acquire
↓
try
build
download
catch
mostra errore / annullamento
finally
release
pulisci controller
ripristina UI
Questa è una struttura particolarmente robusta.
Il finally contiene ciò che deve succedere indipendentemente dall’esito.
Il builder distingue:
const cancelled = error?.name === "AbortError";
Poi presenta messaggi diversi.
Questo riflette una distinzione semantica importante:
failure = qualcosa non ha funzionato come previsto
cancellation = l'utente ha chiesto intenzionalmente di fermarsi
Entrambi possono usare il meccanismo delle eccezioni per interrompere il flusso, ma non sono lo stesso evento di dominio.
throw dentro una funzione asyncIn una funzione async:
throw new Error("x");
produce una Promise rejected.
Quindi:
await functionThatFails();
fa emergere l’errore nel punto dell’await, dove può essere gestito con:
try {
await ...;
} catch (error) {
...
}
Questo permette di scrivere workflow asincroni con una struttura simile al codice sincrono.
action.then(sendResponse).catch(...)Il dispatcher del background termina con:
action
.then(sendResponse)
.catch((error) => {
console.error(...);
sendResponse({ accepted: false, reason: error.message });
});
return true;
Riferimento: background.js L918-L945.
Qui il background trasforma:
Promise fulfilled → protocol response success
Promise rejected → protocol response error
Il boundary di messaging diventa quindi anche un error boundary.
return true è ancora asincroniaIl listener WebExtension viene chiamato sincronicamente quando arriva il messaggio.
Ma l’azione scelta può richiedere:
storage
network
messaggi a tab
creazione builder
Quindi sendResponse() verrà chiamato in futuro.
Il return true comunica al runtime:
non chiudere ancora il canale di risposta
risponderò asincronamente
Abbiamo già incontrato il comportamento nel Capitolo 02.
Ora possiamo collocarlo nel modello dell’event loop:
listener task
↓
avvia Promise
↓
return true
↓
listener termina
↓
... runtime continua ...
↓
Promise settle
↓
.then(sendResponse)
await-atoIn content.js troviamo:
void collectCourseMaterials(format, operationId).finally(() => {
log("Export collection request finished.", { operationId, format });
});
Il chiamante del messaggio risponde subito:
richiesta accettata
senza aspettare che l’intero export termini.
Questo è appropriato perché l’operazione lunga possiede un proprio protocollo di stato.
È una distinzione fondamentale nei sistemi asincroni.
Possiamo avere:
POST /job
→ 202 Accepted
oppure, nel nostro caso:
PEGASO_COLLECT_COURSE_MATERIALS
→ accepted: true
Il significato è:
il workflow è partito
non:
il file è pronto
Lo stato successivo viene comunicato tramite altri eventi e messaggi.
Questo modello assomiglia molto a job queue e background processing lato server.
void somePromise()Se facessimo soltanto:
void doSomethingAsync();
una rejection non gestita potrebbe diventare un errore difficile da correlare.
Quando scegliamo volontariamente il fire-and-forget dovremmo comunque definire:
chi osserva il fallimento?
chi pulisce lo stato?
chi notifica l'utente?
Nel workflow export, la funzione stessa pubblica status/failure event e contiene cleanup.
Quindi l’assenza di await nel chiamante non equivale all’assenza di ownership.
Promise.allSettled()Per cancellare la memoria commissione in tutte le tab:
const results = await Promise.allSettled(
tabs
.filter((tab) => Number.isInteger(tab?.id))
.map((tab) =>
sendTabMessage(tab.id, {
type: "PEGASO_CLEAR_COMMISSION_MEMORY",
})
),
);
Riferimento: background.js L684-L698.
Perché allSettled() e non Promise.all()?
Promise.all() è fail-fastConcettualmente:
await Promise.all([A, B, C]);
se B viene rejected, la Promise aggregata viene rejected.
Questo è utile quando:
tutte le operazioni sono necessarie al risultato complessivo
Ma qui le tab sono indipendenti.
Una tab potrebbe essere appena stata chiusa.
Vogliamo comunque contattare le altre.
Promise.allSettled() conserva il risultato di tuttiIl risultato contiene elementi del tipo:
{ status: "fulfilled", value: ... }
oppure:
{ status: "rejected", reason: ... }
Il background può quindi calcolare:
results.filter(
(result) =>
result.status === "fulfilled" &&
result.value?.accepted
).length;
Il modello semantico è:
best effort verso molte destinazioni indipendenti
Abbiamo visto:
serialized() → forza ordine
Promise.allSettled → permette concorrenza
Non sono contraddittori.
Risolvono problemi diversi.
La capacità importante non è “usare Promise”.
È scegliere quale relazione temporale è corretta per il dominio.
Nel Capitolo 04 abbiamo incontrato:
await exportSleep(API_MATERIAL_PACING_MS, operationId);
tra richieste successive.
Questa scelta riduce volontariamente la concorrenza.
Perché?
Perché massimizzare il parallelismo non significa automaticamente massimizzare l’affidabilità.
Un burst di richieste può:
Il pacing è una forma semplice di backpressure applicativa.
finally e cleanup temporaleIn executeTurboApiRequest():
finally {
clearTimeout(timeout);
turboControllers.delete(message.requestId);
}
Questo garantisce che, indipendentemente da:
success
HTTP error
JSON error
abort
exception
non rimangano:
timer inutili
controller stale
entry in-flight obsolete
Possiamo leggere finally come:
ripristina le invarianti temporanee del workflow.
Non pensiamo soltanto a memoria o file.
Un workflow asincrono può possedere:
timer
listener
AbortController
Map entry
operationId
flag busy
lease
builder tab
storage job
Queste risorse hanno un lifetime.
La domanda architetturale è:
chi le crea?
chi ne è owner?
quando vengono distrutte?
che succede se il workflow fallisce a metà?
Questa prospettiva è molto utile quando un bug sembra essere “uno stato rimasto appeso”.
Nel Capitolo 02 abbiamo visto il problema:
bridge pubblica stato autoplay
↓
content.js non ha ancora installato listener
↓
evento perso
Questo non è un problema di sintassi.
È un problema temporale.
La soluzione usata da PlumePilot è:
window.postMessage({
type: "PEGASO_AUTONEXT_STATE_REQUEST",
}, "*");
subito dopo aver installato il listener.
Riferimento: content.js L557-L559.
Il pattern è:
subscribe
↓
request snapshot
↓
receive future events
Una soluzione classica al problema del subscriber che arriva tardi.
awaitIl capitolo precedente ci ha mostrato gli stale DOM references.
Ora possiamo descriverne la causa temporale.
PlumePilot conserva:
const completedLesson = playbackContext;
poi esegue:
const completed = await waitForLessonCompletion(cur, v);
Durante quell’attesa:
sidebar aggiornata
accordion chiuso
row sostituita
Per questo, dopo l’await, il codice non continua semplicemente a usare cur.
Risolva nuovamente:
const stableCur = await recoverPlaybackLesson(v);
L’asincronia spiega quindi il perché del pattern resolve late studiato nel Capitolo 03.
waitFor() come sincronizzazione con un sistema esternoNei sistemi concorrenti classici esistono primitive come:
condition variable
semaphore
future
channel
Nel browser, quando sincronizziamo PlumePilot con una UI esterna che non espone un evento perfetto, waitFor() svolge un ruolo concettualmente simile:
non continuare finché questa condizione osservabile non diventa vera
Naturalmente è implementato via polling, non è una vera condition variable del runtime.
Ma il modello mentale è utile.
Possiamo distinguere:
await waitFor(() => chapterIsOpen());
Dice:
continua quando la condizione è vera
await sleep(LESSON_CLOSE_DELAY_MS);
Dice:
anche se il segnale visibile è arrivato, concedi alla piattaforma una finestra per completare il cleanup della sessione
Entrambe sono legittime.
La differenza è sapere perché stiamo aspettando.
In waitForLessonCompletion():
await sleep(LESSON_CLOSE_DELAY_MS);
arriva dopo che la sidebar ha registrato il 100%.
Il commento spiega:
la sidebar può raggiungere 100%
prima che la piattaforma abbia realmente rilasciato la sessione/player
Riferimento: content.js L1380-L1388.
Questo è un buon esempio di eventual consistency temporale tra due sottosistemi della stessa piattaforma.
Quando scegliamo:
retry dopo 350 ms
poll ogni 100 ms
pacing 150 ms
commissione ogni 10 minuti
cancellazione controllata ogni 100 ms
non stiamo soltanto scegliendo numeri tecnici.
Stiamo definendo:
I tempi fanno parte del comportamento del prodotto.
Una race condition dipende dall’interleaving.
Per esempio:
rete veloce → funziona
rete lenta → fallisce
DevTools aperti → timing diverso
scheda visibile → funziona
scheda background → fallisce
Questo può produrre la sensazione:
“il bug capita ogni tanto senza motivo”.
Il motivo invece esiste.
È soltanto temporale.
Domande utili:
questa funzione contiene await?
legge stato prima dell'await e lo usa dopo?
qualcun altro può modificare quello stato?
una callback può arrivare due volte?
può partire la stessa operazione da due UI diverse?
un timer precedente è ancora attivo?
il DOM può essere stato ricostruito?
Queste domande sono spesso più utili di aggiungere log casuali.
Nel progetto incontriamo strategie differenti:
serialized queue → ordine su stato condiviso
busy flag → anti-reentrancy locale
operationId → correlazione e ownership
requestId → risposta alla richiesta corretta
dedup sets → ignora messaggi duplicati
AbortController → cancella lavoro in-flight
re-check after await → valida nuovamente le precondizioni
fresh DOM resolution → evita riferimenti obsoleti
timeout → evita pending infinito
lease → ownership temporanea cross-context
Il valore didattico è proprio questo:
“race condition” è una categoria di problema; la soluzione dipende dal tipo di stato condiviso e dal protocollo.
Serializzare significa:
A poi B poi C
Idempotenza significa:
eseguire due volte X non produce effetti aggiuntivi indesiderati
Una coda non rende automaticamente un’operazione idempotente.
E un’operazione idempotente può comunque richiedere serializzazione se modifica stato condiviso con altre operazioni differenti.
PlumePilot usa entrambi i concetti.
Promise come state machinePossiamo modellarla come:
┌────────── fulfilled
pending ───┤
└────────── rejected
Una volta settled, non torna pending.
Questa proprietà è importante perché rende il risultato immutabile dal punto di vista della Promise.
Il workflow esterno può avere molti più stati:
collecting
stopping
building
completed
failed
ma ogni singola Promise rappresenta una transizione futura con un solo esito finale.
Questo anticipa il Capitolo 07 sulle state machine.
Alcune funzioni comunicano errori tramite rejection:
throw new Error(...);
Altre restituiscono valori:
{ ok: false, error: "RESPONSE_TIMEOUT" }
Perché?
Dipende dal livello del protocollo.
Per una mini-RPC può essere utile avere un envelope uniforme:
ok/data/error
Per un workflow interno può essere più naturale propagare eccezioni fino a un catch centrale.
L’importante è che il boundary abbia un contratto coerente.
Dice:
oltre questa deadline non aspetterò più
Dice:
proverò nuovamente l'operazione
Dice:
non desidero più che il workflow continui
Dice:
l'operazione è valida, ma non voglio iniziare la successiva troppo presto
Questi concetti usano tutti il tempo, ma risolvono problemi differenti.
Confonderli porta facilmente a codice difficile da ragionare.
building come guard UIIn materials-builder.js:
async function build(format) {
if (building || !job) return;
setBusy(true);
// ...
}
Riferimento: materials-builder.js L134-L136.
Questo protegge il workflow da:
doppio click
click PDF + EPUB quasi simultanei
Ma notiamo che il progetto dispone anche di una protezione più globale tramite acquire(format).
Quindi abbiamo difesa a più livelli:
UI guard
↓
operation manager globale
La UI guard migliora immediatamente l’esperienza locale.
L’operation manager protegge invece l’invariante globale anche se il comando arriva da:
popup
floating menu
builder
altra tab
Sono due scope differenti.
Questa è defense in depth applicata alla concorrenza.
Un’operazione può durare abbastanza da sopravvivere alla tab che l’ha iniziata.
Per questo il background controlla:
if (await tabExists(ownerTabId)) {
return ...;
}
await removeOperation(operation);
Riferimento: background.js L604-L618.
Questo è un problema di liveness:
un lock/operation record non deve restare occupato per sempre
se il suo owner non esiste più
Qualcosa di sbagliato non deve accadere.
Esempio:
non devono partire due export globali simultanei
Qualcosa di buono deve prima o poi poter accadere.
Esempio:
se una tab owner viene chiusa, l'operation lock non deve restare per sempre
Le code e i lock migliorano la safety.
Il cleanup degli owner orfani protegge la liveness.
Questi termini vengono spesso usati nei sistemi concorrenti e distribuiti e sono sorprendentemente utili anche dentro una browser extension.
leggo stato
await
uso quello stato come se fosse ancora attuale
due trigger avviano lo stesso workflow
request parte
listener/context sparisce
Promise resta pending
nuovo timer creato senza cancellare quello precedente
owner scompare
stato globale resta occupato
risposta arriva correttamente
ma appartiene a uno stato dell'app ormai superato
Queste categorie sono ottimi nomi da usare anche durante il debugging.
Un log:
request completed
è poco utile.
Meglio:
requestId
operationId
courseCode
sourceTabId
phase
timestamp
Perché ci permettono di ricostruire l’interleaving.
Quando un bug è temporale, conviene trasformare i log in una timeline:
12:00:00.000 A acquire requested
12:00:00.010 B acquire requested
12:00:00.020 A entered queue
12:00:00.050 A wrote operation
12:00:00.051 B entered queue
12:00:00.060 B saw existing operation
La timeline rende visibile ciò che il codice sorgente, letto staticamente, nasconde.
Per testare una race può essere utile, in ambiente di sviluppo, inserire temporaneamente:
await sleep(500);
nel punto vulnerabile.
Non come fix.
Come strumento diagnostico.
Se il bug diventa riproducibile quando allarghiamo una finestra temporale, abbiamo una forte indicazione che il problema dipenda dall’interleaving.
Alcune API WebExtension moderne supportano direttamente Promise in determinati browser/versioni.
Dove la compatibilità lo consente, potremmo ridurre wrapper callback manuali.
Ma il wrapper attuale offre anche un vantaggio:
normalizza comportamento cross-browser in un solo punto
Quindi “più moderno” non significa automaticamente “migliore per questo progetto”.
Le funzioni:
serialized
serializedBuilder
serializedCommission
serializedAchievement
...
sono brevi ma ripetitive.
In una futura architettura potremmo modellare:
function createSerialQueue() {
let tail = Promise.resolve();
return function run(task) {
const next = tail.then(task, task);
tail = next.catch(() => {});
return next;
};
}
poi:
const serializeOperation = createSerialQueue();
const serializeCommission = createSerialQueue();
Il concetto non cambierebbe.
Cambierebbe soltanto la sua espressione.
Attualmente PlumePilot usa sia:
AbortController
sia:
flag + ensureExportNotCancelled()
Una possibile evoluzione architetturale sarebbe portare più workflow verso un contratto uniforme basato su AbortSignal, mantenendo eventualmente adapter per le operazioni che richiedono controllo cooperativo.
Per esempio:
function throwIfAborted(signal) {
signal?.throwIfAborted();
}
in punti strategici.
Non è necessariamente urgente, ma renderebbe più uniforme il concetto di cancellazione.
fetch?
timer?
workflow DOM?
message request?
tab?
background?
builder?
operationId?
await?DOM
impostazioni
route
owner
cache
success
failure
timeout
cancel
owner disappeared
finally?
release()?
clearTimeout()?
delete Map entry?
Se sai rispondere a queste cinque domande, gran parte del codice asincrono diventa molto meno misterioso.
Riprendiamo il workflow delle dispense, questa volta guardandolo soltanto come sistema asincrono.
utente clicca "Genera dispense"
popup.js avvia:
runtimeMessage({ type: "PEGASO_START_EXPORT", ... })
Il dispatcher crea la Promise startExport() e mantiene aperto il canale con return true.
acquire() entra nella coda serialized.
Controlla e scrive lo stato globale senza interleaving con altri acquire/update/release.
Il background attende:
await sendTabMessage(...)
Il bridge risponde immediatamente:
accepted: true
ma inoltra un evento al MAIN world.
La raccolta parte fire-and-forget.
Il messaggio iniziale può quindi concludersi senza aspettare tutto il lavoro.
Il workflow esegue sequenze di:
await API
await readiness
await pacing
check cancellation
Durante l’operazione vengono pubblicati aggiornamenti.
Il bridge riceve il payload e chiede al background di aprire il builder.
L’apertura è protetta da serializedBuilder() per evitare builder duplicati.
Il builder legge il job dallo storage e libera l’operation di raccolta.
L’utente può scegliere PDF o EPUB.
Il builder acquisisce una nuova operation di tipo build.
L’EPUB possiede AbortController.
Qualunque sia l’esito:
release operation
reset controller
reset UI busy state
Questo singolo flusso contiene quasi tutti i concetti del capitolo.
sequenceDiagram
participant U as User
participant P as Popup
participant B as Background
participant Br as Bridge
participant C as content.js
participant API as Multiversity API
participant Bu as Builder
U->>P: click export
P->>B: PEGASO_START_EXPORT
B->>B: serialized acquire()
B->>Br: collect materials
Br-->>B: accepted
B-->>P: operation accepted
Br->>C: window.postMessage
C->>API: request lesson data
API-->>C: JSON
C->>C: pacing / cache / cancellation checks
C->>Br: materials collected
Br->>B: open builder
B->>B: serializedBuilder()
B->>Bu: create builder tab + job
Bu->>B: acquire build operation
Bu->>Bu: build PDF/EPUB
Bu->>B: release operation
Osserva una cosa importante:
la risposta al click iniziale arriva molto prima della fine dell'export
Questa è progettazione asincrona del protocollo, non soltanto sintassi JavaScript.
Immagina:
async function acquire() {
const operation = await currentOperation();
if (operation) return false;
await storageSet(newOperation);
return true;
}
Due chiamanti la eseguono quasi contemporaneamente.
Domande:
busy locale in ciascun chiamante risolverebbe il problema?awaitConsidera:
if (!enabled) return;
const row = currentLesson();await waitForSomething();
clickRow(nextLesson(row));
Elenca almeno tre cose che potrebbero essere diventate false durante l’await.
Una possibile risposta:
enabled può essere diventato false
row può essere detached
la route può essere cambiata
Come riscriveresti il flusso?
Promise.all o allSettled?Devi inviare un messaggio a dieci tab aperte.
Vuoi pulire la cache su tutte quelle raggiungibili.
Se una tab fallisce, vuoi comunque continuare con le altre.
Quale combinatore Promise rappresenta meglio il dominio?
Perché?
Immagina:
const result = await Promise.race([
fetch(url),
timeoutPromise(5000),
]);
Se vince il timeout, che cosa succede necessariamente alla fetch?
Risposta importante:
non è detto che venga cancellata
Come useresti AbortController per collegare la deadline al lavoro reale?
Una funzione viene chiamata cinque volte:
function schedule() {
timer = setTimeout(run, 1000);
}
Quante esecuzioni di run() potrebbero essere pianificate?
Come cambia se aggiungiamo:
clearTimeout(timer);
prima del nuovo setTimeout?
Meccanismo che coordina l’esecuzione dei task asincroni nel runtime JavaScript.
Una porzione sincrona di JavaScript termina prima che un altro job JavaScript venga eseguito nello stesso agent.
Stack dei contesti di esecuzione attualmente attivi.
Unità di lavoro programmata dall’event loop, per esempio un evento o un timer.
Lavoro ad alta priorità rispetto al task successivo, usato tra l’altro dalle continuazioni Promise.
Rappresentazione di un risultato futuro fulfilled oppure rejected.
async / awaitSintassi che permette di descrivere workflow Promise-based in forma sequenziale senza bloccare il browser durante le attese.
Più operazioni possono essere in-flight nello stesso intervallo temporale.
Più operazioni vengono realmente eseguite simultaneamente.
Il risultato dipende da un interleaving temporale non adeguatamente controllato.
Regione logica in cui un accesso a stato condiviso deve rispettare una mutua esclusione o un ordine controllato.
Forzare operazioni concorrenti a essere eseguite in sequenza.
Meccanismo che impedisce a un workflow di essere riavviato mentre una sua esecuzione precedente è ancora attiva.
Protocollo in cui il workflow controlla periodicamente un segnale o flag di cancellazione e termina volontariamente.
API standard che produce un AbortSignal per cancellare operazioni compatibili.
Deadline oltre la quale un’attesa non viene più considerata accettabile.
Ritardo deliberato tra operazioni valide per regolare il ritmo del carico.
Proprietà secondo cui un sistema non deve restare bloccato indefinitamente e deve poter continuare a fare progresso.
Proprietà secondo cui stati o transizioni proibite non devono verificarsi.
Meccanismo con cui un produttore di lavoro viene rallentato per non saturare il consumatore o una risorsa esterna.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model
Da studiare soprattutto:
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth
Queste pagine chiariscono il rapporto tra:
task queue
microtask queue
Promise
MutationObserver
rendering
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises
Interessante anche per il pattern:
callback API → Promise wrapper
usato nel background di PlumePilot.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await
Concentrati sul fatto che await sospende la funzione async, non l’intero thread del browser.
setTimeout()https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout
Da ricordare:
il delay è una soglia minima di scheduling, non un appuntamento temporale perfetto.
AbortControllerhttps://developer.mozilla.org/en-US/docs/Web/API/AbortController
AbortSignalhttps://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
Rileggi queste API pensando sia alle request HTTP sia alla cancellazione del builder EPUB.
Promise.all()https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
Promise.allSettled()https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
Confronta esplicitamente:
fail-fast
vs
raccogli tutti gli esiti
Il punto centrale di questo capitolo non è che JavaScript possieda Promise e async/await.
È capire che un programma asincrono è un programma in cui il tempo modifica la validità delle nostre assunzioni.
Abbiamo visto che:
il codice sincrono è run-to-completion
↓
ma un workflow async è diviso da await
↓
tra due segmenti lo stato può cambiare
↓
quindi dobbiamo proteggere invarianti
PlumePilot utilizza diversi strumenti a seconda del problema:
Promise wrappers → uniformano callback API
serialized queues → proteggono read-modify-write condivisi
busy flags → evitano reentrancy locale
requestId → correlano operazioni concorrenti
timeout → evitano attese infinite
AbortController → interrompe il lavoro sottostante
cooperative cancel → interrompe workflow composti
finally → ripristina invarianti e risorse
allSettled → raccoglie best-effort indipendenti
re-check after await → valida nuovamente il mondo corrente
La lezione più importante può essere riassunta così:
Il fatto che JavaScript esegua un callback alla volta non rende automaticamente atomici i workflow asincroni. Ogni
awaitè un punto in cui il mondo può cambiare.
E una seconda lezione segue direttamente:
La correttezza asincrona non consiste nell’eliminare la concorrenza, ma nel decidere dove permetterla, dove ordinarla e come riconoscere quando un risultato arrivato tardi non è più valido.
Ora sappiamo quando le cose possono cambiare.
Nel Capitolo 06 studieremo dove vive quello stato:
Seguiremo:
chrome.storage.local;Dopo i Capitoli 05 e 06 avremo il vocabolario necessario per affrontare l’autoplay come una vera state machine.
← 04 — API e reverse engineering · Indice · 06 — Stato, cache e storage →