Skip to content
Open
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
77 changes: 77 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 33 additions & 3 deletions Sources/Termini/TerminiSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ public final class SurfaceContainerView: NSView {
/// un-ticked surface (the tick that drains it also runs on the main thread).
private var surfaceIOReady = false
private var pendingOutput = Data()
/// Serial queue that hands terminal output to libghostty.
///
/// `ghostty_surface_process_output` can block: escape sequences that
/// notify the host (set_title, set_mouse_shape from DEC mouse-mode
/// toggles, pwd/color changes, …) are pushed onto ghostty's app-wide
/// 64-slot mailbox, and when it's full the push waits until
/// `ghostty_app_tick` drains it — and that tick runs on the MAIN thread.
/// Feeding output on the main thread therefore deadlocks the whole app
/// whenever one output burst carries more than 64 such sequences (e.g. a
/// tmux pane-split redraw with `mouse on`, which toggles mouse modes
/// around every redraw cycle). Upstream ghostty always feeds from a
/// dedicated read thread, so we do the same — the serial queue preserves
/// byte order, and a full mailbox self-heals on the next main-thread tick.
private let outputFeedQueue = DispatchQueue(label: "dev.arach.Termini.surface-output-feed")
private var renderTimer: Timer?
private var renderTimerMode: RenderTimerMode?
private var renderBurstDeadline = Date.distantPast
Expand Down Expand Up @@ -391,10 +405,26 @@ public final class SurfaceContainerView: NSView {
pendingOutput.append(data)
return
}
data.withUnsafeBytes { buffer in
guard let ptr = buffer.bindMemory(to: CChar.self).baseAddress else { return }
ghostty_surface_process_output(surface, ptr, UInt(data.count))
// Feed off-main (see outputFeedQueue). `guard let self` keeps the view
// alive for the duration of the call, so deinit — the only place the
// surface is freed — cannot run mid-feed; blocks that only start after
// deinit see a nil weak self and skip the freed pointer.
outputFeedQueue.async { [weak self] in
guard let self else { return }
withExtendedLifetime(self) {
data.withUnsafeBytes { buffer in
guard let ptr = buffer.bindMemory(to: CChar.self).baseAddress else { return }
ghostty_surface_process_output(surface, ptr, UInt(data.count))
}
}
DispatchQueue.main.async { [weak self] in

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please capture self strongly in this main-queue callback so ownership transfers from outputFeedQueue to the main thread. Otherwise, if this feed block is the last owner, the view’s deinit can run on the background queue and tear down UI resources off-main.

DispatchQueue.main.async { [self] in
    drawAfterRemoteOutput()
}

self?.drawAfterRemoteOutput()
}
}
}

private func drawAfterRemoteOutput() {
guard let surface else { return }
ghostty_surface_refresh(surface)
ghostty_surface_draw(surface)
requestActiveRenderBurst(duration: 0.35)
Expand Down
36 changes: 33 additions & 3 deletions Sources/Termini/TerminiSurfaceView_iOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ public final class SurfaceContainerView: UIView, UIKeyInput, UITextInputTraits,
/// it also runs on the main thread).
private var surfaceIOReady = false
private var pendingOutput = Data()
/// Serial queue that hands terminal output to libghostty.
///
/// `ghostty_surface_process_output` can block: escape sequences that
/// notify the host (set_title, set_mouse_shape from DEC mouse-mode
/// toggles, pwd/color changes, …) are pushed onto ghostty's app-wide
/// 64-slot mailbox, and when it's full the push waits until
/// `ghostty_app_tick` drains it — and that tick runs on the MAIN thread.
/// Feeding output on the main thread therefore deadlocks the whole app
/// whenever one output burst carries more than 64 such sequences (e.g. a
/// tmux pane-split redraw with `mouse on`, which toggles mouse modes
/// around every redraw cycle). Upstream ghostty always feeds from a
/// dedicated read thread, so we do the same — the serial queue preserves
/// byte order, and a full mailbox self-heals on the next main-thread tick.
private let outputFeedQueue = DispatchQueue(label: "dev.arach.Termini.surface-output-feed")
private var renderLink: CADisplayLink?
private weak var controller: TerminiTerminalController?
private var lastReportedSize: TerminiTerminalSize?
Expand Down Expand Up @@ -377,10 +391,26 @@ public final class SurfaceContainerView: UIView, UIKeyInput, UITextInputTraits,
pendingOutput.append(data)
return
}
data.withUnsafeBytes { buffer in
guard let ptr = buffer.bindMemory(to: CChar.self).baseAddress else { return }
ghostty_surface_process_output(surface, ptr, UInt(data.count))
// Feed off-main (see outputFeedQueue). `guard let self` keeps the view
// alive for the duration of the call, so deinit — the only place the
// surface is freed — cannot run mid-feed; blocks that only start after
// deinit see a nil weak self and skip the freed pointer.
outputFeedQueue.async { [weak self] in
guard let self else { return }
withExtendedLifetime(self) {
data.withUnsafeBytes { buffer in
guard let ptr = buffer.bindMemory(to: CChar.self).baseAddress else { return }
ghostty_surface_process_output(surface, ptr, UInt(data.count))
}
}
DispatchQueue.main.async { [weak self] in
self?.drawAfterRemoteOutput()
}
}
}

private func drawAfterRemoteOutput() {
guard let surface else { return }
ghostty_surface_refresh(surface)
ghostty_surface_draw(surface)
reportDiagnostics()
Expand Down