Angefangen hat es damit, dass mir ein LLM-Agent die Grenzen dokumentiert hat, statt sie zu bauen. Die LLM hat korrekte Kommentare für Parallel Prozesse geschrieben, das Problem waren jedoch nicht Kommentare sonder das Kommentare nichts durchsetzen können.

Der LLM hat korrekte Kommentare über Nebenläufigkeit geschrieben. Das Problem war nicht die Korrektheit — es war, dass Kommentare nichts erzwingen können.

Es ist um Nebenläufigkeit gegangen, und herausgekommen ist ein Kommentar: welcher Aufruf aus welcher Goroutine kommen darf, was vorher gesperrt sein muss, worauf man aufpassen soll. Alles richtig. Alles freundlich. Alles nur Text.

Eine Grenze, die in einem Kommentar steht, ist keine Grenze. Sie ist eine Bitte. Sie hält so lange, wie der Kommentar gelesen und behalten wird. Um drei in der Früh behalte ich ihn nicht.

Das ist keine Eigenheit von Agenten. Jede Go-Codebase ist voll davon — „not safe for concurrent use", „caller must hold the lock". Ich habe solche Zeilen selber geschrieben. Aber es hat mich zum ersten Mal richtig gestört, und danach habe ich lange darüber nachgedacht, wie ein Core ausschaut, in dem man diese Kommentare gar nicht braucht.

Mit mehr Disziplin geht sich das nicht aus. Was sich ausgeht, ist eine Form, in der es den Weg zu fremden Daten schlicht nicht gibt. Dann braucht auch keiner einen Kommentar, der ihn verbietet.

In dem, was dabei herausgekommen ist, steht kein einziges sync.Mutex. Das war mir wichtig, und darum geht es hier.

Was da überhaupt läuft

incus-compose hat mittlerweile einen DNS-Server dabei. Der holt sich seine Records aus Incus und hält sie über den Event-Stream aktuell. Darunter liegt ievent: die Kette, durch die die Events von incusd laufen.

incusd → source → debounce → log → enricher → dns → http → log

source liest den Stream. debounce fasst Bursts zusammen. enricher fragt bei incusd nach und füllt das Event mit dem auf, was der Daemon dazu weiß — so eine Rückfrage nenne ich ab jetzt einen Read. dns baut daraus Antworten. http macht /ready und Metriken. log schreibt mit.

Die Reihenfolge ist einkompiliert. Es gibt ein --exclude, aber das darf nur Positionen wegnehmen, die das Binary als optional markiert hat, und welche das sind, entscheidet kein Deployment.

Bis hierher ist das eine gewöhnliche Middleware-Kette mit anderen Namen.

Ein Plugin

type Plugin interface {
	// Name benennt das Plugin, im Log, in den Metriken und in der Kette.
	Name() string

	// Wants sagt, welche Actions dieses Plugin interessieren.
	Wants() []Want

	// Setup verdrahtet das Plugin, einmal, bevor irgendetwas läuft.
	Setup(args SetupArgs) error

	// Handle bekommt ein Event.
	Handle(ev *Event)
}

Vier Methoden. Die letzte ist die interessante, und was in SetupArgs steckt, ist noch interessanter:

// Next ist das Handle des Nachfolgers — das einzige Feld, das sich pro
// Position unterscheidet.
Next Next

next ist das Handle vom nächsten Plugin. Kein Kanal, kein Bus, keine Registry. Eine Funktion, die ich aufrufe, wenn ich fertig bin.

Jedes Glied hält seinen Nachfolger selber und läuft selber weiter. Deshalb darf ein Plugin vor und nach next arbeiten — dieselbe Form wie eine Kette von HTTP-Handlern.

Der Briefkasten

Jetzt der Teil, der anders ist.

// Handle läuft in der Goroutine des Vorgängers und darf nicht blockieren:
// es legt ab und kehrt zurück. Next wird aus der eigenen Goroutine gerufen,
// wenn die Arbeit getan ist.

Handle ist die Briefkastenklappe. Sie läuft in der Goroutine von dem, der sie aufgerufen hat. Also darf sie nichts machen, was dauert. Sie wirft das Event ein und geht.

Das Event selber ist unveränderlich. Jedes With… gibt eine Kopie her, statt dasselbe Stück Speicher anzugreifen. Deshalb ist es harmlos, wenn zwei Goroutinen denselben Zeiger in der Hand haben, und die Frage, wem ein Event grad gehört, stellt sich erst gar nicht.

Bei debounce schaut das so aus:

func (p *Plugin) Handle(ev *shared.Event) {
	select {
	case p.inbox <- ev:
	default:
		p.next(ev.WithDropped(name))
	}
}

Zwei Zeilen Logik. Geht es sich aus, ist es drin. Geht es sich nicht aus, läuft es weiter — mit einem Vermerk, wer es fallen gelassen hat.

Die eigentliche Arbeit passiert woanders:

// Run hält Events, bis es fertig werden soll. Run blockiert, also gehört die
// Goroutine main, und Run kehrt zurück, wenn alles weitergereicht ist.
func (p *Plugin) Run(ctx context.Context) error {
	open := map[string]*burst{}

	// sweeping steht zwischen den Klammern einer Bestandsaufnahme.
	sweeping := false

	for {
		var due <-chan time.Time

		at, ok := earliest(open)
		if ok {
			due = time.After(time.Until(at))
		}

		select {
		case <-ctx.Done():
			return nil

		case cmd := <-p.in:
			p.drain(open, sweeping)
			p.closeAll(open)
			p.answer(ctx, cmd)

			return nil

		case <-due:
			p.closeExpired(open)

		case ev := <-p.inbox:
			sweeping = p.accept(open, ev, sweeping)
		}
	}
}

Ein for mit einem select. Der Briefkasten, ein Timer, ein Kommandokanal, ein Abbruch. Mehr ist es nicht.

Und irgendwann ruft dieses Plugin dann next. Aus seiner eigenen Goroutine. Heißt: es legt das Event in den Briefkasten vom nächsten Plugin und geht zurück an seine Arbeit.

Die Kette ist als Aufrufstapel geschrieben und läuft als Fließband. In der ganzen Schnittstelle steht kein Kanal.

Später weitergeben

Das ist es, was der Briefkasten kauft.

debounce fasst einen Burst auf einem Key zu zwei Events zusammen: das erste geht sofort, das letzte geht, wenn der Key eine Weile ruhig war. Alles dazwischen wird überholt. Ein Burst kostet damit zwei Reads statt einen pro Event.

„Eine Weile" sind 250 Millisekunden. Da ist Handle längst zurück. Der Vorgänger hat weitergemacht, source liest den Stream weiter, und irgendwann läuft der Timer ab und debounce gibt das letzte Event her — eine Viertelsekunde, nachdem es angekommen ist.

Eine synchrone Kette kann das nicht. Die kann warten, aber sie wartet dann mit dem Aufrufer mit. Ein Glied, das 250 Millisekunden schläft, hält alles auf, was hinter ihm steht.

Außer während einer Bestandsaufnahme

Es gibt einen Fall, in dem debounce nichts zurückhalten darf.

In Abständen liest der enricher die ganze Flotte. Die Klammern darum mintet die source: der enricher meldet nur, die source macht ein Event daraus und legt es vorne in die Kette. Daher die Namen, source/sweep-start und source/sweep-end.

Das ist kein Aufräumen nebenbei, das ist der Weg zurück. Ein Verbindungsabriss ist der offensichtliche Fall, aber sie läuft auf einem Timer wegen dem weniger offensichtlichen: ein Event, das nie angekommen ist, lässt nichts zurück, woran man es merken könnte. Nur ein voller Read erwischt einen Übergang, von dem keiner erzählt hat.

Und sie funktioniert über Abwesenheit:

Ein Name, den ein Plugin hält und zwischen den beiden Klammern nicht sieht, ist weg.

Damit das geht, faltet dns die Runde in einen zweiten Snapshot, den noch keiner sieht, und tauscht ihn bei source/sweep-end gegen den bisherigen. Bis dahin beantwortet es jede Frage weiter aus dem alten. Eine halb gelesene Flotte geht nie an einen Client, und „grad keine Antwort" gibt es nicht.

Der Inhalt der Runde nimmt einen anderen Weg als die Klammern. Was der enricher gelesen hat, schiebt er direkt nach vorne weiter, an debounce vorbei — derselbe Weg wie beim Fan-out weiter unten. Das ist es, was die Runde ganz lässt: sie besteht aus lauter instance-updated, und das ist eine Action, die debounce zusammenfassen dürfte. Sie kommt nur nie dort an.

Die Klammern müssen den ganzen Weg herum, aus dem umgekehrten Grund. debounce sitzt vor dem enricher, und es muss auf sie reagieren:

case shared.ActionSweepStart:
	p.closeAll(open)
	p.next(ev)

	return true

Alles Gehaltene geht raus, dann die Klammer. Und zwischen den Klammern wird nichts mehr zusammengefasst:

collapse := ev.State() == shared.StateOk &&
	!sweeping &&
	p.wanted[ev.Action()].Debounce

Die zwei zusammen sorgen dafür, dass nichts, was debounce hält, quer über einer Klammer liegt.

Nicht weil die Daten schlecht würden. Ein gehaltenes Event geht auf dem Weg hinaus noch durch den enricher, und der liest incusd in dem Moment — was bei dns ankommt, ist also aktuell, egal was am Zeitstempel steht. Verloren geht auch nichts: dns faltet die Runde und den Stream in dasselbe Set, und die schließende Klammer veröffentlicht, was drin ist.

Was die zwei Regeln bringen, ist eine saubere Reihenfolge. Zwischen den Klammern läuft, was es gibt, und nichts von vorher hängt noch in der Luft. Eine langweilige Zusage, und die Runde ist auf ihr gebaut.

Und eine Runde ist die Stelle, an der zwei der Wege hinein zusammenkommen. Die Klammern gehen herum an den Kopf, weil ein Plugin davor auf sie reagieren muss. Der Inhalt geht nach vorne weiter, weil genau dieses Plugin ihn zusammenfassen würde. Eine Operation, beide Wege, jeder danach ausgesucht, was er von der Reihenfolge braucht. Der dritte Weg kommt weiter unten: ein Kommando, dem die Reihenfolge wurscht ist, und das genau deshalb an allem vorbeidarf.

Wenn der Briefkasten voll ist

Der default-Zweig von vorhin ist die ehrliche Stelle. Irgendwann ist jeder Puffer voll, und dann muss wer entscheiden, was passiert.

Hier entscheidet das Plugin, das voll ist. Mit seinem eigenen Namen dran. Das Event verschwindet nicht, es läuft weiter durch die Kette und trägt den Vermerk mit — StateDropped heißt „damit fertig, läuft weiter, damit die Beobachter dahinter es sehen". Für log also, und für die Metriken. Wer dort dropped mit einem Namen daneben sieht, weiß, welches Plugin zu klein dimensioniert ist. Mehr soll der Vermerk nicht leisten.

WithDropped schreibt dazu, wer es war, und ist danach ein No-Op. Wer ein Event zuerst fallen lässt, bleibt der, der drinsteht.

Plugins, die etwas tun, überspringen so ein Event:

func (p *Plugin) Handle(ev *shared.Event) {
	if ev.State() != shared.StateOk {
		p.next(ev)

		return
	}
	// ...
}

Und da muss ich jetzt eine Ausnahme von allem machen, was weiter oben steht: diese vier Zeilen sind eine Konvention. Sie stehen als Kommentar im Interface, und wer sie vergisst, arbeitet auf einem Event, mit dem längst wer fertig war. Eine Form, die das unmöglich macht, habe ich nicht gefunden, ohne die vier Methoden aufzublähen.

Eine Grenze in einem Kommentar. Genau die Sorte, über die ich mich am Anfang beschwert habe. Und die einzige ist sie nicht geblieben: bei Value steht die zweite, mit Ansage — „kein Typ kann diese Regel tragen, deshalb steht sie hier". Ehrlich, und es ärgert mich trotzdem.

Ein voller Briefkasten meldet sich also mit Namen. Und ein debounce, das zweihundert Events zu zweien zusammenfasst, macht genau dasselbe:

// Innerhalb eines offenen Fensters. Das erste Event hier hat nichts zu
// überholen.
if b.ev != nil {
	p.next(b.ev.WithDropped(name))
}

Es verschwindet also gar nichts. Jedes überholte Event läuft weiter durch die Kette, mit demselben Vermerk wie ein fallengelassenes, und die Beobachter dahinter sehen es. Zusammengefasst werden nicht die Events, sondern die Reads — der enricher schlägt für ein Event, mit dem schon wer fertig ist, gar nicht mehr nach. Und zweihundert Reads auf dasselbe Objekt geben ohnehin das her, was der letzte auch hergibt.

Gehalten werden instance-updated und network-updated, also das, was sich wiederholt. Start, Stop, Delete und Rename laufen sofort durch, und bei Umbenennungen steht der Grund daneben: zwei zusammengefasst behalten den letzten OldName und verlieren den mittleren, der Record darunter würde nie verschwinden. Ein gehaltenes Event kann deshalb nie eine Nachricht über Existenz sein.

Jedes Plugin legt seine Briefkastengröße selber fest. debounce nimmt 1024, „passend zum Event-Kanal des Incus-Clients". Das ist kein zentraler Wert in irgendeiner Config. Wer voll läuft, weiß am besten, wie viel Luft er braucht.

Deshalb steht in der Schnittstelle Handle und kein Kanal. Ein Kanal hätte die Entscheidung nach außen gelegt.

Die meisten haben gar keinen

log hat keinen Briefkasten, keine Goroutine, kein Run. Sein Setup merkt sich den Nachfolger und startet nichts, Handle schreibt die Zeile und ruft next direkt auf, in der Goroutine des Aufrufers.

Von außen schaut das gleich aus. Wer next aufruft, weiß nicht, ob er grad eine Goroutine-Grenze überschritten oder eine Funktion aufgerufen hat, die sofort zurückkommt. Beides ist next(ev).

Das ist der Teil, bei dem ich hängengeblieben bin. Dieselbe Signatur trägt beides: das Plugin, das ein Event eine Viertelsekunde festhält, und das, was es durchreicht, ohne stehenzubleiben.

Und deshalb kein Mutex

Jetzt der Teil von ganz oben.

Alles außer dem Briefkasten gehört der Goroutine, die Run besitzt.

Ein Besitzer pro Plugin. Die open-Map in debounce greift sonst keiner an. Der Briefkasten ist das einzige, was geteilt wird, und der ist ein Kanal — der synchronisiert selber.

Genau darauf war ich aus. Auf die Kette nicht, die habe ich eh schon gekannt. Auf ein Fundament, auf dem kein sync.Mutex mehr nötig ist, weil es nichts mehr gibt, um das sich zwei Goroutinen streiten könnten. Jede besitzt ihre Daten, und was zwischen ihnen hin und her geht, geht durch einen Kanal.

Der Briefkasten ist die Konsequenz daraus, nicht der Ausgangspunkt. Wenn ein Plugin seine Daten allein besitzen soll, dann darf keiner sonst hineingreifen — also muss ihm wer etwas schicken, statt es ihm hinzulegen. Genau das macht Handle.

In einer Kette, die in einem Worker-Pool läuft, geht sich das nicht aus. Da hat kein Glied eine eigene Goroutine, jedes borgt sich eine, und mit ihr teilt es alles, was ein anderes auch angreift. Dann hilft nur mehr ein Lock.

Dieselbe Form, ein anderes Fundament, und das Mutex ist weg. „Wem gehören diese Daten?" ist die unangenehmere Frage als „wo kommt das Lock hin?", und sie ist genau einmal unangenehm, beim Entwurf. Danach steht die Antwort in der Form, und es bleibt nichts zu merken.

Was jeder will, vorher

Eine Sache habe ich dazubauen müssen, die eine gewöhnliche Kette nicht braucht.

Wants() ist von Setup() getrennt, und das hat einen Grund. Der enricher bedient die ganze Kette aus einem einzigen Read. Er muss also wissen, was alle wollen, bevor er das erste Mal liest — und zwar vollständig.

Würde die Tabelle beim Verdrahten entstehen, kriegte jedes Plugin den Teil der Vereinigung, der zufällig schon da war, als sein eigenes Setup gelaufen ist.

Also fragt die Kette erst alle, dann verdrahtet sie. Jedes Plugin kriegt dieselbe fertige Tabelle.

Die beiden Felder falten in entgegengesetzte Richtungen und meinen dasselbe. Bei Enrich gewinnt mehr: Wenn irgendwer eine Action angereichert haben will, wird sie angereichert. Bei Debounce gewinnt false — „der Nullwert legt für alle ein Veto ein".

Ein Plugin, das nichts sagt, kriegt also keine Anreicherung und verhindert jedes Zusammenfassen. Beide Male fällt die Entscheidung auf mehr Arbeit und weniger Risiko, und beide Male ist der Nullwert die sichere Seite.

Das ist der Preis einer einkompilierten Kette. Sie muss ihre eigenen Glieder fragen, bevor sie sich bauen kann.

Zwei Kanäle

Neben dem Briefkasten hat jedes Plugin noch zwei Kanäle. Einen rein, einen raus, beide für Kommandos. Der eingehende ist ungepuffert, mit Begründung: „ein Slot würde die Quelle ein Plugin fragen lassen, das nicht zuhört, und sie würde glauben, sie sei gehört worden."

Vorher war das ein Funktionsaufruf. Zwei Kanäle pro Plugin sind daraus geworden, und die zwei Richtungen ziehen mit Absicht auseinander.

Rein: an der Schlange vorbei. Wenn der Prozess fertig werden soll, geht source/drain an jedes Plugin — auf dem eigenen Kanal, „egal wie der Event-Briefkasten grad ausschaut". Ein Kommando, das im vollen Briefkasten hinter zehntausend Events warten müsste, wäre nutzlos.

Drain geht die Plugins einzeln durch, in Kettenrichtung, und wartet auf die Antwort, bevor es das nächste fragt. Wer wen füttert, wird zuerst gefragt.

Gefragt werden muss nur, wer etwas halten kann. log hält nichts: es hat seine Arbeit erledigt, bevor next zurückgekommen ist, und ist damit fertig, bevor die Frage überhaupt gestellt wird. Die Quelle kommt selber drauf:

// Ein Plugin ohne Goroutine ist fertig, bevor es anfängt — entschieden
// dadurch, was es ist, statt dadurch, woran main sich erinnert.
_, runs := p.(interface{ Run(context.Context) error })
if !runs {
	close(pl.done)
}

Kein Flag, keine Liste, keine Konfiguration. Eine Type-Assertion darauf, ob das Plugin ein Run hat.

Bei allen anderen kommt die Antwort erst, wenn wirklich alles weitergereicht ist:

// Beantwortet erst, wenn alles weitergereicht ist: die Quelle fragt das
// nächste Plugin, sobald dieses antwortet.
p.answer(ctx, cmd)

Und ein Plugin antwortet mit derselben Action zurück, „auch für Kommandos, die es nicht kennt". Ein unbekanntes Kommando quittiert also, statt das Herunterfahren hängen zu lassen.

Raus: zurück in die Schlange. Wenn ein Plugin selber etwas zu sagen hat — dns meldet, dass es jetzt etwas Vernünftiges zum Antworten hat — geht das nicht als Broadcast an alle.

Die Quelle macht ein Event daraus und legt es vorne in die Kette. Es läuft von Position eins durch, wie jedes andere Event auch, „damit es jede Position erreicht und in der Reihenfolge gegenüber den Events, die es ausgelöst haben".

Das ist der Punkt. Ein Seitenkanal, der an der Schlange vorbeigeht, geht auch an der Reihenfolge vorbei. Ein ready, das die Events überholt, die es ausgelöst haben, sagt die Unwahrheit.

Also darf genau das an der Schlange vorbei, dem die Reihenfolge wurscht ist: das Aufhören. Alles andere stellt sich hinten an.

Vorwärts: von hier aus. Es gibt einen dritten Weg hinein, und der ist der leiseste. Ändert sich ein Profil oder ein Netzwerk, müssen alle Instanzen darunter neu gelesen werden — das Event, das die Änderung gemeldet hat, nennt aber keine einzige davon. Der enricher erfindet deshalb pro betroffener Instanz ein instance-updated, das es so nie gegeben hat, und stellt es in seine eigene Reihe:

// Ein synthetisches instance-updated pro Stück, hier in die Reihe gestellt
// statt an den Kopf der Kette: es ist die eigene Arbeit dieses Plugins, und
// einmal herumgeschickt würde debounce den Satz auf das letzte zusammenfassen.

Von hinten schaut das aus wie jedes andere Update. Entscheidend ist, wo es hineingeht. Am Kopf der Kette liefe es noch einmal durch debounce, das vor dem enricher sitzt — und aus dem Satz würde eines.

Drei Wege also, und jeder ist danach ausgesucht, was die Reihenfolge bedeuten soll. An der Schlange vorbei, wenn sie wurscht ist. An den Kopf, wenn es auf die Reihenfolge gegenüber den Auslösern ankommt. Von hier aus weiter, wenn das, was davor sitzt, es nicht noch einmal angreifen darf.

Man sieht es an den Namen. source/sweep-start, source/connected, chain/ready, source/drain. Der Schrägstrich ist Absicht — eine Lifecycle-Action von Incus kann keinen enthalten, und das Präfix sagt, wer es in die Kette gestellt hat.

Das gilt für Steuerung. Datenevents bleiben ohne Präfix, auch die erfundenen: das synthetische instance-updated von vorhin heißt genauso wie eines von incusd. Für alles dahinter ist ja auch genau das passiert.

Kein init()

Das Übliche wäre eine Registry. Jedes Plugin bringt ein func init() mit, das sich beim Import irgendwo anmeldet, und ein Import mit _ sorgt dafür, dass es auch läuft.

Ich weiß ziemlich genau, wie das ausgeht. In den Plugins von go-orb stehen 42 davon.

func init() {
	registry.Plugins.Add(Name, Provide)
}

Zweiundvierzig Mal meldet sich etwas selber an, während das Programm noch startet. An einer Stelle so:

func init() {
	if err := source.Plugins.Add(New()); err != nil {
		panic(err)
	}
}

Ein Panic, bevor main überhaupt läuft. Wenn das feuert, gibt es keine Stelle, an der man es abfangen könnte, und keinen Aufrufer, dem man es melden könnte.

Das Unangenehme daran ist nicht die Zeile. Es ist, dass man den Code lesen kann und trotzdem nicht weiß, was im Prozess steckt. Was drin ist, steht in einer Import-Liste. Verdrahtet wird als Nebenwirkung. Um herauszufinden, warum sich etwas so verhält, muss man rückwärts nach dem Paket suchen, das es mitgebracht hat.

In ievent gibt es kein einziges init() und keinen Import mit Unterstrich. Ich brauche keins.

Die Kette ist ein Slice. chain() gibt her, was drinsteht, in der Reihenfolge, in der es drinsteht. Man liest eine Funktion und weiß, was der Prozess macht. Und weil es gewöhnlicher Go-Code ist statt einer generierten Tabelle, darf dasselbe Plugin auch zweimal drinstehen — log einmal vorne, einmal hinten, und dazwischen liest man ab, was eine Position gekostet hat.

Es ist wieder dieselbe Grenze wie ganz am Anfang. Was der Prozess macht, soll man lesen können, statt es sich zusammensuchen zu müssen.

Plugins vertrauen einander

Ein Satz steht im Interface, und der ist mir wichtig:

Plugins vertrauen einander: hier verteidigt sich keines gegen ein anderes, und ein Panic nimmt den Prozess mit. Wenn ein Vorschlag nur gegen ein Plugin Sinn ergibt, das sich schlecht benimmt, wird er nicht gebaut.

Das ist kein Leichtsinn, das ist eine Grenze. Innerhalb eines Binaries habe ich alles selber geschrieben. Ein Plugin, das sich schlecht benimmt, ist ein Bug, und Bugs repariert man, statt Wachtürme drumherum zu bauen.

Über eine Prozessgrenze hinweg ist es genau umgekehrt. Der Caddy-Sidecar, den ich grad plane, hält kein Zertifikat. Der Prozess, der ins Internet schaut, hat nichts, was man ihm wegnehmen könnte, und der, der die Incus-Verbindung hält, hört auf keinem öffentlichen Port.

Innen volles Vertrauen, außen keins. Beides mit Absicht.

Was am Ende nicht dasteht

Die Kette habe ich mir nicht ausgedacht. Ein Glied hält seinen Nachfolger und reicht weiter — das steckt hinter jeder Middleware und hinter defer, und es hat auf mich gewartet.

Gesucht habe ich das, was darunter liegt. Ein next, das etwas ablegt und geht, statt zu arbeiten. Ein Besitzer pro Datensatz. Ein Briefkasten als einzige Tür.

Was dabei herauskommt, schaut man sich am besten daran an, was im Code fehlt.

Kein sync.Mutex. Kein init(). Kein Import mit Unterstrich. Keine Registry, in der sich Dinge selber eintragen.

Zwei Kommentare, die um etwas bitten, sind übrig. Sie stehen weiter oben, ich habe für beide keine Form gefunden, und sie ärgern mich nach wie vor. Der Rest der Liste steht.

Ein neues Plugin ist eine Struct mit vier Methoden und einer Zeile in der Liste. Wo es sitzt, geht es nichts an. Wer voll läuft, entscheidet selber.

Boring. Schon wieder. Genau so soll das sein.


Von René Jochum - LLM Assistiert. Lizenz: CC-BY-4.0.