Ein Export läuft mehrere Minuten, und der Besucher schließt den Tab. Im Ausgabeordner bleibt eine halbe CSV-Datei liegen, die niemand aufräumt und die beim nächsten Lauf für Verwirrung sorgt. PHP kann diesen Abbruch bemerken, aber nur unter einer Bedingung.
Im Bild scheitert erst das Schreiben auf der unterbrochenen Leitung. Genau in diesem Moment merkt PHP, dass niemand mehr zuhört.
Was PHP connection_aborted() macht
PHP connection_aborted() gibt 1 zurück, wenn der Client die Verbindung abgebrochen hat, und sonst 0. Die Funktion nimmt keine Parameter und lässt sich an jeder Stelle im Skript aufrufen.
<?php
for ($i = 0; $i < 1000; $i++) {
/* lange Rechnung ohne Ausgabe */
rechneEtwas($i);
if (connection_aborted() === 1) {
break; /* wird nie erreicht */
}
}
Diese Schleife funktioniert nicht, und zwar unabhängig davon, wie oft der Besucher abbricht. Der Grund steckt im nächsten Abschnitt und ist der wichtigste Satz dieses Tutorials.
Warum die Prüfung oft nie anschlägt
PHP überwacht die Verbindung nicht aktiv. Es bemerkt den Abbruch erst, wenn ein Schreibversuch an den Client fehlschlägt. Eine Schleife, die nur rechnet und nichts ausgibt, erfährt vom Abbruch nichts und meldet bis zum Ende 0.
Wer die Erkennung will, muss also regelmäßig etwas ausgeben und den Ausgabepuffer leeren. Ein Leerzeichen oder ein HTML-Kommentar reicht völlig.
<?php
for ($i = 0; $i < 1000; $i++) {
rechneEtwas($i);
echo ' '; /* ein Byte genuegt */
if (ob_get_level() > 0) {
ob_flush(); /* PHP-Puffer leeren */
}
flush(); /* an den Webserver weiterreichen */
if (connection_aborted() === 1) {
break; /* jetzt greift die Pruefung */
}
}
Beide Aufrufe gehören zusammen. ob_flush() leert den Puffer von PHP, flush() schiebt die Daten an den Webserver weiter. Fehlt einer davon, bleibt alles hängen und PHP connection_aborted() liefert weiterhin 0.
Die drei Verbindungszustände
Neben dem Abbruch durch den Besucher gibt es einen zweiten Grund, aus dem ein Skript endet: das Zeitlimit. Beide Zustände liefert connection_status() als Bitmaske.
| Konstante | Wert | Auslöser |
CONNECTION_NORMAL | 0 | alles in Ordnung, das Skript läuft normal |
CONNECTION_ABORTED | 1 | der Client hat die Verbindung geschlossen |
CONNECTION_TIMEOUT | 2 | die Laufzeit aus max_execution_time ist erreicht |
Das Zeitlimit liegt in der Vorgabe bei 30 Sekunden, auf der Kommandozeile bei 0 und damit unbegrenzt. Weil connection_status() eine Bitmaske liefert, können theoretisch beide Bits gesetzt sein; geprüft wird deshalb mit dem bitweisen Und.
<?php
$status = connection_status();
if (($status & CONNECTION_ABORTED) !== 0) {
error_log('Besucher hat abgebrochen');
}
if (($status & CONNECTION_TIMEOUT) !== 0) {
error_log('Zeitlimit erreicht');
}
ignore_user_abort(): der Schalter, ohne den nichts geht
In der Vorgabe beendet PHP das Skript sofort, sobald der Abbruch bemerkt wird. Eigener Aufräumcode kommt dann nie zur Ausführung, und PHP connection_aborted() ist nützlich wie ein Rauchmelder ohne Batterie. Der Schalter ignore_user_abort(true) ändert das: Das Skript läuft weiter, und die Abfrage wird auswertbar.
<?php
ignore_user_abort(true);
set_time_limit(300); /* sonst greift nach 30 Sekunden das Zeitlimit */
echo str_repeat(' ', 1024); /* Puffer des Browsers anschieben */
flush();
Ein Wort der Vorsicht dazu: Ein Skript, das niemand mehr abbrechen kann, läuft bis zum Zeitlimit durch. Bei einer Endlosschleife ohne Zeitlimit läuft es dauerhaft. Der Schalter gehört deshalb immer zusammen mit einer klaren Abbruchbedingung gesetzt.
Wann der Abbruch überhaupt bemerkt wird, zeigt der folgende Ablauf.
flowchart TD
A[Skript laeuft] --> B{Gibt es aus}
B -->|nein| C[Abbruch bleibt unbemerkt]
B -->|ja| D[Schreibversuch]
D --> E{Client noch da}
E -->|ja| F[weiter]
E -->|nein| G{ignore_user_abort}
G -->|nein| H[Skript endet sofort]
G -->|ja| I[connection_aborted gibt 1]
Die beiden Weichen erklären zusammen, warum dieselbe Zeile Code in einem Skript funktioniert und im nächsten nicht.
Praxis: einen halbfertigen Export aufräumen
Damit ist der Fall aus der Einleitung lösbar, und PHP connection_aborted() bekommt endlich einen Zweck. Die Datei wird beim Abbruch entfernt, und zwar auch dann, wenn zwischendurch eine Ausnahme auftritt.
<?php
ignore_user_abort(true);
set_time_limit(600);
$ziel = '/var/www/export/bericht-' . date('YmdHis') . '.csv';
$fertig = false;
$h = fopen($ziel, 'wb');
try {
foreach (datensaetze() as $nr => $satz) {
fputcsv($h, $satz);
if ($nr % 100 === 0) {
echo ' ';
flush();
if (connection_aborted() === 1) {
throw new RuntimeException('Abbruch durch Besucher');
}
}
}
$fertig = true;
} finally {
fclose($h);
if (!$fertig) {
unlink($ziel); /* halbe Datei entfernen */
}
}
Die Prüfung alle hundert Datensätze ist ein guter Mittelweg. Häufiger kostet unnötig Ausgabe, seltener verzögert die Erkennung. Der finally-Block räumt in jedem Fall auf, auch bei einem Datenbankfehler.
Praxis: nur vollständige Downloads zählen
Eine Downloadstatistik, die jeden angefangenen Abruf zählt, ist wertlos. Mit PHP connection_aborted() lässt sich das leicht korrigieren. Wird blockweise ausgeliefert, lässt sich der Zähler an das Ende des letzten Blocks hängen.
<?php
ignore_user_abort(true);
$datei = '/var/www/geschuetzt/handbuch.pdf';
$h = fopen($datei, 'rb');
header('Content-Type: application/pdf');
header('Content-Length: ' . filesize($datei));
$abgebrochen = false;
while (!feof($h)) {
echo fread($h, 8192);
flush();
if (connection_aborted() === 1) {
$abgebrochen = true;
break;
}
}
fclose($h);
if (!$abgebrochen) {
downloadZaehlen($datei); /* erst jetzt buchen */
}
Bei der Auslieferung selbst helfen die Wege aus dem Tutorial zu PHP readfile() und dem direkten Ausliefern von Dateien. Die passenden Header behandelt das Tutorial zu HTTP-Headern mit PHP.
Praxis: eine Endlosschleife bei Server-Sent Events beenden
Bei einer dauerhaften Verbindung ist die Abfrage von PHP connection_aborted() die einzige Abbruchbedingung, die zuverlässig greift. Ohne sie laufen Prozesse weiter, obwohl kein Browser mehr zuhört, und belegen dauerhaft Speicher.
<?php
ignore_user_abort(true);
set_time_limit(0);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (true) {
echo 'data: ' . json_encode(['zeit' => date('H:i:s')]) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
if (connection_aborted() === 1) {
error_log('SSE-Verbindung beendet');
break;
}
sleep(2);
}
Der doppelte Zeilenumbruch am Ende jeder Nachricht gehört zum Format der Server-Sent Events und ist gleichzeitig die Ausgabe, die den Abbruch erkennbar macht.
register_shutdown_function() als sicherer Ort
Protokoll und Aufräumarbeit lassen sich an einer Stelle bündeln, die auch dann noch läuft, wenn das Skript unerwartet endet. Bei aktivem ignore_user_abort() greift sie ebenfalls nach einem Abbruch.
<?php
ignore_user_abort(true);
register_shutdown_function(static function (): void {
$status = connection_status();
$grund = match (true) {
($status & CONNECTION_ABORTED) !== 0 => 'abgebrochen',
($status & CONNECTION_TIMEOUT) !== 0 => 'zeitlimit',
default => 'normal',
};
error_log('Export beendet: ' . $grund);
});
Derselbe Schalter erlaubt noch ein zweites Muster: die Antwort früh abschicken und danach in Ruhe weiterarbeiten. Das ist auf Hosting-Paketen ohne Shell-Zugang oft der einzige Weg zu einer Hintergrundverarbeitung.
<?php
ignore_user_abort(true);
set_time_limit(300);
$antwort = json_encode(['status' => 'angenommen']);
header('Content-Type: application/json');
header('Content-Length: ' . strlen($antwort));
header('Connection: close');
echo $antwort;
if (ob_get_level() > 0) {
ob_end_flush();
}
flush();
/* der Browser ist fertig, das Skript nicht */
langeVerarbeitung();
Entscheidend ist die korrekte Content-Length: Erst dadurch weiß der Browser, dass die Antwort vollständig ist, und beendet die Verbindung. Unter nginx mit Pufferung greift auch dieses Muster nicht zuverlässig, dort bleibt nur ein echter Warteschlangendienst.
Damit steht in jedem Fall eine Zeile im Protokoll, und zwar mit dem Grund. Wer stattdessen nur am Ende des Skripts protokolliert, verliert genau die interessanten Fälle. Zum gezielten Beenden eines Skripts passt ergänzend das Tutorial zu exit() und dem sicheren Beenden von Skripten.
Wo die Pufferung dazwischenfunkt
Unter nginx mit PHP-FPM puffert der Webserver die Antwort, bevor er sie an den Browser schickt. PHP schreibt dann erfolgreich in diesen Puffer, obwohl der Client längst weg ist, und der Abbruch fällt später oder gar nicht auf. Das ist keine PHP-Einstellung und lässt sich mit keiner Zeile Code beheben; zuständig ist die Serverkonfiguration, etwa fastcgi_buffering.
Auf der Kommandozeile gibt es überhaupt keine Verbindung, die abbrechen könnte. Dort liefert PHP connection_aborted() immer 0. Zum Testen eignet sich deshalb nur der Browser: ein Skript mit Ausgabe und sleep() aufrufen, den Tab schließen und anschließend ins Protokoll sehen.
Fazit
PHP connection_aborted() ist einfach zu benutzen und leicht misszuverstehen. Die Funktion meldet nur dann einen Abbruch, wenn das Skript vorher etwas ausgegeben und mit flush() weitergereicht hat. Ohne Ausgabe bleibt der Rückgabewert 0, egal was der Besucher tut.
Dazu gehört ignore_user_abort(true), sonst endet das Skript, bevor die Abfrage überhaupt erreicht wird. Mit diesen beiden Bausteinen lassen sich halbfertige Dateien aufräumen, Downloadzähler ehrlich halten und dauerhafte Verbindungen sauber beenden.