Wild Coding · 29 Maggio 2026
270 a zero
Il log del 18 maggio era chiaro nella sua piattezza: duecentosettanta cicli di fetch consecutivi, zero video restituiti su tutti i canali monitorati. Nessuna eccezione, nessuna riga rossa, nessun errore. Solo la cortesia algida di un endpoint che dice di sì e ti restituisce un array vuoto.
Ho cercato la spiegazione fuori prima che dentro. Le issue di youtubei.js, la libreria che usiamo per parlare con InnerTube, conoscevano già il fenomeno. La #1158 era stata chiusa ad aprile dal maintainer LuanRT con la formula "insufficient time between requests". La #1166, ancora più netta, si era chiusa il 14 maggio con "there is no way to fix it, as it's a server side thing".
Traduzione: il problema viveva nei datacenter di Mountain View, non nel mio codice. L'antibot di YouTube aveva alzato la soglia, e non c'erano più patch a monte che potessero abbassarla.
Resta che non potevo restare a mani in tasca. Anche se la causa era dall'altra parte del mondo, due cose del mio codice la stavano amplificando in modo evitabile.
La prima era una vecchia abitudine: ogni volta che fetchYouTubeVideos() tornava una lista vuota, distruggevo la sessione Innertube con resetYouTubeSession(). L'idea, scritta tempo prima, era difensiva: se non ottieni nulla, ricominci pulito. Peccato che ricominciare pulito, nei termini di YouTube, significhi nascere già marcati. La issue #1158 era esplicita: una volta che il client viene segnato, le nuove istanze ereditano il marchio. Stavo facendo handshake ravvicinati con un server che mi stava dicendo, gentilmente, di starmene buono.
La seconda era un effetto collaterale di una patch ancora più vecchia. Lo scheduler tra un feed e il successivo metteva una pausa di un secondo. L'avevo introdotta mesi prima per evitare burst RSS. Per dieci canali YouTube consecutivi, però, quella finestra produceva esattamente il pattern che YouTube punisce: dieci richieste in diciannove secondi, l'una sull'altra.
Ho cambiato due cose. Su videoList.length === 0 la sessione resta viva. La pausa inter-feed diventa cinque secondi quando il feed corrente o quello successivo è di tipo youtube; per gli RSS rimane a un secondo. La logica salta comunque l'attesa sull'ultimo feed del ciclo, perché non c'è un dopo.
Questa è la v2.0.3 — "Mitigazione antibot". Non risolve. Smette di peggiorare. È la differenza più importante che esista quando il problema vive su un server altrui.
Otto giorni di silenzio (mio)
Per otto giorni non ho toccato il codice.
Non c'erano emergenze. Il bot girava, gli RSS rispondevano, YouTube tornava a darmi qualche video qua e là: meno di prima, ma non zero. Avrei potuto fermarmi, come dopo la v2.0.2.
Invece sapevo che c'erano altre crepe. Le sentivo dal modo in cui il software lavorava nel suo angolo: ogni tanto un log strano, una marcatura sospetta, una statistica che non quadrava di un'ora o due. Niente di drammatico, ma quel rumore di fondo che si fa udibile quando hai smesso di gridare.
Il 29 maggio mi sono seduto davanti al codice con un'idea sola: leggere tutto, dall'index del processo main fino al render della Dashboard, e segnare ogni cosa che mi sembrava fragile. Audit completo. Niente patch al volo, niente fix improvvisati. Prima la lista, poi le release.
La lista è uscita lunga. L'ho divisa in quattro fasce di gravità.

La fascia gravissima: l'app che si spegneva da sola
C'era una sola voce in questa fascia, ma da sola valeva la giornata.
Il file src/main/index.ts aveva un handler globale per le unhandledRejection. La sua logica era brutale: ogni promise rejected non gestita chiamava app.exit(1). L'idea, nata mesi prima, era che una rejection non gestita fosse sintomo di uno stato corrotto da cui era meglio uscire subito. È un ragionamento che vale per le uncaughtException: un'eccezione sincrona arrivata fino al top dello stack è un guaio reale. Per le rejection asincrone è quasi sempre fuori bersaglio. Rejection significa rete che fa i capricci, rate-limit di Telegram, un lock momentaneo su SQLite. Roba che si ripresenta cinque secondi dopo come se niente fosse.
Il guaio è che esisteva un percorso concreto che le innescava. La processPublishQueue() veniva chiamata fire-and-forget, senza await e senza .catch. Dentro, BotManager.markProcessed() e this.getClient() stavano fuori dal try/catch del consumer: un errore SQLite o un token malformato propagava fino in cima alla promise, unhandledRejection, app.exit(1). Lo scheduler chiamava this.checkLoop().then(...) senza .catch, stesso esito. Per un'applicazione che gira ventiquattr'ore su ventiquattro, era una pistola puntata alla tempia ogni notte.
Il fix ha tre passaggi. L'handler unhandledRejection ora logga e prosegue, senza dialog bloccanti né exit. La processPublishQueue() viene invocata con .catch che azzera isPublishing. Il corpo del consumer entra in un blocco try/finally con try/catch per ogni job: un errore su un singolo invio viene loggato e quel job saltato, gli altri proseguono. Lo scheduler diventa this.checkLoop().catch(...).finally(() => this.scheduleNext()): un errore in checkLoop non interrompe più il polling.
Nessuna di queste modifiche cambia il comportamento visibile. Ma adesso, se durante la notte la rete fa i capricci, il bot resta acceso. Questa è la v2.0.4 — "Stability hardening".
La fascia grave: tre buchi che aspettavano il momento
Tre cose, ognuna abbastanza grossa da meritare una versione tutta sua. Le ho impacchettate insieme in v2.1.0 — "IronShield v2", perché la più visibile delle tre era la riscrittura di IronShield, e perché lo schema del database saliva di un livello per accompagnarla (v11 → v12).
IronShield v2: dedup per titolo finalmente onesta
C'era un fastidio che mi portavo dietro dal 28-29 aprile. Avevo notato nei log un comportamento che non sapevo dimostrare ma che mi suonava sbagliato: di tanto in tanto un articolo RSS spariva senza essere stato pubblicato, marcato in history come duplicato. Andando a leggere la riga incriminata, scoprivo che il "duplicato" era un video YouTube con lo stesso titolo dell'articolo. Non lo stesso link, non lo stesso autore, non la stessa fonte. Solo lo stesso titolo.
Il problema viveva in IronShield v1, quella che avevamo presentato a fine aprile come fortezza definitiva. La dedup per title_hash era globale per bot. Se un video intitolato "L'estate del 1985" passava su un feed YouTube, qualsiasi articolo RSS o podcast con lo stesso titolo veniva bloccato per sempre su quel bot. Per due tipi di contenuto che condividono solo l'omonimia, era un esilio ingiusto.
La soluzione che ho scelto è lo scoping per content_type. La colonna entra nello schema v12 della tabella history, con TEXT NOT NULL DEFAULT 'rss'. Il backfill automatico la popola sui dati esistenti leggendo feeds.type: dove vale youtube, il content_type diventa youtube; il resto va su rss; le righe orfane finiscono sul default e nessuna riga viene persa. L'indice idx_history_title_dedup si ricrea su (bot_id, title_hash, content_type). Due item con lo stesso titolo si bloccano a vicenda solo se vengono dallo stesso tipo di sorgente. Il check per ID, ovvero l'MD5 del link, resta globale come prima: un link uguale è sempre un duplicato vero.
Ho collaudato la migrazione su un DB simulato. Backfill, righe orfane, dedup scoped: tutto come previsto. Il falso positivo del 28 aprile, riprodotto sul vecchio schema, viene bloccato; sul nuovo, passa.
Importa Database: nessuno controllava il file
C'era una funzionalità che esisteva da v1.x e che nessuno aveva mai stressato per davvero: «Importa Database». L'utente sceglie un file .db dal disco, l'app sostituisce titan.db con quello e si riavvia. Pulito, lineare, completamente cieco.
import-database copiava qualsiasi file gli passassi sopra il database attivo. Un .db corrotto, un file di testo rinominato in .db, un file completamente diverso da una base SQLite: tutti accettati senza una verifica. Al riavvio, initDB esplodeva con un not a database file o con un errore della cifratura su una colonna inesistente, e l'app non si avviava più. L'utente non aveva nemmeno una UI a cui tornare per riprovare con un file giusto.
Il fix è banale e doveva essere lì dal principio. Prima della copia, apro il file in sola lettura con better-sqlite3, eseguo PRAGMA integrity_check e verifico che esista almeno la tabella bots. Se uno dei due controlli fallisce, l'import si interrompe con un messaggio chiaro e il database attivo resta intatto. Nessun riavvio, nessuna app rotta.
Il token che taceva
Il terzo era il più sottile e per questo il più cattivo. Quando safeStorage di Electron non è disponibile (può succedere su una Linux box senza keychain ben configurato, o quando importi un database su una macchina diversa da quella che l'ha cifrato), la decryptToken() ritornava una stringa vuota. Senza errori, senza log, senza warning. Il bot partiva, contattava Telegram con un token vuoto, e Telegram rispondeva "Unauthorized". Nell'interfaccia compariva quella parola, generica e poco utile, lasciando l'utente a chiedersi se aveva sbagliato il token o se Telegram era giù.
Adesso crypto.ts emette un warning diagnostico esplicito su ogni fallimento di decifratura. L'engine.ts ha un guard sui bot con token illeggibile: invece di tentare invii destinati a fallire, il bot viene saltato e in console e UI compare un messaggio azionabile, token non leggibile su questa macchina, reinserisci il token. Detto in lingua umana, non in linguaggio di protocollo.
La fascia media: quattro cose che funzionavano per sbaglio
Quattro problemi minori, raccolti in v2.1.1 — "Robustness fixes". Tutte cose che giravano da settimane senza farsi notare perché quasi funzionavano.
Il fuso orario che mentiva al watchdog
last_fetch_at e digest_last_sent venivano scritti con datetime('now'): SQLite restituisce un timestamp UTC nel formato YYYY-MM-DD HH:MM:SS, senza indicatore di timezone. Quando il codice li rileggeva con new Date(), JavaScript li interpretava come ora locale. Per chi sta in UTC+2 (quasi tutti i miei utenti), i timestamp risultavano sfasati di due ore in avanti. Conseguenza: il throttle per-feed della funzione F5 di fatto non throttava, perché ogni controllo "credeva" che fossero passate più ore del dovuto e dava luce verde. Per i digest era peggio: partivano in anticipo rispetto all'intervallo configurato.
Il fix è doppio. Le nuove scritture usano un ISO-UTC esplicito (strftime('%Y-%m-%dT%H:%M:%SZ','now')), così non c'è ambiguità. Le letture passano per un helper parseUtcTimestamp() che riconosce sia il formato nuovo sia il vecchio formato-spazio, trattandoli entrambi come UTC. È una migrazione self-healing: nessuna ALTER TABLE, nessuna riga da riscrivere. Le righe già nel DB iniziano semplicemente a essere lette nel modo giusto.
Reset Innertube su ogni errore: il déjà-vu della v2.0.3
Avevo sistemato la mitigazione antibot. E avevo lasciato in piedi, nel catch di fetchYouTubeVideos(), una resetYouTubeSession() chiamata a ogni errore. Significa che ogni errore transitorio (un timeout di tre secondi, un 5xx, una cache miss) costringeva il client a ricreare la sessione Innertube. Lo stesso handshake ravvicinato che la v2.0.3 era nata per evitare. La toppa metteva in scena un assolo del problema che doveva spegnere.
Adesso la sessione resta viva sugli errori transitori. Un contatore registra i fallimenti consecutivi e azzera l'istanza solo dopo cinque di fila, quando è plausibile che la sessione sia compromessa per davvero. Il contatore si rimette a zero alla prima risposta valida. Coerente con la filosofia di v2.0.3, finalmente in vigore ovunque.
History senza tetto
La tabella history cresceva senza limiti. Su un'installazione vissuta, con anni di feed e decine di bot, il file SQLite si sarebbe gonfiato senza nessun beneficio. Pulizia solo manuale, e nessuno la fa.
Ho aggiunto un pruning automatico all'avvio che, per ogni bot, mantiene le ventimila righe più recenti per sent_at. Ventimila è enorme rispetto a qualunque finestra di feed reale (poche centinaia di item visibili al massimo), quindi una riga eliminata è ben oltre il backlog vivo del feed e non può causare ri-pubblicazioni. Il pruning gira una volta all'avvio, non a ogni ciclo: non incide sul tempo di runtime.
Un handler IPC senza guardiano
get-feeds accettava un botId qualsiasi e lo passava direttamente a better-sqlite3. Quando il renderer mandava undefined (succedeva in un edge case all'avvio della Dashboard), better-sqlite3 lanciava un'eccezione poco gentile. Niente di fatale, ma un rumore di fondo evitabile.
Adesso get-feeds valida botId con il mio assertPositiveInt. Anche toggle-feed riceve una piccola gentilezza: l'argomento isActive viene forzato a booleano, perché preferisco non fidarmi di cosa arriva da un postMessage.
La fascia lieve: rifiniture
Quattro cosette, raccolte in v2.1.2 — "Polish". Niente di grosso, ma quel tipo di dettaglio che, se non lo aggiusti, ti fa apparire poco rifinito senza un motivo serio.
getStats e getDetailedStats confrontavano sent_at con date('now') in UTC. Significa che il contatore "oggi" si azzerava alle 02:00 italiane, in pieno cuore della notte, lasciando l'utente la mattina dopo con statistiche che dicevano "ieri" per cose appena pubblicate. Le query passano ora per localtime, e la mezzanotte è di nuovo la mezzanotte di chi sta guardando lo schermo.
addLocalLog generava gli ID dei log locali con Date.now(). Due log emessi nello stesso millisecondo finivano con la stessa key React. React si lamentava nel devtools. Ho rimpiazzato Date.now() con un contatore monotono negativo: non collide con sé stesso e non collide con gli ID del backend, che sono positivi crescenti.
La Dashboard, al mount, inizializzava isRunning = false senza interrogare il main process. Il motore vive nel main e sopravvive a un reload del renderer; in pratica significa che dopo un Ctrl+R l'utente vedeva «offline» mentre il bot stava davvero girando. Ho aggiunto un IPC get-bot-status chiamato al mount, e adesso la UI riflette lo stato vero del motore senza menzogne di cortesia.
Quarta e ultima: il campo homepage di package.json puntava a runtimeradio/titan-desktop, un repository che non esiste. Lo trovavi solo se ci cliccavi sopra, e dava 404. Ora punta al repo vero, su Ecosystem-Runtime.
Cinque versioni, una sera
Cinque versioni in quattordici giorni sembrano molte, e in un certo senso lo sono. Ma se levo la v2.0.3, che ha una storia tutta sua e nasce da un avviso che YouTube ha mandato al mondo a metà maggio, le altre quattro stanno tutte dentro la stessa sera. Quattro release, una sola sessione di pensiero, una sola idea: leggere il codice come se l'avesse scritto qualcun altro e segnare tutto quello che ti fa storcere il naso.
Quello che esce, leggendo così, non sono i bug clamorosi. Quelli si fanno trovare da soli, con un crash o un loop o una notifica impazzita. Quello che esce sono le cose che funzionavano quasi: il timer che restava un'ora indietro ma non abbastanza da farti notare; il guard che proteggeva l'app ma in realtà la spegneva; il check che bloccava i duplicati ma anche qualche omonimo onesto; il messaggio "Unauthorized" che diceva tutto e non diceva niente. Era tutto stabile, in superficie. Sotto, c'erano almeno otto modi diversi di fare il lavoro sbagliato senza farsene accorgere.
Un audit serve a questo. Non a riparare i guasti, ma a sentire dove il software sta lavorando di traverso.
Restano due cose aperte. Una è la cache persistente @handle → UC... per YouTube, che eviterebbe del tutto il fallback yt.search() su cui le issue upstream stanno avvertendo da mesi; entrerà in una release successiva, quando avrò tempo di pensarla bene. L'altra è la perdita di contenuto dopo MAX_RETRIES, ed è una scelta deliberata: preferisco un articolo perso che un loop infinito su un feed rotto. Su questa non ho intenzione di tornare indietro.
Titan è alla v2.1.2. È più silenzioso di prima. E adesso, oltre ad aggiornarsi da solo, sta anche zitto quando deve.
Lo trovi su Ecosystem.Runtime a 9,99€ una tantum. Windows, macOS e Linux.
