mitmario.dev

Wenn niemand zuhört

Node.js Sandbox 3 Min Lesezeit 3 BeispieleLektion 4 von 7

In Lektion 7.3 gab es eine Sonderregel: Ein error-Ereignis, dem niemand zuhört, beendet den Prozess. Für abgelehnte Promises gilt seit Node 15 dasselbe, und aus demselben Grund.

Was passiert, wenn niemand zuhört

Ein Promise, dem niemand zuhört
async function lade() {
  throw new Error("Datenbank weg");
}

process.on("exit", (code) => {
  console.log(`Der Prozess endet mit Code ${code}`);
});

console.log("vorher");

// Kein await, kein .catch(): das abgelehnte Promise findet niemanden.
lade();

setTimeout(() => console.log("diese Zeile kommt nicht mehr"), 100);

lade() wird aufgerufen, ohne await und ohne .catch(). Das Promise lehnt ab, Node schaut nach, ob jemand zuständig ist, findet niemanden und beendet den Prozess mit Code 1. Der Zeitgeber, der hundert Millisekunden später etwas ausgeben wollte, kommt nicht mehr dran.

Früher gab es dafür nur eine Warnung, und das Programm lief weiter. Diese Voreinstellung hat sehr viele Serverprozesse hervorgebracht, die stundenlang liefen und dabei niemandem mehr antworteten. Node hat sie aus gutem Grund gedreht.

Warum ein Abbruch die richtige Antwort ist: Ein abgelehntes Promise, das niemand behandelt, ist per Definition ein Fehler, für den es keinen Plan gab. Alles, was danach passiert, beruht auf einer Annahme, die gerade widerlegt wurde. Von zwei schlechten Möglichkeiten ist „sofort aufhören” die bessere.

Der Handler, der alles schlimmer macht

Es gibt zwei Ereignisse dafür: unhandledRejection für abgelehnte Promises und uncaughtException für geworfene Fehler, die niemand gefangen hat. Beide werden regelmäßig falsch benutzt, nämlich als Auffangnetz, mit dem man weitermacht.

Der Handler, der weitermacht
// So nicht. Der Prozess laeuft in einem Zustand weiter, den niemand vorgesehen hat.
let verbindung = { offen: true };

process.on("uncaughtException", (fehler) => {
  console.log(`aufgefangen: ${fehler.message}, und weiter geht es`);
});

process.on("exit", (code) => console.log(`Der Prozess endet mit Code ${code}`));

setTimeout(() => {
  verbindung = null;
  throw new Error("Verbindung verloren");
}, 10);

setTimeout(() => {
  console.log(`Zustand danach: verbindung ist ${verbindung}`);
  console.log(`Naechster Zugriff: ${verbindung.offen}`);
}, 50);

Der Prozess überlebt, und genau das ist das Problem. Die Verbindung ist weg, der nächste Zugriff darauf scheitert an null, und dieser Folgefehler wird wieder aufgefangen. Am Ende steht Code 0: Von außen sieht es aus, als sei alles gut gegangen. Wer nur den Exit-Code überwacht, merkt nie etwas.

Nach einem uncaughtException weiß Node nicht mehr, in welchem Zustand deine Anwendung ist. Vielleicht mitten in einer Transaktion. Vielleicht mit einer halb geschriebenen Datei. Vielleicht mit einer Antwort, von der schon die Hälfte beim Aufrufer ist. Ein Programm in diesem Zustand weiterlaufen zu lassen ist gefährlicher, als es sterben zu lassen: Es macht aus einem Fehler stille Falschdaten.

Wofür die Handler wirklich da sind

Nicht um weiterzumachen, sondern um sauber aufzuhören. Das ist der ganze Zweck.

Sauber aufhören
import { createServer, request } from "node:http";

const server = createServer(async (req, res) => {
  await new Promise((fertig) => setTimeout(fertig, 200));
  console.log("Anfrage fertig");
  res.end("ok\n");
});

function herunterfahren(grund, code) {
  console.log(`fahre herunter: ${grund}`);

  server.close(() => {
    console.log("alle Anfragen fertig, Ende");
    process.exit(code);
  });

  // Falls close nie zurueckkommt: nach fuenf Sekunden ist trotzdem Schluss.
  setTimeout(() => process.exit(code), 5000).unref();
}

process.on("unhandledRejection", (grund) => herunterfahren(`unbehandelt: ${grund.message}`, 1));
process.on("uncaughtException", (fehler) => herunterfahren(`abgestuerzt: ${fehler.message}`, 1));
process.on("SIGTERM", () => herunterfahren("SIGTERM", 0));

process.on("exit", (code) => console.log(`Der Prozess endet mit Code ${code}`));

server.listen(3000, () => {
  request({ port: 3000, path: "/bericht", agent: false }, (antwort) => antwort.resume()).end();
  setTimeout(() => Promise.reject(new Error("Datenbank weg")), 50);
});

Vier Schritte, immer in dieser Reihenfolge:

  1. Protokollieren, denn das ist die einzige Spur, die von diesem Vorfall bleibt.
  2. Keine neuen Anfragen mehr annehmen. server.close() macht genau das: Es schließt den Lauschposten und lässt die schon laufenden Anfragen zu Ende laufen.
  3. Warten, bis die laufenden fertig sind. Der Rückruf von server.close() kommt genau dann. Im Beispiel siehst du das an der Reihenfolge: Erst kommt „fahre herunter”, danach „Anfrage fertig”, und erst dann ist Schluss. Der Aufrufer, der schon wartete, bekommt seine Antwort.
  4. Beenden, mit einem Code, der die Wahrheit sagt. Nach einem Fehler ist das 1.

Der Zeitgeber mit unref() ist die Versicherung dagegen, dass Schritt 3 nie fertig wird. Hängt eine Anfrage, kommt der Rückruf nicht, und ohne diese fünf Sekunden bliebe der Prozess für immer im Herunterfahren stecken. unref() sorgt dafür, dass dieser Zeitgeber den Prozess nicht künstlich am Leben hält, wenn alles gut geht.

SIGTERM ist dieselbe Mechanik von der anderen Seite

Im Beispiel hängt am selben herunterfahren noch ein dritter Zuhörer, und der ist der Normalfall.

SIGTERM ist das Signal, mit dem eine Umgebung höflich um das Ende bittet: bei einem Neustart, bei einem Deployment, beim Herunterskalieren. Kommt keine Antwort, folgt nach einer Frist SIGKILL, und das ist der Stecker.

Der Unterschied zum Fehlerfall ist nur der Exit-Code: Bei SIGTERM ist 0 richtig, denn niemand ist abgestürzt. Die vier Schritte sind dieselben, und deshalb steht dort dieselbe Funktion. Ein Server, der SIGTERM ignoriert, verliert bei jedem Deployment die Anfragen, die gerade unterwegs waren.

Wer den Prozess wieder startet

Bleibt eine Frage: Wenn dein Server sich bei einem unerwarteten Fehler beendet, wer startet ihn wieder?

Nicht dein Programm. Das ist die Aufgabe der Umgebung: ein Prozessmanager, ein Container-Dienst, eine Plattform. Sie merkt, dass der Prozess weg ist, startet einen neuen, und der beginnt in einem sauberen Zustand statt in einem kaputten. Genau deshalb darf dein Programm sterben. Lektion 18.4 kommt darauf zurück.

Zum Mitnehmen

Ein abgelehntes Promise ohne catch beendet den Prozess, und das ist die richtige Voreinstellung. Ein Handler dafür ist nicht dazu da, weiterzumachen, sondern dazu, sauber aufzuhören.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.