From 582d68580b0f3e553d5f6ddae4f03d5e75c56360 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 14 Aug 2026 13:06:53 +0200 Subject: [PATCH] Drei Befunde aus der Durchsicht: /health, Herunterfahren, ephemeral 1. /health konnte nicht fehlschlagen app.get('/health', async () => ({ status: 'ok' })); Ein fester Wert, ohne irgendetwas anzusehen. Das meldet "sauber", solange der HTTP-Faden laeuft -- und der laeuft auch dann noch, wenn die Verbindung zu Discord weg ist. Ein Bot ohne Gateway ist fuer alle draussen genauso weg wie ein abgestuerzter, nur merkt es niemand. NPM fragt die Adresse an, zwei Hosts, alle paar Minuten; sie hat immer 200 gesagt. Das Bittere: die Auskunft lag laengst vor. heartbeat.js schreibt alle zwei Minuten client.isReady() mit (gemessen an der laufenden Datenbank: 9557 Zeilen, letzter Schlag ok=1, 104 ms). Der Herzschlag wusste es, /health hat nicht gefragt. Jetzt geprueft: steht die Verbindung, und antwortet die Datenbank. Sonst 503 mit Grund -- 503 und nicht 500, das ist kein Programmfehler, sondern ein Zustand, der vorbeigeht. Der Grund steht dabei, sonst faengt das Raten wieder von vorn an. Dazu ein HEALTHCHECK im Dockerfile, denn sonst reagiert niemand darauf. Gemessen: d4rkbot hatte keinen (), d4rk-media hat einen und steht auf "healthy". curl und wget fehlen im slim-Abbild, also Node selbst; die Exitcodes sind gegengeprueft (erreichbar -> 0, tote Adresse -> 1). Fuer die Datenbankprobe eine echte Abfrage und nicht existsSync auf die Datei: die Datei ist auch dann noch da, wenn das Dateisystem nur noch lesbar ist. 2. Kein Handler fuers Beenden Kein SIGTERM, kein SIGINT im ganzen Quelltext. Bei `docker stop` wird der Prozess nach der Schonfrist abgeraeumt, und zwei Dinge geben danach falsche Auskunft: der Bot steht in Discord noch eine Weile als online, und die Statuszeile am Sprachkanal behauptet weiter, es liefe Musik -- die ueberlebt den Container, der sie geschrieben hat. Reihenfolge ist wichtig: erst das Radio, denn zum Abraeumen der Statuszeile braucht es die Verbindung zu Discord noch. Dann Webserver, dann Discord. Jeder Schritt einzeln abgesichert, sonst verhindert ein Fehler beim Aufraeumen das restliche Aufraeumen. Nach acht Sekunden bricht es selbst ab, weil Docker nach zehn schiesst. radio_state bleibt dabei absichtlich stehen -- der Merker ist genau dafuer da, dass der Bot nach einem Deploy von selbst zurueckkommt. 3. ephemeral: true Einzige Stelle im ganzen Bot, ueberall sonst steht flags: MessageFlags.Ephemeral. discord.js 14.27 wertet es noch aus (InteractionResponses.js:118), warnt aber, und in v15 faellt es weg -- dann waere ausgerechnet die Fehlermeldung ploetzlich oeffentlich. Was die Durchsicht sonst ergab, gehoert genauso hierher: 125 Routen durchgezaehlt, keine einzige aendernde ohne Wache. Keine Nutzereingabe in SQL. Der Mod-Download prueft gegen die Modliste statt gegen ein Namensmuster. Die Sicherung packt ihr Archiv wieder aus und laesst integrity_check laufen. Keine Geheimnisse im Log. Daran war nichts zu verbessern. Ungeprueft geblieben: 76 Stellen mit stillschweigend verschlucktem Fehler (catch {}), das Frontend, und ob NPM schon drosselt -- eine Ratenbegrenzung hat die API naemlich nicht. Entschaerft dadurch, dass alle aendernden Routen hinter einer Wache liegen. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 9 ++++++++ src/bot/client.js | 5 ++++- src/bot/radio.js | 22 +++++++++++++++++-- src/db.js | 11 ++++++++++ src/index.js | 56 ++++++++++++++++++++++++++++++++++++++++++++--- src/web/server.js | 31 ++++++++++++++++++++++++-- 6 files changed, 126 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index a63ebc9..e6109eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,4 +35,13 @@ RUN mkdir -p /app/data ENV NODE_ENV=production +# Ohne diese Zeile weiss Docker nur, DASS der Prozess laeuft — nicht, ob der +# Bot noch mit Discord verbunden ist. Genau das beantwortet /health. +# +# `curl` und `wget` fehlen im slim-Abbild (nachgemessen), also Node selbst. +# Die Schonfrist ist grosszuegig: der Anmeldevorgang bei Discord dauert, und +# ein Neustart soll nicht als Ausfall gelten. +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.HTTP_PORT||3080)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD ["node", "src/index.js"] diff --git a/src/bot/client.js b/src/bot/client.js index d72fe1d..b34ea00 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -396,7 +396,10 @@ export async function startBot() { await command.execute(interaction); } catch (error) { console.error(`[bot] Fehler bei /${interaction.commandName}:`, error); - const reply = { content: '❌ Da ist etwas schiefgelaufen.', ephemeral: true }; + // `ephemeral: true` stand hier als einzige Stelle im ganzen Bot; + // discord.js 14 wertet es noch aus, warnt aber, und in v15 fällt + // es weg. Dann wäre diese Meldung plötzlich öffentlich. + const reply = { content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }; if (interaction.replied || interaction.deferred) { await interaction.followUp(reply).catch(() => {}); } else { diff --git a/src/bot/radio.js b/src/bot/radio.js index 6072cb8..8c3d433 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -403,15 +403,33 @@ export function lautstaerkeSetzen(guildId, wunsch) { /** Alles abräumen — Verbindung, Spieler, ffmpeg */ export function stoppen(guildId) { const s = laufend.get(guildId); + let abgeraeumt; if (s) { s.player.stop(true); s.ffmpeg?.kill('SIGKILL'); laufend.delete(guildId); // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es - // liefe ein Lied, der längst vorbei ist. Leerer Text räumt sie ab. - kanalStatusSetzen(s.client, s.channelId, ''); + // liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab. + // + // Das Versprechen wird zurückgegeben, nicht verworfen: beim + // Herunterfahren muss jemand darauf warten können, sonst ist der + // Prozess weg, bevor die Anfrage draussen ist. + abgeraeumt = kanalStatusSetzen(s.client, s.channelId, ''); } getVoiceConnection(guildId)?.destroy(); + return abgeraeumt; +} + +/** + * Alles anhalten — fürs geordnete Herunterfahren. + * + * `radio_state` bleibt absichtlich stehen: der Merker ist genau dafür da, + * dass der Bot nach einem Deploy von selbst zurückkommt. Abgeräumt wird nur, + * was sonst falsche Auskunft gäbe — die Statuszeile am Sprachkanal. + */ +export async function radioBeenden() { + // Erst eine Kopie der Schlüssel: `stoppen` löscht aus derselben Karte. + await Promise.allSettled([...laufend.keys()].map((guildId) => stoppen(guildId))); } /** diff --git a/src/db.js b/src/db.js index ee090c5..8115b89 100644 --- a/src/db.js +++ b/src/db.js @@ -11,6 +11,17 @@ mkdirSync(dirname(dbFile), { recursive: true }); export const db = new Database(dbFile); db.pragma('journal_mode = WAL'); +/** + * Antwortet die Datenbank überhaupt? Für `/health`. + * + * Bewusst eine echte Abfrage und nicht `existsSync` auf die Datei: die Datei + * ist auch dann noch da, wenn das Dateisystem nur noch lesbar ist oder die + * Verbindung hinüber ist. Einmal vorbereitet, damit die Prüfung selbst nichts + * kostet. + */ +const pingStmt = db.prepare('SELECT 1 AS eins'); +export const pingDb = () => pingStmt.get()?.eins === 1; + db.exec(` CREATE TABLE IF NOT EXISTS commits ( sha TEXT PRIMARY KEY, diff --git a/src/index.js b/src/index.js index 37c9b7e..0ec5fa2 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ import { startWatchdog } from './bot/watchdog.js'; import { scheduleBackups } from './backup.js'; import { startServerMonitor } from './bot/server-monitor.js'; import { startLsFarm } from './bot/ls-farm.js'; -import { startRadio } from './bot/radio.js'; +import { startRadio, radioBeenden } from './bot/radio.js'; import { startGiveaways } from './bot/giveaways.js'; import { startScheduledPosts } from './web/scheduled-posts.js'; import { startSocialNotify } from './bot/social-notify.js'; @@ -17,9 +17,59 @@ process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); }); +let client = null; +let app = null; + +/** + * Geordnet herunterfahren. + * + * Ohne das räumt `docker stop` den Prozess nach der Schonfrist einfach ab. + * Zwei Dinge geben dann falsche Auskunft: der Bot steht in Discord noch eine + * Weile als online, und die Statuszeile am Sprachkanal behauptet weiter, es + * liefe Musik. Beides überlebt den Container, der es geschrieben hat. + * + * Reihenfolge ist wichtig: erst das Radio, denn zum Abräumen der Statuszeile + * braucht es die Verbindung zu Discord noch. + */ +let laeuftAus = false; +async function beenden(signal) { + if (laeuftAus) return; // zweites SIGTERM nicht doppelt abarbeiten + laeuftAus = true; + console.log(`[main] ${signal} — fahre herunter`); + + // Docker wartet vorgabemässig zehn Sekunden und schiesst dann. Lieber + // vorher selbst aufhören, als mitten im Aufräumen abgeschossen zu werden. + const frist = setTimeout(() => { + console.error('[main] Herunterfahren dauert zu lange — breche ab'); + process.exit(1); + }, 8000); + frist.unref?.(); + + for (const [was, tun] of [ + ['Radio', () => radioBeenden()], + ['Webserver', () => app?.close()], + ['Discord', () => client?.destroy()], + ]) { + try { + await tun(); + } catch (error) { + // Ein Fehler beim Aufräumen darf das restliche Aufräumen nicht + // verhindern — sonst bleibt wegen der Statuszeile der Bot online. + console.error(`[main] ${was} beim Herunterfahren:`, error.message); + } + } + + clearTimeout(frist); + console.log('[main] beendet'); + process.exit(0); +} + +process.on('SIGTERM', () => beenden('SIGTERM')); +process.on('SIGINT', () => beenden('SIGINT')); + try { - const client = await startBot(); - await startWebServer(client); + client = await startBot(); + app = await startWebServer(client); scheduleWeeklyRecap(client); startWatchdog(client); scheduleBackups(client); diff --git a/src/web/server.js b/src/web/server.js index 778c4c6..330c1f5 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { config } from '../config.js'; -import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage } from '../db.js'; +import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage, pingDb } from '../db.js'; import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName, hubUrl, botUrl } from '../runtime-settings.js'; import { postPushEmbed } from '../bot/commit-feed.js'; import { postReleaseEmbed } from '../bot/release-feed.js'; @@ -75,7 +75,34 @@ export async function startWebServer(client) { registerLinkedRoleRoutes(app, client); // Healthcheck (für Portainer/NPM) - app.get('/health', async () => ({ status: 'ok' })); + // + // Hier stand ein fester Wert: `{ status: 'ok' }`, immer, ohne etwas + // anzusehen. Das ist keine Prüfung — es meldet „sauber", solange der + // HTTP-Faden läuft, und der läuft auch dann noch, wenn die Verbindung zu + // Discord längst weg ist. Ein Bot ohne Gateway ist für alle draussen + // genauso weg wie ein abgestürzter, nur merkt es niemand. + // + // Geprüft wird deshalb, was von aussen zählt: steht die Verbindung, und + // antwortet die Datenbank. Beides weiss der Prozess ohnehin — der + // Herzschlag schreibt `isReady()` alle zwei Minuten mit. + app.get('/health', async (request, reply) => { + const gruende = []; + if (!client?.isReady?.()) gruende.push('keine Verbindung zu Discord'); + try { + if (!pingDb()) gruende.push('Datenbank antwortet unerwartet'); + } catch (error) { + gruende.push(`Datenbank antwortet nicht: ${error.message}`); + } + if (gruende.length > 0) { + // 503 und nicht 500: das ist kein Programmfehler, sondern ein + // Zustand, der vorbeigehen kann. Der Grund steht dabei, sonst + // fängt das Raten wieder von vorn an. + return reply.code(503).send({ status: 'gestoert', gruende }); + } + // -1 heisst „noch nicht gemessen"; das ist kein Fehler, nur unbekannt + const ping = client.ws?.ping; + return { status: 'ok', discord: { ping: ping >= 0 ? ping : null } }; + }); // Lokal gespeicherte Devlog-Bilder (Discord-CDN-Links laufen ab) await app.register(fastifyStatic, {