aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/termio/Thread.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/termio/Thread.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/termio/Thread.zig')
-rw-r--r--vendor/libghostty-vt/src/termio/Thread.zig531
1 files changed, 531 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/termio/Thread.zig b/vendor/libghostty-vt/src/termio/Thread.zig
new file mode 100644
index 00000000..ce4c1f4a
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/Thread.zig
@@ -0,0 +1,531 @@
+//! Represents the "writer" thread for terminal IO. The reader side is
+//! handled by the Termio struct itself and dependent on the underlying
+//! implementation (i.e. if its a pty, manual, etc.).
+//!
+//! The writer thread does handle writing bytes to the pty but also handles
+//! different events such as starting synchronized output, changing some
+//! modes (like linefeed), etc. The goal is to offload as much from the
+//! reader thread as possible since it is the hot path in parsing VT
+//! sequences and updating terminal state.
+//!
+//! This thread state can only be used by one thread at a time.
+pub const Thread = @This();
+
+const std = @import("std");
+const ArenaAllocator = std.heap.ArenaAllocator;
+const builtin = @import("builtin");
+const xev = @import("../global.zig").xev;
+const crash = @import("../crash/main.zig");
+const internal_os = @import("../os/main.zig");
+const termio = @import("../termio.zig");
+const renderer = @import("../renderer.zig");
+
+const Allocator = std.mem.Allocator;
+const log = std.log.scoped(.io_thread);
+
+/// This stores the information that is coalesced.
+const Coalesce = struct {
+ /// The number of milliseconds to coalesce certain messages like resize for.
+ /// Not all message types are coalesced.
+ const min_ms = 25;
+
+ resize: ?renderer.Size = null,
+};
+
+/// The number of milliseconds before we reset the synchronized output flag
+/// if the running program hasn't already.
+const sync_reset_ms = 1000;
+
+/// The number of milliseconds between each movement during selection scrolling.
+const selection_scroll_ms = 15;
+
+/// Allocator used for some state
+alloc: std.mem.Allocator,
+
+/// The main event loop for the thread. The user data of this loop
+/// is always the allocator used to create the loop. This is a convenience
+/// so that users of the loop always have an allocator.
+loop: xev.Loop,
+
+/// The completion to use for the wakeup async handle that is present
+/// on the termio.Writer.
+wakeup_c: xev.Completion = .{},
+
+/// This can be used to stop the thread on the next loop iteration.
+stop: xev.Async,
+stop_c: xev.Completion = .{},
+
+/// This is used for timer-based selection scrolling.
+scroll: xev.Timer,
+scroll_c: xev.Completion = .{},
+scroll_active: bool = false,
+
+/// This is used to coalesce resize events.
+coalesce: xev.Timer,
+coalesce_c: xev.Completion = .{},
+coalesce_cancel_c: xev.Completion = .{},
+coalesce_data: Coalesce = .{},
+
+/// This timer is used to reset synchronized output modes so that
+/// the terminal doesn't freeze with a bad actor.
+sync_reset: xev.Timer,
+sync_reset_c: xev.Completion = .{},
+sync_reset_cancel_c: xev.Completion = .{},
+
+flags: packed struct {
+ /// This is set to true only when an abnormal exit is detected. It
+ /// tells our mailbox system to drain and ignore all messages.
+ drain: bool = false,
+
+ /// True if linefeed mode is enabled. This is duplicated here so that the
+ /// write thread doesn't need to grab a lock to check this on every write.
+ linefeed_mode: bool = false,
+
+ /// This is true when the inspector is active.
+ has_inspector: bool = false,
+} = .{},
+
+/// Initialize the thread. This does not START the thread. This only sets
+/// up all the internal state necessary prior to starting the thread. It
+/// is up to the caller to start the thread with the threadMain entrypoint.
+pub fn init(
+ alloc: Allocator,
+) !Thread {
+ // Create our event loop.
+ var loop = try xev.Loop.init(.{});
+ errdefer loop.deinit();
+
+ // This async handle is used to stop the loop and force the thread to end.
+ var stop_h = try xev.Async.init();
+ errdefer stop_h.deinit();
+
+ // This timer is used for selection scrolling.
+ var scroll_h = try xev.Timer.init();
+ errdefer scroll_h.deinit();
+
+ // This timer is used to coalesce resize events.
+ var coalesce_h = try xev.Timer.init();
+ errdefer coalesce_h.deinit();
+
+ // This timer is used to reset synchronized output modes.
+ var sync_reset_h = try xev.Timer.init();
+ errdefer sync_reset_h.deinit();
+
+ return Thread{
+ .alloc = alloc,
+ .loop = loop,
+ .stop = stop_h,
+ .scroll = scroll_h,
+ .coalesce = coalesce_h,
+ .sync_reset = sync_reset_h,
+ };
+}
+
+/// Clean up the thread. This is only safe to call once the thread
+/// completes executing; the caller must join prior to this.
+pub fn deinit(self: *Thread) void {
+ self.scroll.deinit();
+ self.coalesce.deinit();
+ self.sync_reset.deinit();
+ self.stop.deinit();
+ self.loop.deinit();
+}
+
+/// The main entrypoint for the thread.
+pub fn threadMain(self: *Thread, io: *termio.Termio) void {
+ // Call child function so we can use errors...
+ self.threadMain_(io) catch |err| {
+ log.warn("error in io thread err={}", .{err});
+
+ // Use an arena to simplify memory management below
+ var arena = ArenaAllocator.init(self.alloc);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // If there is an error, we replace our terminal screen with
+ // the error message. It might be better in the future to send
+ // the error to the surface thread and let the apprt deal with it
+ // in some way but this works for now. Without this, the user would
+ // just see a blank terminal window.
+ io.renderer_state.mutex.lock();
+ defer io.renderer_state.mutex.unlock();
+ const t = io.renderer_state.terminal;
+
+ // Hide the cursor
+ t.modes.set(.cursor_visible, false);
+
+ // This is weird but just ensures that no matter what our underlying
+ // implementation we have the errors below. For example, Windows doesn't
+ // have "OpenptyFailed".
+ const Err = @TypeOf(err) || error{
+ OpenptyFailed,
+ InputNotFound,
+ InputFailed,
+ };
+
+ switch (@as(Err, @errorCast(err))) {
+ error.OpenptyFailed => {
+ const str =
+ \\Your system cannot allocate any more pty devices.
+ \\
+ \\Ghostty requires a pty device to launch a new terminal.
+ \\This error is usually due to having too many terminal
+ \\windows open or having another program that is using too
+ \\many pty devices.
+ \\
+ \\Please free up some pty devices and try again.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+
+ error.InputNotFound,
+ error.InputFailed,
+ => {
+ const str =
+ \\A configured `input` path was not found, was not readable,
+ \\was too large, or the underlying pty failed to accept
+ \\the write.
+ \\
+ \\Ghostty can't continue since it can't guarantee that
+ \\initial terminal state will be as desired. Please review
+ \\the value of `input` in your configuration file and
+ \\ensure that all the path values exist and are readable.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+
+ else => {
+ const str = std.fmt.allocPrint(
+ alloc,
+ \\error starting IO thread: {}
+ \\
+ \\The underlying shell or command was unable to be started.
+ \\This error is usually due to exhausting a system resource.
+ \\If this looks like a bug, please report it.
+ \\
+ \\This terminal is non-functional. Please close it and try again.
+ ,
+ .{err},
+ ) catch
+ \\Out of memory. This terminal is non-functional. Please close it and try again.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+ }
+ };
+
+ // If our loop is not stopped, then we need to keep running so that
+ // messages are drained and we can wait for the surface to send a stop
+ // message.
+ if (!self.loop.stopped()) {
+ log.warn("abrupt io thread exit detected, starting xev to drain mailbox", .{});
+ defer log.debug("io thread fully exiting after abnormal failure", .{});
+ self.flags.drain = true;
+ self.loop.run(.until_done) catch |err| {
+ log.err("failed to start xev loop for draining err={}", .{err});
+ };
+ }
+}
+
+fn threadMain_(self: *Thread, io: *termio.Termio) !void {
+ defer log.debug("IO thread exited", .{});
+
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"io".*);
+ }
+
+ // Setup our crash metadata
+ crash.sentry.thread_state = .{
+ .type = .io,
+ .surface = io.surface_mailbox.surface,
+ };
+ defer crash.sentry.thread_state = null;
+
+ // Get the mailbox. This must be an SPSC mailbox for threading.
+ const mailbox = switch (io.mailbox) {
+ .spsc => |*v| v,
+ // else => return error.TermioUnsupportedMailbox,
+ };
+
+ // This is the data sent to xev callbacks. We want a pointer to both
+ // ourselves and the thread data so we can thread that through (pun intended).
+ var cb: CallbackData = .{ .self = self, .io = io };
+
+ // Run our thread start/end callbacks. This allows the implementation
+ // to hook into the event loop as needed. The thread data is created
+ // on the stack here so that it has a stable pointer throughout the
+ // lifetime of the thread.
+ try io.threadEnter(self, &cb.data);
+ defer cb.data.deinit();
+ defer io.threadExit(&cb.data);
+
+ // Start the async handlers.
+ mailbox.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback);
+ self.stop.wait(&self.loop, &self.stop_c, CallbackData, &cb, stopCallback);
+
+ // Run
+ log.debug("starting IO thread", .{});
+ defer log.debug("starting IO thread shutdown", .{});
+ try self.loop.run(.until_done);
+}
+
+/// This is the data passed to xev callbacks on the thread.
+const CallbackData = struct {
+ self: *Thread,
+ io: *termio.Termio,
+ data: termio.Termio.ThreadData = undefined,
+};
+
+/// Drain the mailbox, handling all the messages in our terminal implementation.
+fn drainMailbox(
+ self: *Thread,
+ cb: *CallbackData,
+) !void {
+ // We assert when starting the thread that this is the state
+ const mailbox = cb.io.mailbox.spsc.queue;
+ const io = cb.io;
+ const data = &cb.data;
+
+ // If we're draining, we just drain the mailbox and return.
+ if (self.flags.drain) {
+ while (mailbox.pop()) |_| {}
+ return;
+ }
+
+ // This holds the mailbox lock for the duration of the drain. The
+ // expectation is that all our message handlers will be non-blocking
+ // ENOUGH to not mess up throughput on producers.
+ var redraw: bool = false;
+ while (mailbox.pop()) |message| {
+ // If we have a message we always redraw
+ redraw = true;
+
+ log.debug("mailbox message={s}", .{@tagName(message)});
+ switch (message) {
+ .color_scheme_report => |v| try io.colorSchemeReport(data, v.force),
+ .crash => @panic("crash request, crashing intentionally"),
+ .change_config => |config| {
+ defer config.alloc.destroy(config.ptr);
+ try io.changeConfig(data, config.ptr);
+ },
+ .inspector => |v| self.flags.has_inspector = v,
+ .resize => |v| self.handleResize(cb, v),
+ .size_report => |v| try io.sizeReport(data, v),
+ .clear_screen => |v| try io.clearScreen(data, v.history),
+ .scroll_viewport => |v| io.scrollViewport(v),
+ .selection_scroll => |v| {
+ if (v) {
+ self.startScrollTimer(cb);
+ } else {
+ self.stopScrollTimer();
+ }
+ },
+ .jump_to_prompt => |v| try io.jumpToPrompt(v),
+ .start_synchronized_output => self.startSynchronizedOutput(cb),
+ .linefeed_mode => |v| self.flags.linefeed_mode = v,
+ .focused => |v| try io.focusGained(data, v),
+ .write_small => |v| try io.queueWrite(
+ data,
+ v.data[0..v.len],
+ self.flags.linefeed_mode,
+ ),
+ .write_stable => |v| try io.queueWrite(
+ data,
+ v,
+ self.flags.linefeed_mode,
+ ),
+ .write_alloc => |v| {
+ defer v.alloc.free(v.data);
+ try io.queueWrite(
+ data,
+ v.data,
+ self.flags.linefeed_mode,
+ );
+ },
+ }
+ }
+
+ // Trigger a redraw after we've drained so we don't waste cyces
+ // messaging a redraw.
+ if (redraw) {
+ try io.renderer_wakeup.notify();
+ }
+}
+
+fn startSynchronizedOutput(self: *Thread, cb: *CallbackData) void {
+ self.sync_reset.reset(
+ &self.loop,
+ &self.sync_reset_c,
+ &self.sync_reset_cancel_c,
+ sync_reset_ms,
+ CallbackData,
+ cb,
+ syncResetCallback,
+ );
+}
+
+fn handleResize(self: *Thread, cb: *CallbackData, resize: renderer.Size) void {
+ self.coalesce_data.resize = resize;
+
+ // If the timer is already active we just return. In the future we want
+ // to reset the timer up to a maximum wait time but for now this ensures
+ // relatively smooth resizing.
+ if (self.coalesce_c.state() == .active) return;
+
+ self.coalesce.reset(
+ &self.loop,
+ &self.coalesce_c,
+ &self.coalesce_cancel_c,
+ Coalesce.min_ms,
+ CallbackData,
+ cb,
+ coalesceCallback,
+ );
+}
+
+fn syncResetCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ _ = r catch |err| switch (err) {
+ error.Canceled => {},
+ else => {
+ log.warn("error during sync reset callback err={}", .{err});
+ return .disarm;
+ },
+ };
+
+ const cb = cb_ orelse return .disarm;
+ cb.io.resetSynchronizedOutput();
+ return .disarm;
+}
+
+fn coalesceCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ _ = r catch |err| switch (err) {
+ error.Canceled => {},
+ else => {
+ log.warn("error during coalesce callback err={}", .{err});
+ return .disarm;
+ },
+ };
+
+ const cb = cb_ orelse return .disarm;
+
+ if (cb.self.coalesce_data.resize) |v| {
+ cb.self.coalesce_data.resize = null;
+ cb.io.resize(&cb.data, v) catch |err| {
+ log.warn("error during resize err={}", .{err});
+ };
+ }
+
+ return .disarm;
+}
+
+fn wakeupCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Async.WaitError!void,
+) xev.CallbackAction {
+ _ = r catch |err| {
+ log.err("error in wakeup err={}", .{err});
+ return .rearm;
+ };
+
+ // When we wake up, we check the mailbox. Mailbox producers should
+ // wake up our thread after publishing.
+ const cb = cb_ orelse return .rearm;
+ cb.self.drainMailbox(cb) catch |err|
+ log.err("error draining mailbox err={}", .{err});
+
+ return .rearm;
+}
+
+fn stopCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Async.WaitError!void,
+) xev.CallbackAction {
+ _ = r catch unreachable;
+ cb_.?.self.loop.stop();
+ return .disarm;
+}
+
+fn startScrollTimer(self: *Thread, cb: *CallbackData) void {
+ self.scroll_active = true;
+
+ switch (self.scroll_c.state()) {
+ // If it is already active, e.g. startScrollTimer is called multiple
+ // times, then we just return. We can't simply check `scroll_active`
+ // because its possible that `stopScrollTimer` was called but there
+ // was no loop tick between then and now to halt out completion.
+ .active => return,
+
+ // If the completion is not active then we need to start it.
+ .dead => self.scroll.run(
+ &self.loop,
+ &self.scroll_c,
+ selection_scroll_ms,
+ CallbackData,
+ cb,
+ selectionScrollCallback,
+ ),
+ }
+}
+
+fn stopScrollTimer(self: *Thread) void {
+ // This will stop the scrolling on the next iteration.
+ self.scroll_active = false;
+}
+
+fn selectionScrollCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ _ = r catch |err| switch (err) {
+ error.Canceled => {},
+ else => {
+ log.warn("error during selection scroll callback err={}", .{err});
+ return .disarm;
+ },
+ };
+
+ const cb = cb_ orelse return .disarm;
+ const self = cb.self;
+
+ // Send the tick to the main surface
+ _ = cb.io.surface_mailbox.push(
+ .{ .selection_scroll_tick = self.scroll_active },
+ .{ .instant = {} },
+ );
+
+ if (self.scroll_active) self.scroll.run(
+ &self.loop,
+ &self.scroll_c,
+ selection_scroll_ms,
+ CallbackData,
+ cb,
+ selectionScrollCallback,
+ );
+
+ return .disarm;
+}