mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-24 20:56:08 +03:00
524 lines
17 KiB
Zig
524 lines
17 KiB
Zig
//! 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 BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
|
|
|
|
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) {
|
|
.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| try 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;
|
|
|
|
// Start the timer which loops
|
|
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;
|
|
}
|