Ein Terminal im Echtbetrieb – Die Widrigkeiten nach dem Launch
Relay im Echtbetrieb: Memory Leaks in SwiftTerm, undokumentierte APIs von Dritthersteller-Tools und was das Skalieren einer Workspace-App wirklich kostet.
Der erste Artikel endete damit, dass Relay „fertig" war. Das war naiv. Fertig bedeutete: Der Core funktioniert, der nächste Entwickler kann es starten und hat ein Terminal. Was ich nicht miteinberechnet hatte: Der erste Entwickler, der es wirklich benutzt, wird es innerhalb einer Woche in Situationen bringen, die der Unit-Test nicht deckt.
Das ist die Geschichte davon, was nach dem Launch passiert ist.
Widrigkeit 8: Speicher wächst und wächst und hört nicht auf
Die erste echte Nutzerin war eine Kollegin. Sie arbeitete damit wie ich – Terminal offen, gelegentliche lange-Befehle, viel Scroll-Back. Nach zwei Stunden waren 2 GB RAM weg. Nach vier Stunden sperrte sich die ganze Kiste auf.
Das erste, das ich dachte: „Das ist SwiftTerm. Das hätte der Autor bugfixen sollen." Das zweite, das ich tat: instruments öffnen. Das dritte, das ich erkannte: Der Bug war völlig vorhersehbar, und ich hätte ihn mich selbst finden müssen.
SwiftTerm speichert den gesamten Terminal-Scroll-Back im RAM. Wenn du tail -f auf eine große Log-Datei laufen lässt oder in eine bunte Test-Suite schaust, die hunderte Zeilen pro Sekunde druckt, wächst das Array, das die Zeilen hält. Das ist eigentlich nötig – wie sonst würde man scrollen? Nur: Die App speichert das Zeug und gibt es nie wieder frei.
Warum? Weil SwiftTerm sein buffer-Property nie kürzt. Eine echte Terminal-App (iTerm2, Alacritty) hat einen Maximum-Scroll-Back – sagen wir 50.000 Zeilen – und wirft ältere Zeilen weg, sobald das Limit überschritten wird. SwiftTerm macht das nicht. Die Quelle-Bibliothek war für die iPad-Variante konzipiert, wo eine Shell ohnehin kurz lädt und stirbt. Für einen Long-Running-Terminal ein Problem.
Meine Lösung war nicht elegant, aber sie funktioniert:
// Nach der Datenverarbeitung
func trimBuffer(maxLines: Int = 10000) {
let current = terminalView.terminal.buffer.lines.count
if current > maxLines {
let toRemove = current - maxLines
// Achtung: SwiftTerm speichert den Buffer intern,
// direkte Mutation knackt die State-Invarianten
// Stattdessen: Einfach _nicht_ scrollen weit nach oben ✌
}
}
Moment, das funktioniert nicht? Richtig, das funktioniert nicht. SwiftTerm exponiert das Array nicht auf eine Weise, die man einfach kürzen kann. Die echte Lösung sieht so aus:
- Ich höre auf, SwiftTerm als Terminal zu benutzen – stattdessen nutze ich es nur zum Rendern.
- Den Scroll-Back halte ich selbst in einer ringförmigen
CircularBuffer<TerminalLine>aus der Foundation. - Wenn SwiftTerm eine Zeile braucht, frage ich meine Buffer ab, nicht seinen.
Das bedeutete ein Refactoring der ganzen Terminal-Daten-Seite – vier, fünf Stunden Code, Test, Verifikation.
final class RelayTerminalBuffer {
private var buffer: CircularBuffer<TerminalLine>
private let capacity: Int
init(maxLines: Int = 10000) {
self.capacity = maxLines
self.buffer = CircularBuffer(capacity: maxLines)
}
func append(_ line: TerminalLine) {
// Die älteste Zeile fällt automatisch raus
buffer.append(line)
}
func line(at index: Int) -> TerminalLine? {
return buffer[index]
}
}
Die Lektion: Die Abhängigkeit, die du für das Hauptproblem einsetzt, kann ein Haupt-Problem selbst sein. Code-Review wäre hier nicht geholfen – die Stelle, wo SwiftTerm undicht ist, ist völlig dokumentiert und völlig unvermeidbar, solange man SwiftTerm selbst nutzt. Man merkt es erst unter Last.
Widrigkeit 9: Der Terminal kann nicht länger als 30 Sekunden blockieren
Dazu kam ein anderes Pattern, das ich übersehen hatte: Ein Nutzer drückt eine Taste, und das ganze Interface friert für 30 Sekunden ein.
Was war passiert? Der Nutzer hatte einen rustc-Kompilier-Befehl laufen lassen, der alle Kerne maximal belastet. Rustc läuft im gleichen Prozess wie meine Relay-App – das ist normal, das ist das PTY-Konzept, so funktioniert macOS und Linux. Nur: Je sauberer dein Code ist, desto mehr scheißt du dich ein, wenn plötzlich ein Unterprozess die CPU monopolisiert.
Das UIThread-Modell von SwiftUI ist: Das Rendern muss jedes Frame schnell fertig werden. Mit 60 Hz / VSync braucht das UI-Rendering unter 16.6 ms. Mein Terminal-Render-Loop sah ungefähr so aus:
@MainActor
final class TerminalViewModel: ObservableObject {
func dataReceived(_ bytes: [UInt8]) {
// Zeichen parsen, Buffer updaten ...
self.objectWillChange.send() // SwiftUI re-render anstoßen
}
}
Das war der Fehler. SwiftUI wollte das Rendern sofort machen, aber wenn die Shell gerade den Prozessor vollgelastet hat, war kein Slice für UI-Rendering da.
Die Lösung: Debounce die Render-Updates. Sammle mehrere dataReceived-Aufrufe auf und bündele sie zu einem einzigen Re-Render:
private var renderDebounce: Timer?
@MainActor
func dataReceived(_ bytes: [UInt8]) {
buffer.append(contentsOf: bytes)
// Render nur alle 16 ms, maximum
if renderDebounce == nil {
renderDebounce = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: false) { [weak self] _ in
self?.objectWillChange.send()
self?.renderDebounce = nil
}
}
}
Jetzt könnte ich einen 30-sekündigen Kompilier-Prozess laufen lassen, und das Terminal schnell scrollen, ohne dass die App hängt. Der Scroll-Back wird gepuffert, die UI-Updates werden batched.
Die Lektion: Prozesse, die du startest, sind keine ausführlichen Quellen – sie sind Widerstände. Wenn eine Quelle zu schnell ist, musst du sie drosseln. Debouncing ist nicht nur für Netzwerk-Anfragen da.
Widrigkeit 10: Ein Spiel startet – und das Terminal bricht ab
Noch eine kuriosere Geschichte. Ein Nutzer schrieb: „Ich habe ein Spiel gestartet (Balatro, eine Roguelike), und das hat das Terminal kaputt gemacht. Das Spiel lief in einem neuen Fenster, aber mein Terminal hat dann Buchstabensalat angezeigt."
Moment, ein Spiel? Im Terminal?
Ja. Der Nutzer hatte das Spiel über ein Shell-Alias gestartet – etwas wie alias game='~/Games/balatro/balatro.bin'. Das Spiel fork()t sich selbst, lädt eine Grafik-Engine (vermutlich mit vollständigen TTY-Controls), und wenn es wieder zum Terminal zurückkehrt, ist das PTY in einem undefinierten Zustand.
Das ist eine echte Sache. Unix hat sich schon lange damit rumgeschlagen. Ein Spiel macht ein isatty(1) – ist stdout ein Terminal? – und wenn ja, setzt es die TTY in den „raw mode": Keine Zeilenbearbeitung, keine Echo, kein nichts. Das Spiel zeichnet direkt. Wenn das Spiel fertig ist, sollte es den Raw-Mode wieder ausschalten. Nur: Das tut es oft nicht sauber.
Noch vor meinem Code könnte ich auch versuchen, das Ganze elegant zu handhaben. Aber echte Terminals tun das auch einfach nicht – iterm2, kitty, alacritty, alle haben dasselbe Problem, und die Lösung ist immer: „Nutzer, wenn dein Spiel den Terminal kaputt macht, schreib reset ein."
Aber es war ein gutes Zeichen, dass mich jemand darauf hingewiesen hat. Ich habe eine Warnung eingebaut: Wenn das PTY in einen Zustand gerät, in dem Eingaben nicht echo’d werden, blendet Relay einen schwebenden Alert ein – „Terminal scheint im Raw Mode zu sein – reset eingeben?". Nicht elegant, aber praktisch.
private func detectStuckTerminal() {
// Sende ein Escape-Sequenz, die zwar druckt, aber keine Nebenwirkung hat
terminalView.sendToPty("\\033[5n".data(using: .utf8)!)
// Und überwache, ob wir DSR (Device Status Report) zurückbekommen
// Fehlt die Antwort, sind wir im Raw Mode
}
Die Lektion: Die Grenze zwischen „Terminal-App" und „dem Ökosystem, das sie hostet" ist nicht so sauber, wie man hoffen würde. Du wirst mit Bugs testen, die eigentlich nicht dein Bug sind.
Widrigkeit 11: Die Private API für „Fenster öffnen" existiert nicht
Die nächste Feature-Idee kam vom ersten Nutzer-Feedback: In den Relay-Einstellungen gibt es ein anderes Fenster (Preferences). Wenn das Nutzer diesen Einstellungs-Hotkey drückt, während das Einstellungs-Fenster schon offen ist, soll es in den Vordergrund kommen, nicht einfach nochmal öffnen.
Das klingt trivial. Ist es nicht.
In SwiftUI gibt es @State und @Environment für Single-View-State, und für Multi-Window-State gibt es… eigentlich nicht viel. Der Canonical Way ist:
@main
struct RelayApp: App {
var body: some Scene {
Window("Relay", id: "main") {
ContentView()
}
Window("Settings", id: "settings") {
SettingsView()
}
WindowGroup("Tools", id: "tools") {
ToolWindow()
}
}
}
Das ist deklarativ und nett. Nur: Es gibt keine offizielle API, um programmatisch ein Fenster in den Fokus zu schieben, das bereits offen ist. Apples eigene Apps machen das – Mail bringt die Preferences nach vorne, wenn du Cmd+, drückst und die Preferences sind schon offen. Aber die API dafür ist privat.
Meine Lösung:
#if os(macOS)
import AppKit
extension NSWindow {
func bringToFront() {
// Private API – aber funktioniert zuverlässig
self.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
}
#endif
// In der Hotkey-Handler:
if let settingsWindow = NSApplication.shared.windows.first(where: { $0.title == "Settings" }) {
settingsWindow.bringToFront()
} else {
// Fenster öffnen
}
Das ist keine Private API, der Code ist öffentlich. Aber dass dich Apple zwingt, so einen Umweg zu gehen, statt dir eine einfache openWindow(id: "settings") API zu geben, die genau das tut, ist frustrierend.
Die Lektion: Swift und SwiftUI sind jung. Sie haben Kanten. Du wirst auf Lücken treffen, wo es keinen schönen API-Weg gibt, und dann auf AppKit-Code ausweichen müssen.
Widrigkeit 12: Nicht alle Shell-Umgebungen emittieren OSC-Sequenzen
Ich dachte, die Shell-Integration wäre mit Widrigkeit 3 erledigt. Sie war nicht.
Ein Nutzer kam mit einem Setup: Er hatte bash im Container laufen, und der Container war nicht seine Maschine – das war ein Entwicklungs-Lxc, den sein Team managte. Seine Shell-Hooks (die, die ich eingebaut hatte) waren nicht installiert. Ergebnis: Relay sah den Exit-Code nicht, sah das aktuelle Verzeichnis nicht, sah gar nichts.
Ich konnte dem Nutzer nicht sagen „installiere meine Hooks". Die Infrastruktur gehört nicht ihm. Die Lösung war, auf einen Fallback-Mechanismus zu bauen: Wenn keine OSC-Sequenzen kommen, schniff die App trotzdem noch irgendwie sinnvolle Informationen aus dem raw Terminal-Output:
- Erkenne den Prompt-Pattern: Die meisten Shells zeigen den aktuellen Pfad im Prompt an. Ein Simple Regex kann das extrahieren.
- Schätze den Exit-Code: Wenn die Shell nach einem Befehl einen nicht-null Status hat, schreib sie oft
[exitcode]oder ähnliches. - Fallback auf das PTY selbst:
ioctl(TIOCGWINSZ)gibt dir Fenster-Dimensionen,getpwuid()gibt dir den Nutzer.
Keine dieser Techniken ist reliable – sie sind Heuristiken. Aber kombiniert geben sie dir genug Information, um die App brauchbar zu halten, auch wenn der Nutzer in einem Setup steckt, das deine Hooks nicht zahlen kann.
func parsePromptForWorkingDirectory(_ prompt: String) -> String? {
// Einfaches Pattern: `/home/user/path $ ` oder `user@host:~/path% `
let patterns = [
"^([/~][^\\s]*?)\\s+[#%$]", // Absolute path + prompt char
":[~]([^\\s]*?)\\s+[#%$]", // host:path format
]
for pattern in patterns {
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: prompt, range: NSRange(prompt.startIndex..., in: prompt)),
let range = Range(match.range(at: 1), in: prompt) {
return String(prompt[range])
}
}
return nil
}
Das war nicht super befriedigend – ich baue Heuristiken statt auf echte Signale zu verlassen. Aber es war besser, als dem Nutzer zu sagen „dein Setup ist nicht unterstützt".
Die Lektion: „Funktioniert mit Hooks" ist keine akzeptable User Experience, wenn nicht alle Nutzer Hooks installieren können. Du musst einen Fallback haben, der weniger schön ist, aber funkioniert.
Widrigkeit 13: macOS-Updates brechen deine Abhängigkeiten
Drei Wochen nach dem Launch kam macOS Sonoma 14.7. SwiftTerm war unter dieser Version nicht mehr zu kompilieren. Der Grund: Eine API-Änderung in CoreText, die Relay indirekt nutzte – NSAttributedString.init(attributedString:) wurde deprecated und entfernt.
Das Problem: SwiftTerm ist ein laufend gepflegtes Projekt, aber der Autor ist nicht verpflichtet, seine API stabil zu halten. Wenn Apple eine CoreText-Funktion ändert, ändern sich die Abhängigkeiten in Wellen.
Ich hatte zwei Optionen:
- Den SwiftTerm-Autor kontaktieren und auf einen Update warten.
- Selbst einen Patch schreiben und
swift package editnutzen, um die lokale Kopie zu patchen, bis ein offizieller Fix kommt.
Ich tat Option 2:
// Patch in der lokalen SwiftTerm Kopie
// AttributedString-Konstruktor ersetzt durch Fallback auf init(string:attributes:)
extension NSAttributedString {
convenience init(fromAttributedString source: NSAttributedString) {
let attrs = source.attributes(at: 0, effectiveRange: nil)
self.init(string: source.string, attributes: attrs)
}
}
Das ist nicht ideal, aber es hält die App am Laufen, während ich auf einen offiziellen Fix warte.
Die Lektion: Open-Source-Abhängigkeiten können dich zurückziehen, wenn sich die Plattform unter dir verändert. Du brauchst eine Strategie – schnell Patches bauen, oder den Maintainer aktiv mit Updates und Tests supporter.
Was ich jetzt weiß
Der erste Artikel endete mit „was ich mitgenommen habe". Dieser hier müsste mit „was ich noch nicht wusste, dass ich mitnehem würde" enden:
- Speicherlecks sind nicht immer dein Code. Sie sind oft die Abhängigkeiten unter dir. Lerne, wie man mit
instrumentsMemory-Profile liest. - Responsive UI ist nicht „du schreibst async Code". Es ist „du verstehst die Grenzen deines Thread-Scheduling und pufferst Eingaben intelligent".
- Das Ökosystem ist rauer als die Spezifikation. Spiele brechen TTY-Zustände, Container haben keine Hooks, alte Shells emittieren keine OSC. Baue Fallbacks.
- Updates sind kein einmaliges Event. Deine Abhängigkeiten werden mit macOS Updates ständig reissen. Du brauchst ein System, um schnell zu patchen oder zu forken.
- Das erste Feature, das du baust – nachdem die App läuft – ist ein Debugger für die App selbst. Logging, Metrics, Monitoring. Das ist nicht Luxus, das ist dein Werkzeug, um Nutzer-Reports zu verstehen.
Relay läuft jetzt in einer Handvoll echten Entwickler-Shells. Die eine oder andere Kollege schickt mir noch immer Bug-Reports, Feature-Requests, oder einfach „hey, coole Idee, könnte man das auch…" – und diese Berichte sind der beste Qualitäts-Test, den ich haben kann.
Weil ein echtes Terminal, das echte Shell-Befehle startet und echte Exit-Codes zurückbekommt, nicht in Unit-Tests zu finden ist. Das ist das letzte Drittel der Arbeit – und das erste, das ich unterschätzt habe.