diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..38cf611 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,77 @@ +{ + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", + "version" : "2.101.2" + } + }, + { + "identity" : "swift-nio-ssh", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssh.git", + "state" : { + "revision" : "1a915a324afefd4031e396e81a0e210178621be8", + "version" : "0.13.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" + } + } + ], + "version" : 2 +} diff --git a/Sources/Termini/TerminiSurfaceView.swift b/Sources/Termini/TerminiSurfaceView.swift index 3c6e70d..05091c8 100644 --- a/Sources/Termini/TerminiSurfaceView.swift +++ b/Sources/Termini/TerminiSurfaceView.swift @@ -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 @@ -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 + self?.drawAfterRemoteOutput() + } } + } + + private func drawAfterRemoteOutput() { + guard let surface else { return } ghostty_surface_refresh(surface) ghostty_surface_draw(surface) requestActiveRenderBurst(duration: 0.35) diff --git a/Sources/Termini/TerminiSurfaceView_iOS.swift b/Sources/Termini/TerminiSurfaceView_iOS.swift index 3ce9ba6..91bb841 100644 --- a/Sources/Termini/TerminiSurfaceView_iOS.swift +++ b/Sources/Termini/TerminiSurfaceView_iOS.swift @@ -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? @@ -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()