Fix: stderr-Pipe gegen Instant-Exit-Datenverlust, TCP-Vorab-Check, Fehler-Banner; Icon (Dracula) im Bundle
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user