Drei Befunde aus der Durchsicht: /health, Herunterfahren, ephemeral
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

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 (<nil>), 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:06:53 +02:00
co-authored by Claude Opus 5
parent 1154e7a6a5
commit 582d68580b
6 changed files with 126 additions and 8 deletions
+9
View File
@@ -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"]
+4 -1
View File
@@ -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 {
+20 -2
View File
@@ -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)));
}
/**
+11
View File
@@ -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,
+53 -3
View File
@@ -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);
+29 -2
View File
@@ -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, {