Fix: stderr-Pipe gegen Instant-Exit-Datenverlust, TCP-Vorab-Check, Fehler-Banner; Icon (Dracula) im Bundle

This commit is contained in:
x3
2026-09-21 23:14:08 +02:00
parent 1828a96a2b
commit 7dc6ff75d2
14 changed files with 397 additions and 33 deletions
+97 -24
View File
@@ -9,9 +9,14 @@ enum PTYState: Equatable {
case done(exitCode: Int32?, message: String)
}
/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY), damit interaktive
/// Prompts (Passwort, Host-Key) funktionieren. Ausgabe wird via AnsiParser
/// in gestylte Zeilen für die Dracula-Ansicht übersetzt.
/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY).
/// - stdin/stdout: PTY-Slave (interaktiv, TTY bleibt erhalten)
/// - stderr: PIPE — macOS verwirft ungelesene PTY-Daten beim Slave-Close,
/// dadurch gingen Fehlermeldungen bei Instant-Exit (z. B. Code 255) verloren.
/// Pipes liefern gepufferte Daten garantiert aus (Bugfix, siehe docs/ERRORS.md #8).
///
/// FD-Regeln: masterFD wird NUR auf dem Main-Thread vergeben/geschlossen,
/// auf ioQueue nur gelesen (EBADF-tolerant). Kein Close auf ioQueue.
final class PTYSession: ObservableObject {
@Published private(set) var lines: [[StyledText]] = [[]]
@Published private(set) var state: PTYState = .never
@@ -22,9 +27,11 @@ final class PTYSession: ObservableObject {
private var masterFD: Int32 = -1
private var process: Process?
private var readSource: DispatchSourceRead?
private var errPipe: Pipe?
private let ioQueue = DispatchQueue(label: "RemminaMac.pty")
private var parser = AnsiParser()
private var finished = false
private var epoch = 0 // nur Main-Thread
private let maxLines = 2000
var isRunning: Bool {
@@ -42,7 +49,8 @@ final class PTYSession: ObservableObject {
}
func start() {
stop()
cleanupSync()
epoch += 1
finished = false
parser = AnsiParser()
state = .running
@@ -64,6 +72,7 @@ final class PTYSession: ObservableObject {
}
masterFD = master
let pipe = Pipe()
let p = Process()
p.executableURL = URL(fileURLWithPath: executable)
p.arguments = arguments
@@ -74,25 +83,44 @@ final class PTYSession: ObservableObject {
// closeOnDealloc=false: Parent schließt slave explizit nach run().
p.standardInput = FileHandle(fileDescriptor: slave)
p.standardOutput = FileHandle(fileDescriptor: slave)
p.standardError = FileHandle(fileDescriptor: slave)
p.standardError = pipe.fileHandleForWriting
let myEpoch = epoch
p.terminationHandler = { [weak self] proc in
DispatchQueue.main.async {
self?.finish(message: "Sitzung beendet (Code \(proc.terminationStatus)).", code: proc.terminationStatus)
DispatchQueue.main.async { [weak self] in
guard let self, self.epoch == myEpoch else { return }
self.ioQueue.async { [weak self] in
self?.finalFlush(code: proc.terminationStatus, myEpoch: myEpoch)
}
}
}
do {
try p.run()
} catch {
close(slave); close(master); masterFD = -1
errPipe = nil
finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil)
return
}
process = p
close(slave) // Parent behält nur Master
errPipe = pipe
pipe.fileHandleForReading.readabilityHandler = { [weak self] h in
let data = h.availableData
guard let self else { return }
if data.isEmpty {
h.readabilityHandler = nil // EOF
return
}
self.ioQueue.async { [weak self] in
guard let self else { return }
let events = self.parser.feed(data)
DispatchQueue.main.async { self.apply(events) }
}
}
let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue)
source.setEventHandler { [weak self] in self?.drain() }
source.setCancelHandler { [master] in close(master) }
// Kein Close hier (Eigentum: Main-Thread) — nur Cancel.
readSource = source
source.resume()
}
@@ -106,8 +134,27 @@ final class PTYSession: ObservableObject {
let events = parser.feed(Data(buf[..<n]))
DispatchQueue.main.async { [weak self] in self?.apply(events) }
} else {
readSource?.cancel()
masterFD = -1
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
}
}
/// Letzte Reste vom PTY-Master einsammeln (non-blocking, EBADF-safe).
/// Pipe-Reste kommen garantiert über den readabilityHandler.
private func finalFlush(code: Int32, myEpoch: Int) {
var events: [TerminalEvent] = []
let fd = masterFD
if fd >= 0 {
var buf = [UInt8](repeating: 0, count: 65536)
while true {
let n = read(fd, &buf, buf.count)
if n <= 0 { break }
events += parser.feed(Data(buf[..<n]))
}
}
DispatchQueue.main.async { [weak self] in
guard let self, self.epoch == myEpoch, !self.finished else { return }
self.apply(events)
self.finish(message: "Sitzung beendet (Code \(code)).", code: code)
}
}
@@ -168,36 +215,62 @@ final class PTYSession: ObservableObject {
lines = [[]]
}
func note(_ message: String) {
lines.append([StyledText(text: message, style: TermStyle(fg: 8))])
}
/// Vorab-Check-Fehlschlag (ohne Spawn): rote Notiz + done.
func fail(_ message: String) {
cleanupSync()
epoch += 1
finished = true
state = .done(exitCode: nil, message: message)
lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 1))])
lines.append([])
}
func stop() {
let wasRunning = isRunning
process?.terminate()
process = nil
if !finished {
cleanupSync()
if wasRunning {
epoch += 1 // späte terminationHandler-Ausläufer verwerfen
finished = true
readSource?.cancel()
readSource = nil
masterFD = -1
if case .running = state {
state = .done(exitCode: nil, message: "Getrennt.")
}
state = .done(exitCode: nil, message: "Getrennt.")
lines.append([StyledText(text: "— Getrennt. —", style: TermStyle(fg: 8))])
lines.append([])
}
}
/// Synchroner Cleanup auf Main: Source canceln, Master schließen, Pipe abklemmen.
/// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst.
private func cleanupSync() {
readSource?.cancel()
readSource = nil
if masterFD >= 0 {
close(masterFD)
masterFD = -1
}
if let pipe = errPipe {
pipe.fileHandleForReading.readabilityHandler = nil
errPipe = nil
}
process = nil
}
private func finish(message: String, code: Int32?) {
guard !finished else { return }
finished = true
readSource?.cancel()
readSource = nil
masterFD = -1
process = nil
cleanupSync()
state = .done(exitCode: code, message: message)
let note = StyledText(text: "— \(message) —", style: TermStyle(fg: 8))
lines.append([note])
lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 8))])
lines.append([])
}
deinit {
process?.terminate()
readSource?.cancel()
if masterFD >= 0 { close(masterFD) }
}
}