piplet: Wenn Programm und Daten in derselben Datei wohnen

WordPress' piplet legt seine Notizen hinter __halt_compiler() in die eigene PHP-Datei. Was der Quelltext über atomares Schreiben, Inode-Races, Validierung vor dem Parsen und optimistische Nebenläufigkeit lehrt — und was davon in eigene Projekte passt.

von ·

Heute bin ich über ein Repository gestolpert, das ich seitdem nicht mehr aus dem Kopf bekomme: WordPress/piplets (öffnet in neuem Tab). Eine einzige PHP-Datei, 175.162 Bytes groß. Darin steckt ein komplettes Wiki: PHP-Backend, HTTP-API, HTML, CSS, JavaScript — und die Notizen. Nicht in einer Datenbank daneben, nicht in einem data/-Verzeichnis. In derselben Datei, direkt hinter dem Programmcode.

Mich interessiert an so etwas selten die Frage „soll ich das einsetzen". Mich interessiert: Wie ist es gebaut? Welche Probleme entstehen, wenn du diese Grenze einreißt? Und welche Antworten darauf sind so gut, dass ich sie in Projekten wiederverwenden kann, die mit selbstmodifizierendem PHP nichts zu tun haben?

Dieser Artikel ist die Antwort auf diese drei Fragen. Er ist keine Rezension und keine Empfehlung. Er nimmt den Quelltext auseinander, ordnet ihn in eine Traditionslinie ein, die bis zu Perl reicht, und destilliert am Ende heraus, was davon übertragbar ist. Wo ich Zahlen nenne, habe ich sie am laufenden System nachgemessen; wo ich etwas nicht belegen konnte, sage ich das.

Der Trick, in vier Zeilen

Das Repository enthält zwei Programme. Das kleine heißt wiki-piplet-unsafe.php, ist 78 Zeilen lang und existiert ausschließlich, um den Kern zu zeigen. Die entscheidenden Zeilen sind Nummer 3 und 4:

$raw = file_get_contents(__FILE__);
$pages = json_decode(substr($raw, __COMPILER_HALT_OFFSET__), true);

Das Programm liest sich selbst. Und am Ende derselben Datei steht:

<?php __halt_compiler();
{"welcome":{"title":"Hello, piplet","body":"This is a deliberately tiny, unsafe piplet."}}

__halt_compiler() ist keine gewöhnliche Funktion, sondern eine Regel in PHPs Grammatik. Sie beendet nicht die Ausführung, sondern die lexikalische Analyse: Der Compiler hört an dieser Stelle auf, die Datei zu lesen. Alles danach wird nie zu Tokens, nie zu Opcodes, nie zu irgendetwas Ausführbarem. Es sind einfach Bytes am Ende einer Datei. Das PHP-Handbuch beschreibt den Zweck selbst so:

„Beendet die Kompilerausführung. Dies kann sinnvoll sein, wenn Daten in PHP-Skripte eingebettet sind, wie z. B. in Installationsdateien."

Damit du an diese Bytes herankommst, definiert PHP beim Kompilieren eine Konstante: __COMPILER_HALT_OFFSET__. Sie enthält die Byte-Position, an der die Daten beginnen. Beides gibt es seit PHP 5.1.0, also seit 2005.

Ein Detail, das ich nachgemessen habe, weil es in Erklärungen im Netz regelmäßig falsch steht: Der Offset zeigt auf das Byte direkt hinter dem Semikolon. Der Zeilenumbruch danach wird nicht übersprungen, er gehört bereits zu den Daten. Wer beim Serialisieren annimmt, PHP schlucke „den einen Umbruch", verschiebt sich um genau ein Byte — und schreibt beim nächsten Speichern Müll.

Das Zurückschreiben ist in der kleinen Fassung eine einzige Zeile:

file_put_contents(__FILE__, substr($raw, 0, __COMPILER_HALT_OFFSET__) . "\n$json\n", LOCK_EX);

Der Programmteil wird aus dem beim Request-Start gelesenen Puffer genommen, byteidentisch vorangestellt, und dahinter kommt frisches JSON. Ich habe drei Speichervorgänge hintereinander laufen lassen: Die Dateigröße bleibt über alle drei stabil — in meinem Test 7.066 Bytes, wobei die Zahl am gespeicherten Text hängt —, mit genau einem \n zwischen __halt_compiler(); und {. Der ausführbare Teil reproduziert sich byteidentisch.

Das ist alles. Kein Datenbank-Handle, keine Migration, kein Schema. Und in dieser Nacktheit siehst du sofort, warum die Datei „unsafe" heißt — dazu später mehr.

Zwischenfazit: Der Kern von piplet ist ein 20 Jahre alter PHP-Mechanismus, den fast jeder Entwickler schon einmal benutzt hat, ohne es zu merken. Neu ist nicht der Mechanismus. Neu ist die Richtung, in die er benutzt wird.

Woher dieses Muster kommt — und wo es überall endet

Denn „Daten hinter dem Code" ist eine der ältesten Ideen im Software-Handwerk. Ich habe mir angesehen, wie andere Sprachen es lösen, und dabei ist mir ein Muster aufgefallen, das ich vorher so nicht auf dem Schirm hatte.

Sprache Idiomatisches Gegenstück Seit Schreibbar zur Laufzeit?
Perl __END__ / __DATA__ + DATA-Filehandle __END__ seit Perl 4, __DATA__ seit Perl 5 nein, reines Lese-Handle
PHP __halt_compiler(), darauf aufbauend phar 5.1.0 / 5.3 phar per Default gesperrt
Python kein Sprachfeature; zipapp (Shebang + ZIP) 3.5 ja, siehe unten
Go //go:embed 1.16 (2021) nein, ausdrücklich read-only
Java JAR = ZIP; Daten davor unbegrenzt, dahinter nur bis ~64 KiB praktisch nein
Shell Marker + tail; makeself: Byte-Offset + dd 1998 nein

Perls Dokumentation (öffnet in neuem Tab) ist da unmissverständlich: „The filehandle is left open pointing to the line after __DATA__." Ein Lese-Handle. Ein dokumentierter Rückkanal existiert nicht. Gos embed-Paket (öffnet in neuem Tab) sagt es noch deutlicher: „An FS is a read-only value" — und es wirkt ohnehin zur Compile-Zeit, kann also gar nicht zurückschreiben. Und makeself, das Werkzeug hinter unzähligen selbstentpackenden Installern, berechnet zwar einen Byte-Offset in die eigene Datei, liest ihn aber nur mit dd aus.

Zwei Ausnahmen habe ich beim Gegenprüfen gefunden, und beide sind ausdrücklich dokumentiert. Python kann zurückschreiben: zipfile.ZipFile(sys.argv[0], 'a') hängt an ein bestehendes Archiv an, und das Handbuch nennt genau diesen Fall — „This is meant for adding a ZIP archive to another file (such as python.exe)." Der Shebang-Präfix bleibt dabei byteidentisch, die .pyz lauffähig. Und SQLite bringt dafür ein eigenes VFS mit: sqlite3 --append legt eine vollwertige, schreibbare Datenbank hinter eine beliebige Datei, laut Quelltextkommentar „such as an executable". Was ich nirgends gefunden habe, ist eine Sprache, die den Rückkanal auf ihren eigenen __DATA__-Bereich vorsieht.

Am interessantesten ist PHPs eigener Nachfahre. phar-Archive benutzen exakt denselben Trennmarker. Der kleinstmögliche phar-Stub (öffnet in neuem Tab) ist laut Handbuch:

<?php __HALT_COMPILER();

PHP kennt „Programm plus angehängte Daten" also seit 2005 als offizielles Paketformat. Und es hat den Schreibkanal darauf standardmäßig verriegelt, mit einer Begründung, die ich in der Form selten in einer Sprachdokumentation gesehen habe:

„This option disables creation or modification of Phar archives using the phar stream or Phar object's write support. This setting should always be enabled on production machines, as the phar extension's convenient write support could allow straightforward creation of a php-based virus when coupled with other common security vulnerabilities."

phar.readonly (öffnet in neuem Tab) steht per Default auf 1 und lässt sich aus einem Skript heraus nicht abschalten, nur in der php.ini. piplet umgeht diese Sperre nicht, es kommt gar nicht erst mit ihr in Berührung: Es schreibt nicht über den phar://-Stream, sondern mit gewöhnlichen Dateifunktionen.

Die Wiki-Linie: TiddlyWiki und das Verbot des Browsers

Es gibt eine zweite Traditionslinie, und die ist noch älter. TiddlyWiki, erstmals im September 2004 erschienen, ist die Urform des selbstspeichernden Einzeldatei-Wikis. Wikipedia nennt es „an unusual example of a practical quine" — die Fähigkeit, eine Kopie des eigenen Quelltextes zu erzeugen, liegt im Herzen seiner Speicherfunktion.

Nur: TiddlyWiki kann sich nicht selbst schreiben. Der Browser verbietet das. Die gut 21-jährige Geschichte des Projekts ist eine Kette von Umwegen um genau dieses Verbot: In der Classic-Ära waren es ActiveX-FileSystemObject im Internet Explorer, ein Java-Applet und Mozillas UniversalXPConnect — Letzteres hat Firefox später entfernt. Im Kern von TiddlyWiki 5 liegen heute 15 Saver-Module, darunter TiddlyFox (das mit Firefox 57 im Jahr 2017 aufhörte zu funktionieren), HTTP PUT, GitHub/GitLab/Gitea-Uploads und Saver für die Wrapper-Apps AndTidWiki und TWEdit. Feather Wiki, der moderne Verwandte mit rund 57 kB, macht es genauso: Download oder ein HTTP PUT an einen Server, den du selbst stellen musst — der Speichern-Knopf erscheint nur, wenn die Gegenstelle einen dav-Header liefert.

Das ist die eigentliche Pointe an piplets Bauform: Es löst nicht dasselbe Problem wie TiddlyWiki. Es läuft serverseitig, und dort darf ein Prozess seine eigene Datei ersetzen. Die Browser-Sandbox als Gegner fällt weg — dafür erbt es einen anderen: Härtungsrichtlinien.

Und die PHP-Einzeldatei-Apps?

Hier wollte ich es genau wissen, weil der Vergleich „das ist doch wie Adminer" naheliegt. Ich habe die Quelltexte heruntergeladen statt Projektbeschreibungen zu glauben.

Projekt Einzeldatei? Schreibt in die eigene Datei? Wohin gehen die Daten?
Adminer 6.0.1 ja, 519.492 B nein in die verwaltete Datenbank
tinyfilemanager 2.6 ja ja — nur Konfiguration eigene Datei oder config.php
WonderCMS 3.6.0 fast ja — nur Selbst-Update database.js
Bludit 3.22 nein nein Metadaten in .php-Dateien mit JSON-Rumpf, Seitentext in index.txt
Pico 2.1.4 nein nein — kein einziger Schreibaufruf content/*.md
antonmedv/wiki ja nein db/wiki.db (SQLite)

Adminer ist der wichtigste Negativbefund. In der ausgelieferten Datei kommt file_put_contents null Mal vor, es gibt keinen Write auf __FILE__ und kein __halt_compiler. Adminer ist eine Einzeldatei-Auslieferung, kein selbstspeicherndes Programm.

Am nächsten kommt tinyfilemanager: Zeile 3 der ausgelieferten Datei ist ein $CONFIG = '{"lang":"en",…}';, und eine Methode schreibt genau diese Zeile in __FILE__ zurück, sobald du Sprache oder Theme umstellst. Aber es sind Einstellungen, keine Inhalte — und sobald eine config.php daneben liegt, weicht es dorthin aus. WonderCMS überschreibt seine index.php ebenfalls, allerdings mit neuem Code beim Selbst-Update; die Daten gehen nach database.js.

Der interessanteste Grenzfall ist Bludit: Seine Datenbankdateien sind PHP-Dateien. Sie beginnen mit <?php defined('BLUDIT') or die('Bludit CMS.'); ?>, darunter steht JSON. Aber der Prolog ist ein Direktzugriffsschutz, kein Programm — und die Programmdatei selbst bleibt unangetastet.

Zwischenfazit: „Daten hinter dem Code" ist fast überall ein Lese-Muster — für Installer, Archive, eingebettete Assets. Der Rückkanal ist selten und in keiner Sprache ein Sprachfeature; wo es ihn gibt, ist er ein Bibliotheks- oder Werkzeugweg wie bei Pythons zipfile und SQLites appendvfs. Und von den sechs produktiv genutzten Einzeldatei- und Flat-File-PHP-Projekten in der Tabelle schreibt keines seine Nutzdaten in die eigene Programmdatei. piplet steht am Ende dieser Linie, nicht in ihrer Mitte. Ich habe kein älteres, benanntes PHP-Projekt gefunden, das Inhalte hinter __halt_compiler() in der eigenen Datei ablegt — was nicht heißt, dass es keines gibt, sondern nur, dass ich keines finden konnte.

Die große Fassung: 3.584 Zeilen

Neben der 78-Zeilen-Demo liegt wiki-piplet.php. Dieselbe Idee, aber ausgebaut:

wiki-piplet-unsafe.php wiki-piplet.php
Zeilen 78 (davon 29 PHP) 3.584
Größe 7.209 B 175.162 B
Aufteilung fast alles Markup PHP 1.541, JS 1.597, CSS 362, HTML 80 Zeilen (Rest: Datentrailer)
Schutz keiner Basic-Auth (Pflicht), CSRF, CSP mit Nonce, Fetch Metadata
Schreibweg file_put_contents(__FILE__) Temp-Datei + fsync + rename
Tests 64 Assertions 433 Assertions

Der Aufbau der großen Datei sieht so aus:

wiki-piplet.php
├── PHP-Persistenz und HTTP-API
├── HTML, editierbares CSS, Browser-UI
├── __halt_compiler();
├── PIPLET-DATA/2
└── { generation, versionierte JSON-Notizen, appearance }

Bevor ich in den Code gehe, kurz die Herkunft — sie gehört zur Einordnung dazu.

Matt Mullenweg hat piplets am 19. August 2026 im Abschluss-Fireside-Chat der WordCamp US in Phoenix vorgestellt, im Gespräch mit Robert Jacobi. Der offizielle Rückblick auf wordpress.org, geschrieben von Nicholas Garofalo, fasst es in genau einem Absatz zusammen:

„After a detour through why AI models now prefer simple, self-contained HTML files, the argument became tangible with WordPress Piplets, a 2007 idea built around a single self-modifying PHP file that stores its own data, with no database and no third-party packages. One file can hold up to 25 megabytes, about four million words, and load in about 50 milliseconds. For Mullenweg, it extends the experiments that took WordPress from MySQL to SQLite to Playground, and he floated the idea of a WordPress.org directory where people could publish, fork, and remix."

Und das einzige Zitat, das der Rückblick Mullenweg wörtlich zuschreibt:

„I want WordPress to be known for simplicity, not just complexity." — Matt Mullenweg

Drei Beobachtungen dazu, die ich beim Nachprüfen gemacht habe und die für die Lektüre des Codes hilfreich sind.

Erstens: Die Zahl im Rückblick und die Zahl im Code gehen auseinander. Der Quelltext deckelt die Datei bei PIPLET_MAX_FILE_BYTES = 8 * 1024 * 1024, also 8 MiB, und die Notizen bei 2.000. Das README schreibt es genauso hin: „The configured file ceiling is 8 MiB; request JSON is capped at 5 MiB, stored notes at 2,000, and tag references at 24,000." Das ist rund ein Drittel der genannten 25 MB. Und der Wert ist kein nachträgliches Härtungs-Artefakt — er steht schon im allerersten Commit, damals unter dem Namen PHPLET_MAX_FILE_BYTES, gut drei Tage vor der Bühne. Im README kommen weder „25" noch „million" noch „millisecond" ein einziges Mal vor. Und für die Zuschreibung wichtig: Die drei Zahlen stehen im Rückblick in Garofalos Fließtext, nicht in einem ausgewiesenen Mullenweg-Zitat. Wenn du sie zitierst, stell die 8 MiB daneben.

Zweitens: Die Commit-Historie ist ihre eigene Geschichte. 14 Commits, alle mit Autor und Committer Codex <codex@openai.com> — kryptografisch signiert ist keiner davon, die Zuordnung beruht auf dem Git-Autorfeld. Kein menschlicher Commit, kein Co-authored-by. Der Commit f928aca heißt „Rename: phplet -> piplet" und trägt den Zeitstempel 19.08.2026, 21:17 UTC — das ist 1 Stunde und 43 Minuten vor Beginn der Session, in der der Name fiel. Davor hießen Datei, README-Titel und Konstanten-Präfix phplet — mit einer aufschlussreichen Ausnahme: Der Formatmarker in den Daten lautete schon im allerersten Commit PIPLET-DATA/1. Der Name existierte also drei Tage vor der Umbenennung, nur nicht als Projektname. Und das Repository selbst wurde erst am 21.08. um 03:31 UTC angelegt, rund 28 Stunden nach der Ankündigung; der erste Push folgte 69 Sekunden später. Das ist das Muster eines einmaligen Imports.

Drittens: Den 2007er-Ursprung konnte ich nicht belegen. Ich habe die Blog-Suche auf ma.tt, das wp-hackers-Mailinglisten-Archiv von Juli 2007 und die üblichen Suchwege durchgesehen — kein Vorkommen von „piplet" oder „phplet" vor August 2026. Die Zuschreibung „2007er Idee" ist ausschließlich durch Mullenwegs eigene, mündliche Aussage gedeckt, wiedergegeben im offiziellen Rückblick. Eine Aufzeichnung gibt es allerdings: WordPress hat den Livestream als Closing Keynote — Fireside Chat mit Matt Mullenweg und Robert Jacobi (öffnet in neuem Tab) veröffentlicht, 1:07:48 lang, mit automatisch erzeugten englischen Untertiteln. Ein redigiertes Transkript liegt nicht vor, und ich habe die Aufzeichnung nicht ausgewertet — wer den genauen Wortlaut zu den 25 MB braucht, findet ihn dort. Der fehlende 2007er-Beleg ist kein Gegenbeweis, nur eine Lücke, die du kennen solltest, bevor du die Jahreszahl weiterträgst.

Zwischenfazit: piplet ist ausweislich seiner Commit-Metadaten in wenigen Tagen von einem KI-Agenten geschrieben, gut einen Tag nach der Ankündigung veröffentlicht worden und trägt seinen Projektnamen seit 103 Minuten vor dem Vortrag. Das sagt nichts über die Qualität des Codes — und die ist, wie du gleich sehen wirst, an mehreren Stellen bemerkenswert. Es sagt etwas über die Halbwertszeit von Konferenzzahlen.

Wie ein Speichervorgang wirklich abläuft

Hier wird es interessant, denn dieser Teil ist unabhängig von PHP, von Wikis und von WordPress lehrreich.

Die erste Überraschung: piplet überschreibt sich gar nicht selbst. Die Formulierung „selbstmodifizierende Datei" beschreibt das Ergebnis, nicht den Weg. Im gesamten Programm kommt file_put_contents null Mal vor. Beide Schreibpfade öffnen die Livedatei ausschließlich lesend (@fopen($path, 'rb')), der einzige Schreib-Handle zeigt auf eine Temp-Datei, und der kanonische Pfad wird durch genau einen rename()-Aufruf verändert.

Daraus folgen zwei Dinge, die leicht zu übersehen sind:

  • Lesen braucht kein Lock. Wer die Datei öffnen kann, hält einen Deskriptor auf einen Inode, der nie mehr verändert wird. Er sieht garantiert eine vollständige alte oder eine vollständige neue Datei — nie eine halbe.
  • Der PHP-Prozess braucht kein Schreibrecht an der Datei, sondern am Verzeichnis. Genau das prüft die UI: is_readable($path) && is_writable(dirname($path)).

Der Ablauf in seinen elf Kernschritten, in der Reihenfolge des Codes:

  1. Prüfen, ob fsync() überhaupt existiert — wenn nicht, HTTP 503, bevor irgendetwas gesperrt wird.
  2. fopen($path, 'rb'), dann flock(LOCK_EX | LOCK_NB). Blockiert die Sperre, 5–20 ms schlafen und erneut versuchen.
  3. fstat() des Deskriptors gegen stat() des Pfades vergleichen. Weichen sie ab, freigeben und von vorn (dazu gleich mehr).
  4. Hardlinks ablehnen: nlink !== 1 → Abbruch.
  5. Datei lesen, Trailer dekodieren, Mutation anwenden, Dokument-Revision erhöhen.
  6. $prefix = substr($raw, 0, __COMPILER_HALT_OFFSET__);
  7. Exakte Größe der Ausgabe ausrechnen, bevor eine Datei entsteht.
  8. Private Temp-Datei daneben anlegen, in 64-KiB-Blöcken befüllen, fflush + fsync.
  9. Rechtebits der Livedatei übernehmen, erneut fsync.
  10. Ein letztes Mal prüfen, ob der Zielpfad noch derselbe Inode ist.
  11. rename($temp, $path).

Schritt 6 ist meine Lieblingszeile der ganzen Datei. Der Offset kommt aus dem laufenden Compilat — PHP hat ihn beim Übersetzen als Literal eingebacken. Die Bytes kommen aus der Datei, die gerade unter Sperre gelesen wurde. Es gibt kein Template, keine Regeneration, kein strpos('__halt_compiler'), nichts, was von Daten beeinflussbar wäre. Das Präfix wird kopiert, nie erzeugt.

Der Inode-Retry: warum flock(__FILE__) falsch ist

Das ist die Stelle, an der ich beim Lesen kurz innehalten musste. Der Kommentar im Code benennt sie selbst:

rename() swaps inodes, so locking the first file we open is not enough: a waiter may have opened the old inode. We lock, compare the descriptor's device/inode with the current path, and retry until we own the live file."

Der Ablauf, an dem das hängt:

  1. A öffnet die Datei → Deskriptor auf Inode 1, flock gelingt.
  2. B öffnet dieselbe Datei → ebenfalls Inode 1, flock blockiert, B wartet.
  3. A schreibt seine Temp-Datei und macht rename(). Der Pfad zeigt jetzt auf Inode 2. Inode 1 ist unlinked, lebt aber weiter, weil B ihn offen hält.
  4. A gibt die Sperre frei.

Und hier trennen sich naive und korrekte Umsetzung. Naiv bekommt B jetzt die Sperre — auf einer verwaisten Datei. flock auf einem unlinked Inode gelingt problemlos und schließt niemanden aus, denn ein nächster Schreiber sperrt Inode 2. B liest den Zustand vor A, wendet seine Änderung an und schiebt sein Ergebnis über den Pfad. As Speicherung ist ersatzlos weg — und die gegenseitige Ausschließung, auf die sich alle verlassen, existiert schlicht nicht.

Die Linux-Manpage sagt genau das: „Locks created by flock() are associated with an open file description." Die Sperre hängt an der Inode, nicht am Pfadnamen. Nach einem rename() sperrst du das Falsche.

Die Lösung ist unspektakulär und deshalb so gut:

$lockedStat = fstat($handle);
clearstatcache(true, $path);
if (!piplet_same_inode($lockedStat, @stat($path))) {
    @flock($handle, LOCK_UN); @fclose($handle); $handle = null;
    usleep(random_int(5000, 20000));
    continue;
}

dev und ino gemeinsam vergleichen, bei Abweichung neu ansetzen. Das clearstatcache() davor ist Pflicht, kein Zierrat: PHP cacht stat-Ergebnisse pro Request, ein ungeleerter Cache würde die Vertauschung nie sehen — und der Fehler wäre völlig stumm.

Zwei Feinheiten noch. Der Vergleich ist ABA-frei, weil das Dateisystem eine Inode-Nummer nicht wiederverwenden darf, solange ein Prozess einen Deskriptor darauf hält. Ein Vergleich zweier stat(path)-Aufrufe zu verschiedenen Zeitpunkten hätte diese Eigenschaft nicht. Und der Backoff ist mit random_int(5000, 20000) randomisiert — gegen Lockstep, wenn mehrere Schreiber gleichzeitig warten.

Alle Wiederholungen teilen sich eine gemeinsame, monotone Deadline von zwei Sekunden (hrtime(true) + 2000000000). Läuft sie ab, gibt es HTTP 503 mit Retry-After: 1. Wichtig für die Einordnung: Diese Deadline begrenzt nur die Wiederholungen. Sobald die Sperre steht, laufen Lesen, Mutation und das gesamte Schreiben ohne jedes Zeitlimit. Zwei Sekunden sind kein Gesamtbudget für die Anfrage.

Die Größe ausrechnen, bevor eine Datei entsteht

piplet_json_encoded_length() bildet die JSON-Grammatik ohne Whitespace nach: 2 für das Klammerpaar, +1 je Komma, bei Objekten der gequotete Schlüssel plus 1 für den Doppelpunkt, Skalare einzeln kodiert und nur die Länge behalten. Der Docblock sagt warum: „Exact length under PIPLET_JSON_FLAGS, without allocating the full JSON string."

Drei Gründe, alle im Code sichtbar:

  • Kein Artefakt bei Übergröße. Der 413-Wurf steht vor dem Anlegen der Temp-Datei. Ein zu großer Save erzeugt niemals eine Datei, die aufgeräumt werden müsste.
  • Speicherschutz. Bei Übergröße wird json_encode auf das Gesamtdokument nie aufgerufen — der Spitzenverbrauch bleibt klein, statt mehrere MiB zu allozieren, während das alte Dokument noch im Speicher liegt.
  • Exaktes Byte-Budget. Der Deckel ist überlaufsicher formuliert: $jsonLength > MAX - $fixed statt $fixed + $jsonLength > MAX.

Und dann kommt die Zeile, die den ganzen Ansatz erst tragfähig macht:

if (strlen($json) !== $jsonLength) {
    throw new RuntimeException('Snapshot size projection failed.');
}

Ein Selbstaudit. Das Modell — die handgeschriebene Längenrechnung — wird nach dem echten json_encode gegen die Realität geprüft. Weicht PHPs Encoder je ab, weil sich Escaping-Semantik ändert oder ein Typ falsch behandelt wird, bricht der Save ab, statt eine Datei zu schreiben, die den Deckel überschreitet. Eine Optimierung, die sich selbst kontrolliert.

Die Temp-Datei

tempnam() hat einen Fallstrick, den ich selbst schon einmal übersehen habe: Ist das angegebene Verzeichnis nicht beschreibbar, fällt die Funktion stillschweigend auf sys_get_temp_dir() zurück. Eine Temp-Datei in /tmp liegt aber womöglich auf einem anderen Dateisystem — und rename() ist nur innerhalb eines Dateisystems atomar. Schlimmer noch: PHPs rename() fällt bei EXDEV intern auf Kopieren plus Löschen zurück und meldet trotzdem Erfolg. Du verlierst die Atomarität, ohne eine Fehlermeldung zu sehen.

piplet fängt das ab, bevor ein einziges Byte geschrieben ist:

$created = @tempnam($directory, '.piplet-tmp-');
if (!is_string($created) || realpath(dirname($created)) !== realpath($directory)) {
    if (is_string($created)) { @unlink($created); }
    throw new RuntimeException('Cannot create a snapshot beside the piplet.');
}

Der Name bekommt anschließend 16 Hex-Zeichen aus random_bytes(8) und die Endung .php. Diese Endung ist Absicht: Die Datei liegt kurzzeitig in einem Verzeichnis, das ein Webserver ausliefert. Ohne .php würde sie als Klartext ausgegeben — Quelltext und sämtliche Notizen. Mit .php wird sie ausgeführt, und dann greift der Riegel, der ganz oben in der Datei steht, noch vor jeder Klassendefinition:

// A half-written temporary copy must never behave as the live application.
if (str_contains(basename(__FILE__), '.piplet-tmp-')) {
    http_response_code(503);
    exit('Save in progress.');
}

Ein Artefakt, das sich selbst erkennt. Das ist das Muster, das ich mir davon gemerkt habe.

Beim Anlegen prüft piplet vier Eigenschaften: reguläre Datei, keine Gruppen-/Other-Bits, nlink === 1, und fstat(fd) gegen lstat(pfad) — Letzteres schließt einen untergeschobenen Symlink aus, weil lstat keinem Link folgt. Zwei davon, Inode-Identität und nlink, werden nach dem Schreiben und noch einmal nach dem chmod wiederholt.

Auch das chmod(0600) gleich nach dem Anlegen hat einen nicht offensichtlichen Grund, den der Kommentar erklärt: Es repariert, statt zu verengen. tempnam() legt mit 0600 an, aber die umask wird angewandt — bei umask 0777 entsteht Modus 0000, und der Eigentümer könnte die Datei über den Pfad nicht mehr öffnen. Die Zielrechte werden erst gesetzt, wenn Inhalt und fsync durch sind: „it never widens an exposed group/other-readable interval."

Was zugesichert ist — und was nicht

Der Aufräumpfad im finally löscht ausschließlich dann, wenn lstat immer noch denselben Inode zeigt und nlink === 1 ist. Zeigt der Pfad inzwischen woandershin, wird nichts gelöscht, sondern nur geloggt: 'piplet left an unrecognized temporary path untouched'. Ein rekursives Löschen, das nur vom Pfadnamen ausgeht, ist ein Fußgeschoss — hier ist es sauber vermieden.

Was ein harter Kill hinterlässt, ist getestet und dokumentiert: Wird der Prozess zwischen fsync und rename mit SIGKILL beendet, ist die kanonische Datei bit-identisch unverändert, und daneben liegt genau eine 0600-Waisendatei — die kein Fragment ist, sondern ein vollständiges, gültiges Piplet, nur eben nicht publiziert.

Und jetzt die Lücke, die das README selbst benennt und die für jeden gilt, der dieses Muster nachbaut. Die maßgebliche Referenz für sicheres Ersetzen ist der LWN-Artikel „Ensuring data reaches disk" von Jeff Moyer. Er nennt fünf Schritte:

  1. „create a new temp file (on the same file system!)"
  2. „write data to the temp file"
  3. „fsync() the temp file"
  4. „rename the temp file to the appropriate name"
  5. „fsync() the containing directory"

piplet macht die Schritte 1 bis 4. Schritt 5 fehlt. fsync auf der Datei sichert Inhalt und Metadaten dieser Inode — aber nicht den Verzeichniseintrag, der auf sie zeigt. Fällt der Strom nach dem rename, aber vor dem Zurückschreiben des Verzeichnisblocks aus, kann der letzte Speichervorgang fehlen. Das Ergebnis ist trotzdem nie zerrissen: entweder vollständig alt oder vollständig neu.

Interessanterweise ist die verbreitete Begründung dafür — „portables PHP kann Verzeichnisse nicht fsyncen" — nicht ganz richtig. Ich habe es nachgemessen: fopen($dir, 'r') gefolgt von fsync($handle) liefert bool(true), und strace zeigt den echten Syscall:

openat(AT_FDCWD, "/tmp/dsynctest", O_RDONLY) = 4
fsync(4)                                     = 0

Auf POSIX-Systemen geht das also. Unter Windows nicht, und die PHP-Dokumentation erwähnt den Fall nirgends — insofern ist die Zurückhaltung nachvollziehbar. Aber wenn du das Muster in einem reinen Linux-Kontext nachbaust, kannst du den fünften Schritt haben.

Was ebenfalls nicht zugesichert ist, weil rename() einen neuen Inode publiziert: ACLs, erweiterte Attribute, setuid/setgid/sticky-Bits (die Maske ist & 0777) und der Eigentümer. Ein als root:root deploytes Piplet gehört nach dem ersten Speichern dem PHP-Prozessbenutzer.

Zwischenfazit: Der Speicherpfad ist der stärkste Teil des Programms, und er hat mit „Wiki" nichts zu tun. Vier ineinandergreifende Ideen tragen ihn: nie in-place schreiben; die Sperre gegen die Inode-Identität validieren, nicht gegen den Pfadnamen; die Kosten kennen, bevor du sie verursachst; und jedes Zwischenartefakt so bauen, dass es sich selbst als Zwischenartefakt erkennt.

Zum Mitnehmen: atomar schreiben, in jeder Sprache

Das Muster ist sprachunabhängig und du brauchst es öfter, als du denkst — jedes Mal, wenn eine Konfigurationsdatei, ein Cache-Index, ein Export oder ein Zustandsdokument im Betrieb ersetzt wird:

  1. Temp-Datei im selben Verzeichnis anlegen (nicht im System-Temp — sonst Dateisystemgrenze).
  2. Vollständig schreiben, dabei Kurzschreiber in einer Schleife behandeln.
  3. fsync auf die Datei.
  4. Rechte setzen, gegebenenfalls erneut fsync.
  5. rename über das Ziel.
  6. Wenn möglich: fsync auf das Verzeichnis.

In Node.js heißt das fs.writeFileSync in eine Temp-Datei, fs.fsyncSync(fd), dann fs.renameSync. In Python os.fsync(f.fileno()) und os.replace(). Was du in keiner Sprache tun solltest, ist der naheliegende Einzeiler: file_put_contents($f, $data, LOCK_EX) sieht sicher aus, ist es aber nicht. Ich habe mir angesehen, was dabei tatsächlich passiert:

openat(AT_FDCWD, "/tmp/lockx/t.txt", O_WRONLY|O_CREAT, 0666) = 4
flock(4, LOCK_EX)                                            = 0
ftruncate(4, 0)                                              = 0

Immerhin: Die oft behauptete „Truncate vor Lock"-Race gibt es in aktuellem PHP nicht — gesperrt wird vor dem Kürzen. Nicht atomar ist es trotzdem, aus zwei anderen Gründen. Erstens ist die Sperre advisory: Jeder Leser, der kein flock aufruft — und das tut praktisch jedes include, jedes file_get_contents, jeder Webserver — sieht das Fenster zwischen ftruncate() und dem letzten write(). In diesem Fenster ist die Datei leer oder halb. Zweitens gibt es keine Alles-oder-nichts-Zusage: Bricht der Prozess dazwischen ab, bleibt eine trunkierte Datei zurück. Bei einem Programm, das seinen eigenen Quelltext schreibt, heißt das Parse-Error und Totalverlust.

Prüfen, bevor geparst wird

Die zweite Idee, die ich mitnehme, betrifft etwas, das die meisten von uns viel zu selbstverständlich behandeln: json_decode.

piplet ruft drei eigene Byte-Scanner auf, bevor der Parser die eingebetteten Daten sieht. Alle drei laufen ohne Allokation über den rohen String. Auf eingehende Requests wendet es zwei davon an — Struktur-Budget und Membernamen; der Zahlen-Scanner bewacht ausschließlich den Trailer. Der Kommentar über dem ersten benennt die Arbeitsteilung präzise:

/** Cheap allocation guard; json_decode remains the JSON grammar authority. */

Erstens: das Struktur-Budget

piplet_json_within_budget() zählt beim Zeichen-für-Zeichen-Scan drei Größen — Strukturzeichen, geöffnete Container und Verschachtelungstiefe — und überspringt dabei korrekt Strings und Escapes.

Warum? Weil der depth-Parameter von json_decode nur die Verschachtelung begrenzt, nicht die Menge. Ein Body aus [1,1,1,…] hat Tiefe 2 und ist völlig unauffällig. Ich habe es nachgemessen:

Wert
Payload [1,1,…] an der Request-Grenze 5.242.879 Bytes, 2.621.439 Elemente
json_decode() Speicherzuwachs 91 MiB
Der Vorab-Scan false in 0,05 ms, kein Zuwachs

Bei den vom README geforderten memory_limit=128M bleibt für genau diesen Body noch Luft — der Spitzenverbrauch liegt bei rund 105 MiB. Tausch die Einsen gegen [1], und in dieselben 5 MiB passen 1,3 Millionen verschachtelte Arrays: Dann stirbt der Request am Fatal Error statt an einem sauberen HTTP 413. Genau diese Sorte Payload sieht der Vorab-Scan, bevor irgendetwas alloziert wird.

Der Container-Zähler ist dabei die eigentlich interessante Größe: Er ist kumulativ, nicht maximal. 8.192 flache Objekte nebeneinander sind ebenso begrenzt wie 8.192 verschachtelte.

Zweitens: doppelte Membernamen

json_decode('{"a":1,"a":2}') wirft keinen Fehler. Der letzte Wert gewinnt, der erste verschwindet stillschweigend. Wer einen Datensatz mit {"version":"<echt>","version":"<gefälscht>"} einschleust, kann damit eine Prüfung und die spätere Verwendung auseinanderlaufen lassen.

Der Scanner führt einen kleinen Stack und prüft jeden Membernamen auf Eindeutigkeit. Der Trick ist, dass er den Namen dekodiert, statt Bytes zu vergleichen:

$member = json_decode(substr($json, $index, $end - $index + 1), false, 2, JSON_THROW_ON_ERROR);
$lookup = "\0" . $member; // Prevent numeric-string conversion by PHP arrays.
if (isset($stack[$slot][$lookup])) return false;

Warum das nötig ist, zeigt eine einzige Zeile aus meiner Messreihe: In {"a":1,"\u0061":2} stehen zwei verschiedene Byte-Folgen, die denselben Membernamen bezeichnen. Ein Byte-Vergleich hätte sie durchgewinkt, json_decode kollabiert sie. Und das "\0"-Präfix ist kein Zierrat: PHP-Arrays casten dezimale String-Keys zu Integern. array_keys(get_object_vars(json_decode('{"123":"x"}'))) liefert [int(123)]. Genau deshalb — und nur deshalb — schließt die ID-Regel von piplet reine Ziffernfolgen aus (!ctype_digit($value)).

Drittens: verlustfreie Zahlen

Der dritte Scanner isoliert jede Zahl außerhalb von Strings und prüft einen Round-Trip gegen die eigenen Encoder-Flags:

if (json_encode($decoded, PIPLET_JSON_FLAGS) !== $number) return false;

Dekodieren, wieder kodieren, byteweise vergleichen. Was dabei nicht identisch herauskommt, wird abgelehnt. Ein Auszug aus der Messung:

Zahl verlustfrei? Grund
1 ja
1.0 ja nur dank JSON_PRESERVE_ZERO_FRACTION
0.30000000000000004 ja Float-Round-Trip stimmt
9007199254740993 ja PHP-Integer, exakt
1e2 nein wird zu 100.0
-0 nein wird zu 0
12345678901234567890 nein wird zu 1.2345678901234567e+19
1e999 nein wird INF, json_encode wirft

Der Grund ist keine Pedanterie, sondern die Bauform: Jeder Speichervorgang liest das gesamte Dokument, dekodiert es und kodiert es komplett neu. Jede Zahl, die PHP nicht identisch reproduziert, würde beim nächsten fremden Save unbemerkt umgeschrieben — auch in Feldern, die piplet gar nicht kennt, denn unbekannte Schlüssel überleben die Mutation. Ohne die Prüfung hätte piplet Dokumente, die es lesen, aber nie wieder schreiben kann.

Bemerkenswert ist die Eichung: Sie prüft PHPs Round-Trip, nicht JavaScripts. 9007199254740993 besteht sie, obwohl der Browser die Zahl verlieren würde. Für die Felder, die tatsächlich zum Browser reisen, gibt es eine eigene Grenze: PIPLET_MAX_REVISION = 9007199254740991 — exakt Number.MAX_SAFE_INTEGER.

Zwischenfazit: Die drei Scanner sind zusammen keine 130 Zeilen und decken drei Klassen ab, die ein Parser prinzipiell nicht abdecken kann: Kosten vor der Allokation, stille Kollaps-Semantik und stillen Präzisionsverlust. Das ist die Denkfigur dahinter — billige, allokationsfreie Vorprüfung; der Parser bleibt trotzdem die Autorität für die Grammatik. Übertragbar auf jede Stelle, an der du fremde Daten in einen Parser gibst: XML, YAML, CSV, Bildformate.

Zwei Editoren, keine Datenbank

Die dritte Idee betrifft ein Problem, für das du normalerweise eine Datenbank hättest: Was passiert, wenn zwei Leute gleichzeitig schreiben?

piplet legt drei Größen übereinander:

Größe Typ Rolle
generation 32 Hex-Zeichen (128 Bit) Identität der Datei-Abstammungslinie
version 32 Hex-Zeichen, pro Datensatz Identität dieser Fassung dieses Datensatzes
revision Integer, dokumentweit + pro Datensatz Anzeigezähler und Ordnungsrelation

Jede Mutation schickt alle drei als Vorbedingung mit; die beiden Hex-Werte werden mit hash_equals() verglichen, die Revision als Integer mit !==. Und hier steckt die Entscheidung, die das ganze Modell brauchbar macht: Die Dokument-Revision ist niemals Vorbedingung. Verglichen wird immer die Version des betroffenen Datensatzes. Deshalb funktioniert das Versprechen „zwei Editoren können gefahrlos verschiedene Notizen ändern" — jeder Save erhöht den globalen Zähler, aber das invalidiert die Vorbedingungen der anderen nicht.

Fehlt eine der drei Basisangaben, antwortet der Server nicht mit 422, sondern mit HTTP 428 Precondition Required. Das ist der Statuscode, für den es ihn gibt, und ich sehe ihn in freier Wildbahn fast nie.

Das ABA-Problem

Klassisch: Eine Notiz foo wird von einem zweiten Client gelöscht und unter demselben Namen neu angelegt. Sie sieht aus wie vorher. Der erste Client hält noch id=foo und würde blind überschreiben.

Der Slug wird tatsächlich wiederverwendet — nach dem Löschen ist er wieder frei. Erkannt wird der Fall über die Version, nicht über die ID. Ein 128-Bit-Zufallswert pro Fassung kann durch Löschen und Neuanlegen nicht zurückkehren. Der Ablauf, gegen eine Arbeitskopie durchgespielt:

create#1                      => OK   id=idem revision=8  version=cee493c2…
delete                        => OK   docRev=9
recreate same title           => OK   id=idem revision=10 version=faddd3dd…   (gleicher Slug!)
alter Client speichert        => 409  This note changed after you opened it.

Die Revision allein würde hier auch auffallen (10 ≠ 8) — nach einem Restore aus einem Backup aber nicht mehr. Dafür ist die Generation zuständig. Und das README ist ehrlich über die Grenze:

„They cannot detect an exact rollback to the same generation and versions a client already loaded; that requires trusted state outside this one file, such as an append-only log or database."

Gegen einen exakten Rollback hilft also: eine Datenbank. Das ist ein bemerkenswert offener Satz für ein Projekt, dessen Verkaufsargument „keine Datenbank" lautet.

Idempotenz ohne Zustand

Der andere Klassiker: Der Client schickt „neue Notiz anlegen", die Antwort geht verloren, der Client wiederholt. Ohne Vorkehrung hast du zwei Notizen.

Jeder Editor erzeugt beim Öffnen einen createToken — 128 Bit aus crypto.getRandomValues, der über Wiederholungen stabil bleibt und mit dem Entwurf zusammen im sessionStorage liegt. Serverseitig gibt es drei Ausgänge:

create#1                            => OK   id=idem revision=8 docRev=8
create#2 identischer Retry          => OK   id=idem revision=8 docRev=8   (unverändert!)
create#3 gleicher Token, anderer Text => 409  This new note was already saved with different content.

Der zweite Request liefert 200 mit derselben Notiz und unveränderter Dokument-Revision. Es wird kein einziges Byte geschrieben: Ein spezieller Rückgabetyp kurzschließt vor dem Inkrement und vor dem Persistieren. Der dritte Fall ist die eigentlich interessante Designentscheidung — bei gleichem Token und abweichendem Inhalt legt piplet weder still eine zweite Notiz an noch überschreibt es, sondern gibt 409 mit der bestehenden Notiz zurück und lässt den Menschen entscheiden.

Und der Token bleibt lebenslang an der Notiz kleben. Das Dedupe-Fenster ist damit unbegrenzt, solange die Notiz existiert — nicht zeitbasiert, wie es Idempotency-Keys in APIs meistens sind.

Zwischenfazit: Optimistische Nebenläufigkeit mit drei Ebenen — Abstammungslinie, Datensatzfassung, Anzeigezähler — ersetzt hier eine Transaktion. Das ist kein exotisches Konstrukt, sondern exakt das, was ein HTTP-ETag und If-Match tun, nur pro Datensatz statt pro Ressource. Wenn du eine API baust, in der zwei Clients dasselbe Objekt bearbeiten können: Zufällige Version pro Fassung schlägt Zeitstempel, und ein stabiler Idempotenz-Schlüssel gehört zum Client-Zustand, nicht zum Request.

Alles im Browser, alles aus einer Datei

Der größte Einzelblock der Datei ist nicht das PHP, sondern das JavaScript: 1.597 Zeilen, 81 KB. Es gibt keinen Build, kein Modul, kein Framework — ein IIFE, 14 let-Variablen als zusammenhängender Zustandsblock, kein Diffing, kein Router.

Drei Details fand ich lehrreich.

Der Boot-Transport. Der gesamte Datenbestand reist inline mit der ersten Antwort. Es gibt keine Lese-Endpunkte, nur drei Schreibaktionen. Und die Daten kommen so in die Seite:

<script type="application/octet-stream" id="piplet-state" nonce="…">…base64…</script>

Zwei Eigenschaften greifen ineinander. type="application/octet-stream" ist kein JavaScript-MIME-Type — der Browser parst den Inhalt als Datenblock, führt nichts aus, macht ihn aber über .textContent verfügbar. Und base64 enthält kein <. Das Alphabet ist A–Z a–z 0–9 + / =. Damit ist es unabhängig vom Inhalt der Notizen mathematisch unmöglich, aus dem Boot-State heraus </script> oder irgendein anderes HTML-Endtag zu bilden. Nicht „unwahrscheinlich" — unmöglich. Das ist eine deutlich stärkere Aussage als jedes Escaping.

Notiztext erreicht das gerenderte HTML nie. Kein Notiztitel, kein Body, kein Tag erscheint im servergenerierten Markup; der <title> ist statisch. Die einzige Nutzereingabe, die dort überhaupt auftaucht, ist das Custom-CSS — und die reist als JSON-String mit JSON_HEX_TAG und Geschwistern in eine reine textContent-Zuweisung. Im Client läuft alles über eine Hilfsfunktion, die ausschließlich node.textContent setzt. Es gibt genau einen innerHTML-Zugriff im ganzen Programm, und beide Aufrufer übergeben literale SVG-Pfade. eval, new Function, insertAdjacentHTML, document.write kommen nicht vor.

Die CSP ist entsprechend knapp:

default-src 'none'; style-src 'nonce-…'; script-src 'nonce-…'; connect-src 'self';
img-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'

img-src data:kein 'self'. Selbst eigene Bilddateien wären blockiert. Nebeneffekt: Die klassische CSS-Exfiltration über background: url(https://…) in Attributselektoren stirbt damit ebenfalls. Und weil das Custom-CSS-Feature bewusst unbeschränkt ist (32 KiB komplettes Stylesheet, kein Parser, keine Selektor-Allowlist), ist genau das die Absicherung: CSS kann die Oberfläche zerstören, aber nichts nachladen und nicht zu Skript werden. Für den Fall, dass du dir die Oberfläche zerlegst, gibt es ?safe=1 — der Parameter ändert nichts an Headern oder Rechten; er leert das Custom-CSS server- wie clientseitig und blendet einen Hinweis mit Rückweg ein.

Der Entwurfs-Retter. Rund 250 Zeilen nur dafür, dass Text überlebt, den der Server noch nie gesehen hat. Was mich daran überzeugt hat, ist nicht der Mechanismus, sondern die Haltung: sessionStorage wird wie feindliche Eingabe behandelt. Jeder wiedergefundene Entwurf durchläuft ein eigenes Strukturbudget (256 Strukturzeichen, Tiefe 8 — die JS-Spiegelung des Server-Scanners), eine Schema-Validierung, eine Schlüsselform-Prüfung, eine Unicode-Reparatur für einsame Surrogates und eine Read-back-Verifikation:

if (raw.length > 512 * 1024 || !sessionWrite(source.recoveryKey, raw)
    || sessionRead(source.recoveryKey) !== raw) {
    source.recoveryWarning = 'This browser could not store the latest draft. …';
    return false;

Dem Erfolg von setItem wird nicht geglaubt; es wird zurückgelesen und verglichen. Das fängt Browser ab, die still verschlucken oder kürzen.

Und die Konsequenz daraus ist konsequent zu Ende gedacht: Kann der Entwurf nicht gesichert werden, verweigert die Anwendung den Zustandswechsel, statt optimistisch weiterzumachen. Der Editor bleibt offen und sagt warum. Passend dazu wird beforeunload nur in genau diesem Fall registriert — eine normale Editorsitzung zeigt nie einen „Seite verlassen?"-Dialog. Das ist die Umkehrung dessen, was die meisten Anwendungen tun, und ich finde sie richtig: Wenn der Dialog eine Ausnahme ist, nimmt der Nutzer ihn ernst.

Zwischenfazit: Wenn du Daten aus dem Server in eine Seite bekommen musst, ist base64 in einem inerten Element robuster als jedes Escaping, weil es die Angriffsklasse durch das Alphabet ausschließt statt durch eine Filterregel. Und: Behandle deinen eigenen Client-Speicher wie fremde Eingabe. JSON.parse(localStorage.getItem(…)) und dem Ergebnis vertrauen ist die Stelle, an der viele SPAs überraschend nachlässig sind.

Wie sich so etwas überhaupt testen lässt

Die Testsuite ist 3.614 Zeilen lang und hat keine Abhängigkeiten. Kein Framework, kein Autoloader, keine Klassenhierarchie. Die komplette Assert-Bibliothek sind acht Zeilen:

function check(bool $condition, string $message): void
{
    global $assertions;
    $assertions++;
    if (!$condition) throw new RuntimeException($message);
}

Ich habe sie laufen lassen, auf PHP 8.4.24:

ok — 433 assertions; source file untouched; 7+ MiB cycle 10.86s (worker peak 27.6 MiB)

Die 433 sind dabei die Zahl eines Laufs mit Browser-Regressionen. Mein erster Versuch war daran gescheitert, dass das Snap-Chromium unter WSL an seiner Mount-Namespace-Konstruktion hängen blieb; der zweite lief durch. Fehlt der Browser wirklich, sagt die Suite das selbst und zählt anders: Dann steht davor skip — Chrome unavailable; dynamic browser regressions were not run und darunter 436 Assertions. Das README ist an dieser Stelle streng — „a skip is not evidence that the browser boundary passed" —, und für Release-Validierung gibt es PIPLET_REQUIRE_CHROME=1, das ein fehlendes Chrome zum Fehlschlag macht.

Interessant ist, wie die schwierigen Fälle überhaupt testbar gemacht werden. Das durchgehende Verfahren ist Quelltext-Instrumentierung einer Wegwerfkopie, nie Mocking:

  1. Isolierte Kopie erzeugen.
  2. Einen exakten, mehrzeiligen Needle definieren.
  3. check(substr_count($source, $needle) === 1, 'Could not locate …')genau ein Treffer, sonst bricht der Test ab.
  4. Per str_replace einen umgebungsgesteuerten Checkpoint einfügen.

Schritt 3 ist der Clou: Wird die Anwendung refaktoriert und der Needle verschwindet oder verdoppelt sich, schlägt der Test laut fehl, statt still ins Leere zu laufen.

Damit werden Dinge prüfbar, die sonst nur probabilistisch reproduzierbar wären:

  • Die Inode-Race deterministisch. Eine dateibasierte Barriere direkt nach dem fopen: Das Kind schreibt <barrier>.opened, der Runner prüft die Inode-Nummer, ersetzt die Datei, schreibt <barrier>.release. Eine .passed-Marke macht die Barriere einmalig, damit der Retry-Durchlauf nicht erneut hängt, und die eingespielte Warteschleife hat ihre eigene Deadline und wirft.
  • Crash-Konsistenz mit echtem SIGKILL. Der Checkpoint signalisiert und hängt sich absichtlich auf (while (true) usleep(10000);), dann proc_terminate($process, 9). Kein finally läuft — genau darum geht es. Danach: kanonische Datei per SHA-256 unverändert, genau ein Waise mit Modus 0600, und der Waise wird ausgeführt und muss Save in progress. antworten.
  • Partielle Schreibvorgänge ohne OS-Fault-Injector. Ein eigener Stream-Wrapper, dessen stream_write nie mehr als 7 Bytes akzeptiert. 120.000 Bytes durchgeschoben, Ergebnis muss byteidentisch sein.
  • Fehlendes fsync über php -d disable_functions=fsync — erwartet wird ein Abbruch bei unverändertem Datei-Hash.
  • Verlorene Antworten ganz ohne Netzwerk-Fault: derselbe Payload zweimal senden, und dann nicht nur den Inhalt vergleichen, sondern die Inode-Nummer — der Beweis, dass gar kein rename stattfand.

Und das Harness testet seine eigenen Primitive, bevor es sie benutzt: dass die Kommando-Deadline hält, dass der Worker /etc/passwd ablehnt, dass die Server-Readiness-Prüfung keinen fremden Prozess auf demselben Port akzeptiert, dass 1 MiB Worker-Output ungekürzt ankommt — und dass der Runner selbst unter einem Web-SAPI 404 liefert.

Zwischenfazit: 280 check()-Stellen erzeugen 433 Assertions für 3.584 Zeilen Anwendungscode. Zum Vergleich: 64 Assertions für die 78-Zeilen-Demo. Der Umfang beider Suiten ist die präziseste Beschreibung des Preises, den die fehlenden Eigenschaften der kleinen Fassung haben.

Was der Betrieb voraussetzt

Hier lohnt es sich, genau hinzusehen — nicht als Urteil, sondern weil die Bedingungen selbst lehrreich sind.

PHP 8.1 ist keine willkürliche Grenze. fsync() gibt es in PHP erst seit 8.1 (RFC „fsync_function", angenommen mit 30 zu 1 Stimmen). Und piplet macht das Speichern hart davon abhängig:

if (!function_exists('fsync')) {
    throw new PipletHttpError(503, 'Saving is unavailable because file synchronization is disabled.');
}

Die zweite Anforderung — 64 Bit — ist unabhängig davon begründet: hrtime(true) liefert auf 32 Bit einen Float statt eines Integers, und PIPLET_MAX_REVISION ist 2⁵³−1 und dort nicht darstellbar. Der Code prüft das separat.

Das Deployment ist kein „Datei hochladen". Das README verlangt eine eigene HTTPS-Origin, in der nur index.php liegt, PIPLET_PASSWORD in der Worker-Umgebung, jeden anderen Pfad am Webserver gesperrt, und ein Backend, das nur über den TLS-Proxy erreichbar ist. Die Anforderungsliste an den Proxy ist über zehn Zeilen lang: maximal 5 MiB Body, 4 KiB Request-Target, höchstens 64 Header, Ablehnung widersprüchlicher Content-Length/Transfer-Encoding-Kombinationen, Rate-Limiting fehlgeschlagener Authentifizierung, Fetch-Metadata-Prüfung. Nicht unterstützt sind NFS/SMB, mehrere Hosts, serverlose Dateisysteme, Hardlink-Aliase und Windows — erzwungen wird davon im Code allerdings nichts, es gibt keinen Dateisystem-Typ-Check.

Das Passwort ist Pflicht, ausnahmslos, auch auf Loopback. Ich habe das an der Abwesenheit verifiziert: Die vollständige Liste der gelesenen $_SERVER-Schlüssel umfasst zwölf Einträge — kein REMOTE_ADDR, kein HTTP_HOST. Fehlt das Passwort, gibt es HTTP 403 mit Klartext, bewusst ohne WWW-Authenticate, weil ein Login-Dialog, der nie erfolgreich sein kann, irreführend wäre. Und die Zeichenfolge „forward" kommt im gesamten Programm kein einziges Mal vor — X-Forwarded-Proto und Forwarded werden nicht bloß ignoriert, sie werden nicht einmal erwähnt.

Der Rahmen, in dem eine web-schreibbare PHP-Datei steht. Diese Regeln existieren, unabhängig davon, wie sauber der Code ist. Das HTTPD-Wiki der Apache Software Foundation (öffnet in neuem Tab) formuliert es am direktesten:

„Read access only. The web server user should not own, or be able to write to, its configuration files or content."

Das BSI verlangt in IT-Grundschutz APP.3.2.A1 (Edition 2023) als Basis-Anforderung: „Dem Webserver-Dienst MÜSSEN alle nicht notwendige Schreibberechtigungen entzogen werden." — und in A2, dass Skripte und Konfigurationsdateien gegen unbefugtes Lesen und Ändern geschützt sind. PCI DSS 11.5.2 (v4.0.1) fordert mindestens wöchentliche Dateivergleiche auf „critical files", definiert als Dateien, die sich normalerweise nicht ändern, deren Änderung aber auf eine Kompromittierung hindeuten könnte.

Wichtig ist mir die Genauigkeit hier: Keine dieser Quellen enthält einen Satz, der selbstmodifizierenden Code namentlich verbietet. Und das WordPress-Härtungshandbuch ist differenzierter, als es oft zitiert wird — es erlaubt Schreibrechte des Webserver-Prozesses für /wp-content/ ausdrücklich, nur eben nicht für Kern-Code. Was sich daraus folgern lässt, und das ist meine Folgerung, keine Quellenaussage: Eine Änderungserkennung an dieser einen Datei kann nicht mehr zwischen „jemand hat eine Notiz gespeichert" und „jemand hat Code eingefügt" unterscheiden. Es ist derselbe Event.

Das README nimmt diesen Einwand übrigens selbst vorweg:

„A web-writable PHP file is intentionally unusual. Keep backups and do not deploy it where policy or hardening rules forbid self-modifying code."

Und dann ist da OPcache. Das ist der Punkt, an dem ich am meisten gelernt habe, weil er auch ohne piplet gilt. __COMPILER_HALT_OFFSET__ wird zur Compile-Zeit als Literal in die Opcodes eingebacken. Solange sich nur die Daten hinter dem Marker ändern, bleibt das Präfix gleich lang und der gecachte Offset stimmt — deshalb ist die normale Speicherung harmlos, und deshalb ruft piplet nirgends opcache_invalidate() auf. Ändert sich aber die Länge des Code-Präfixes, zeigt der gecachte Offset ins Leere.

Ich habe den Fehlerpfad reproduziert. Eine Datei im piplet-Stil, zweimal im selben Request inkludiert, dazwischen mit längerem Präfix neu geschrieben:

1st include: off=144 data='PAYLOAD_ONE'
file rewritten. new size=189
2nd include: off=144 data='), $o)];\n__halt_compiler();PAYLOAD_TWO_LONGER'
TRUE offset on disk = 171, true payload = 'PAYLOAD_TWO_LONGER'

Der zweite Include liest Quelltext als Nutzdaten. Drei Befunde, die sich nicht raten lassen:

  • Ohne OPcache tritt es nicht auf. Es ist ausschließlich ein Cache-Effekt.
  • opcache.revalidate_freq=0 hilft nicht. Es sorgt zwar dafür, dass OPcache die Datei im selben Request erneut prüft — aber stat() liefert nur ganze Sekunden, und zwei Schreibvorgänge innerhalb derselben Sekunde sehen für den Cache identisch aus.
  • Der Default opcache.file_update_protection=2 verdeckt den Fehler, weil eine frisch geschriebene Datei gar nicht erst gecacht wird. Der Bug schlägt erst zu, wenn die Datei älter als zwei Sekunden ist — also im Produktivbetrieb, nicht im schnellen Test.

Und die Invalidierung selbst hat eine Falle, die ich so nicht kannte:

Aufruf Rückgabe Ergebnis
(keiner) korrupt
opcache_invalidate($f, false) true korrupt trotz true
opcache_invalidate($f, true) true korrekt

Die nicht-erzwungene Variante meldet Erfolg und tut nichts. Ursache ist die mtime-Auflösung: PHPs stat() liefert Sekunden ohne Bruchteile — zwei Schreibvorgänge eine Millisekunde auseinander haben dieselbe mtime, die Datei gilt nicht als „newer". Nach jeder Code-Änderung an einer Datei, die zur Laufzeit geschrieben wird, ist force = true zwingend. Das gilt für jedes Template-Cache- und Codegen-Szenario, nicht nur hier.

Zum Vergleich, damit die Einordnung fair bleibt: Frameworks schreiben ständig Code zur Laufzeit. Blade kompiliert Templates zu PHP, Symfony generiert seinen Container, WordPress aktualisiert sich selbst seit 2013. Die technischen Unterschiede sind aber benennbar und alle vier dokumentiert: Diese Kompilate liegen außerhalb des Document Root; sie sind deterministisch aus Quelldateien rekonstruierbar, Löschen kostet Performance und keine Daten; sie lassen sich zur Build-Zeit vorwärmen, sodass Read-only-Betrieb möglich ist; und WordPress-Auto-Updates schreiben nur dann direkt, wenn der PHP-Prozess selbst Eigentümer der Kern-Dateien ist — get_filesystem_method() vergleicht dafür fileowner(__FILE__) mit dem Eigentümer einer frisch angelegten Temp-Datei; passt das nicht, weicht WordPress auf FTP oder SSH aus. Bei piplet ist die Schreibdatei die per URL angefragte, sie ist die einzige Kopie des Inhalts, und der Schreibvorgang ist der Normalbetrieb.

Zwischenfazit: Die Einfachheit liegt im Artefakt, nicht im Betrieb. Eine Datei kopieren ist einfach; die Origin, den Proxy, die Rechte, die Backups und die OPcache-Disziplin drumherum aufzubauen, ist es nicht. Das ist keine Schwäche der Umsetzung — es ist der Preis dafür, dass Code und Daten dieselbe Inode teilen.

Was davon in eigene Projekte passt

Hier ist die Ernte. Neun Muster, die nichts mit selbstmodifizierendem PHP zu tun haben, jeweils mit der Frage, wann sie sich lohnen und wann sie überzogen sind.

1. Temp-Datei + fsync + rename statt Direktschreiben. Lohnt sich bei jeder Datei, die im Betrieb ersetzt wird und deren halber Zustand schadet: Konfiguration, Caches, Exporte, Zustandsdokumente, generierte Assets. Überzogen bei Append-only-Logs, wo ein abgeschnittener letzter Eintrag verschmerzbar ist.

2. Sperren gegen die Identität validieren, nicht gegen den Pfad. Sobald irgendwo im System per rename ersetzt wird, ist eine Sperre auf dem ersten geöffneten Deskriptor wertlos. fstat gegen stat vergleichen und wiederholen. Überzogen, wenn nur ein einziger Prozess je schreibt — aber genau das weißt du selten sicher.

3. Randomisierter Backoff mit gemeinsamer, monotoner Deadline. hrtime(), nicht time() — NTP-Sprünge und Zeitumstellung dürfen eine Deadline nicht verschieben. Und die Deadline einmal vor der Schleife berechnen statt pro Versuch, sonst ist sie keine.

4. Kosten ausrechnen, bevor du sie verursachst — und das Modell auditieren. Die Größenprojektion mit anschließendem Vergleich gegen das echte Ergebnis ist das übertragbarste Detail des ganzen Programms. Lohnt sich überall, wo eine harte Obergrenze existiert und der teure Weg schon Ressourcen kostet. Überzogen bei weichen Grenzen, wo ein Abbruch nach der Tatsache genügt.

5. Billige, allokationsfreie Vorprüfung vor dem Parser. Der Parser bleibt die Autorität für die Grammatik; der Scanner beantwortet nur „darf ich das überhaupt anfassen". Lohnt sich an jeder Grenze, an der fremde Daten in einen Parser gehen. Überzogen bei Daten aus dem eigenen Build.

6. Optimistische Nebenläufigkeit mit Zufallsversion pro Fassung. Zeitstempel kollidieren, Zähler kehren nach einem Restore zurück, ein 128-Bit-Zufallswert nicht. Zusammen mit einem stabilen Idempotenz-Schlüssel auf Client-Seite deckt das die zwei häufigsten Mehrbenutzer-Fehler ab. Überzogen bei Single-Writer-Systemen.

7. Artefakte, die sich selbst erkennen. str_contains(basename(__FILE__), '.piplet-tmp-') macht jede versehentlich ausgeführte Zwischenkopie zu einer 503-Antwort statt zu einer zweiten Anwendungsinstanz. Lohnt sich bei jedem temporären Artefakt im ausführbaren Pfad. Überzogen, wenn das Artefakt strukturell außerhalb liegt — dann ist der Ort die bessere Antwort als der Name.

8. Aufräumen nur, was du selbst identifiziert hast. Vor dem Löschen dev/ino gegen den gemerkten Zustand prüfen; passt es nicht, loggen statt löschen. Das kostet fünf Zeilen und verhindert die Sorte Fehler, über die später Post-mortems geschrieben werden.

9. Fehlgeschlagene Zusicherungen in Verweigerungen übersetzen. Kann die Sicherung nicht bestätigt werden, bleibt der Editor offen und sagt warum — statt optimistisch weiterzumachen. Lohnt sich, wo ein stiller Verlust unbemerkt bliebe. Überzogen bei Aktionen ohne Verlustrisiko wie Sortierung oder Filter, wo die Verweigerung nur Reibung erzeugt.

Und wo trägt die Grundidee selbst?

Getrennt davon die Frage nach der Bauform „Programm und Daten in einer Datei". Sie trägt dort, wo das Deployment das eigentliche Problem ist und die Datenmenge klein bleibt: ein internes Notizwerkzeug auf einer Maschine ohne Datenbank, ein Wegwerf-Werkzeug, das per scp ankommt und nach zwei Wochen wieder verschwindet, ein Lehrbeispiel. Sie trägt nicht bei mehreren Schreibern, geclustertem Hosting, containerisierten Dateisystemen oder allem, was Änderungserkennung braucht.

Es gibt zu dieser Frage eine bemerkenswerte Antwort aus dem Projektumfeld selbst. Der einzige Fork mit eigener Substanz — kasparsd/piplets (öffnet in neuem Tab), entstanden am Tag meiner Recherche — schreibt in seinem plan.md unter „Research findings (verified, not assumed)":

„Does the data need to live inside the PHP file? No. The data section is pure JSON — the __halt_compiler() boundary existed only because data shared the file with executable code. Once external, it is a plain .json document (validated: format 2, bounded structure, lossless numbers, unique members)."

Eines von fünf erklärten Zielen dieses Forks, dessen Hauptziel eine gebündelte CLI-Executable ist: „Separates code from data: the engine stays immutable; the document is a portable pure-JSON file." Der erste Nachahmer mit eigener Substanz hebt also ausgerechnet das Merkmal auf, das piplet definiert — und behält alles andere: die atomare Persistenz, die Validierung, die Konfliktbehandlung.

Das halte ich für die ehrlichste Zusammenfassung, die dieses Projekt bekommen konnte. Der Wert steckt nicht in der Grenze, die eingerissen wird. Er steckt in der Sorgfalt, mit der die Folgen aufgefangen werden — und die ist vollständig übertragbar auf Programme, die diese Grenze respektieren.

Fazit

Ich bin heute über ein Repository gestolpert, dessen Grundidee ich für eine Kuriosität hielt, und habe den Tag mit einem Notizbuch voller Muster beendet, die ich weiterverwenden werde.

Die Grundidee selbst ist die am wenigsten interessante Schicht. „Daten hinter __halt_compiler()" sind vier Zeilen, und die 78-Zeilen-Demo zeigt sie vollständig. Alles Interessante entsteht erst aus der Frage, was passiert, wenn diese vier Zeilen im echten Betrieb überleben sollen: Zwei Schreiber gleichzeitig. Ein Stromausfall in der falschen Millisekunde. Ein Backup, das zwei Wochen alt ist. Ein Browser, der setItem stillschweigend verschluckt. Ein JSON-Body mit 2,6 Millionen Einsen darin.

Die 3.506 Zeilen Differenz zwischen der kleinen und der großen Fassung sind fast vollständig Antworten auf diese Fragen — und sie sind es, die den Quelltext lesenswert machen, unabhängig davon, ob dich Wikis interessieren oder WordPress oder PHP. Es ist selten, so konzentriert nachlesen zu können, was Dauerhaftigkeit im Detail kostet.

Was ich an der Sache am meisten mag, ist die Ehrlichkeit des README. Es benennt die Grenzen seines eigenen Ansatzes deutlicher als jede Berichterstattung darüber — bis zu dem Satz, dass gegen einen exakten Rollback nur „trusted state outside this one file, such as an append-only log or database" hilft. Ein Projekt, das mit „keine Datenbank" antritt und den einen Fall dokumentiert, in dem es eine bräuchte, hat meinen Respekt.

Ein letzter Gedanke, der zu diesem Blog passt. Ich habe hier oft darüber geschrieben, dass Dateien in einem Verzeichnis die stabilste Grundlage sind, die ein Projekt haben kann — weil du sie wieder mitnehmen kannst. piplet stellt diese Idee auf die Spitze: Es gibt nur noch eine Datei, und sie enthält alles. Der Fork daneben zieht die Konsequenz und trennt wieder. Zwischen diesen beiden Punkten liegt genau die Entscheidung, die du bei jedem System triffst, das Zustand hält — nur selten so klar zu besichtigen wie hier.

Weiterlesen