Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Sources/Terminal/TerminalSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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")
Expand Down
33 changes: 33 additions & 0 deletions Sources/Window/DeckardWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 }
Expand Down
Loading