diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index 0e48633..f46b52d 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -275,6 +275,10 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { var title: String = "" var pwd: String? var isAlive: Bool { !processExited } + /// Set when the pty I/O path broke (dropped input, or output reading + /// abandoned). The process may still be running, but this surface can no + /// longer be trusted to show or deliver anything — it needs a restart. + private(set) var ioFailure: String? var onProcessExit: ((TerminalSurface) -> Void)? /// The tmux session name, if this terminal is wrapped in tmux. var tmuxSessionName: String? @@ -727,12 +731,42 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { "processTerminated: surfaceId=\(surfaceId) exitCode=\(exitCode ?? -1)") onProcessExit?(self) } + + /// Input was dropped on the way to the agent. Keystrokes are lost, so say + /// so loudly — a silent write failure looks exactly like a frozen tab. + func writeFailed(source: LocalProcessTerminalView, errno: Int32) { + ioFailure = "write failed (errno \(errno))" + DiagnosticLog.shared.log("surface", + "writeFailed: surfaceId=\(surfaceId) errno=\(errno) — input was dropped") + notifyIOFailure() + } + + /// Reading from the agent was abandoned: the terminal will never show + /// another byte from this process even though it keeps running. This is + /// the frozen-tab failure mode (#99) — it must never be silent again. + func readFailed(source: LocalProcessTerminalView, errno: Int32) { + ioFailure = "output stopped (errno \(errno))" + DiagnosticLog.shared.log("surface", + "readFailed: surfaceId=\(surfaceId) errno=\(errno) — terminal will not update again;" + + " restart the tab to recover") + notifyIOFailure() + } + + private func notifyIOFailure() { + guard let ioFailure else { return } + NotificationCenter.default.post( + name: .deckardSurfaceIOFailure, + object: nil, + userInfo: ["surfaceId": surfaceId, "reason": ioFailure] + ) + } } // MARK: - Notification Names extension Notification.Name { static let deckardSurfaceTitleChanged = Notification.Name("deckardSurfaceTitleChanged") + static let deckardSurfaceIOFailure = Notification.Name("deckardSurfaceIOFailure") static let deckardSurfaceClosed = Notification.Name("deckardSurfaceClosed") static let deckardNewTab = Notification.Name("deckardNewTab") static let deckardNewCodexTab = Notification.Name("deckardNewCodexTab") diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 5ab1a9f..943472d 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -242,6 +242,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange(_:)), name: .deckardThemeChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(vibrancyDidChange), name: .deckardVibrancyChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(quotaDidChange), name: QuotaMonitor.quotaDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(surfaceIODidFail(_:)), name: .deckardSurfaceIOFailure, object: nil) // Show cached quota data immediately if available quotaDidChange() @@ -1855,6 +1856,38 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { : colors.sidebarBackground.cgColor } + /// A surface's pty I/O broke: input is being dropped, or its output will + /// never be read again. The process often keeps running, so without this + /// the tab just looks frozen with no explanation (#99). Flag it in the tab + /// bar so the state is visible and a restart is an obvious next step. + @objc private func surfaceIODidFail(_ note: Notification) { + DispatchQueue.main.async { [weak self] in + guard let self, + let surfaceIdStr = note.userInfo?["surfaceId"] as? UUID + ?? (note.userInfo?["surfaceId"] as? String).flatMap(UUID.init(uuidString:)) + else { return } + let reason = note.userInfo?["reason"] as? String ?? "pty I/O failure" + for workspace in self.workspaces { + guard let tab = workspace.tabs.first(where: { $0.surface.surfaceId == surfaceIdStr }) + else { continue } + let errorBadge: TabItem.BadgeState + switch tab.kind { + case .codex: errorBadge = .codexError + case .terminal: errorBadge = .terminalError + default: errorBadge = .error + } + if tab.badgeState != errorBadge { + tab.badgeState = errorBadge + self.rebuildSidebar() + self.rebuildTabBar() + } + DiagnosticLog.shared.log("surface", + "surfaceIOFailure: workspace=\(workspace.path) tab=\"\(tab.name)\" \(reason)") + return + } + } + } + @objc private func quotaDidChange() { DispatchQueue.main.async { [weak self] in guard let self = self else { return }