From 49c92fd0e67592fc9eebab4540db7de426b0283e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 09:29:54 -0700 Subject: [PATCH 01/32] termio: rename Exec to Termio throughout --- src/Surface.zig | 10 +++++----- src/termio.zig | 7 +------ src/termio/Options.zig | 2 +- src/termio/Thread.zig | 40 ++++++++++++++++++++-------------------- src/termio/message.zig | 2 +- 5 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 95bc4d765..94bb058b3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -101,7 +101,7 @@ color_scheme: apprt.ColorScheme = .light, last_binding_trigger: u64 = 0, /// The terminal IO handler. -io: termio.Impl, +io: termio.Termio, io_thread: termio.Thread, io_thr: std.Thread, @@ -397,12 +397,12 @@ pub fn init( errdefer render_thread.deinit(); // Start our IO implementation - var io = try termio.Impl.init(alloc, .{ + var io = try termio.Termio.init(alloc, .{ .grid_size = grid_size, .screen_size = screen_size, .padding = padding, .full_config = config, - .config = try termio.Impl.DerivedConfig.init(alloc, config), + .config = try termio.Termio.DerivedConfig.init(alloc, config), .resources_dir = main.state.resources_dir, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, @@ -809,9 +809,9 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void { // our messages aren't huge. var renderer_message = try renderer.Message.initChangeConfig(self.alloc, config); errdefer renderer_message.deinit(); - var termio_config_ptr = try self.alloc.create(termio.Impl.DerivedConfig); + var termio_config_ptr = try self.alloc.create(termio.Termio.DerivedConfig); errdefer self.alloc.destroy(termio_config_ptr); - termio_config_ptr.* = try termio.Impl.DerivedConfig.init(self.alloc, config); + termio_config_ptr.* = try termio.Termio.DerivedConfig.init(self.alloc, config); errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); diff --git a/src/termio.zig b/src/termio.zig index 45382cda1..e0b3efb28 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -5,15 +5,10 @@ pub usingnamespace @import("termio/message.zig"); pub const Exec = @import("termio/Exec.zig"); pub const Options = @import("termio/Options.zig"); +pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); pub const Mailbox = Thread.Mailbox; -/// The implementation to use for the IO. This is just "exec" for now but -/// this is somewhat pluggable so that in the future we can introduce other -/// options for other platforms (i.e. wasm) or even potentially a vtable -/// implementation for runtime polymorphism. -pub const Impl = Exec; - test { @import("std").testing.refAllDecls(@This()); diff --git a/src/termio/Options.zig b/src/termio/Options.zig index b4edb473a..ac12c31eb 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -23,7 +23,7 @@ padding: renderer.Padding, full_config: *const Config, /// The derived configuration for this termio implementation. -config: termio.Impl.DerivedConfig, +config: termio.Termio.DerivedConfig, /// The application resources directory. resources_dir: ?[]const u8, diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 45b9fd1ae..aceeaa636 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -58,8 +58,8 @@ sync_reset: xev.Timer, sync_reset_c: xev.Completion = .{}, sync_reset_cancel_c: xev.Completion = .{}, -/// The underlying IO implementation. -impl: *termio.Impl, +/// The main termio state. +termio: *termio.Termio, /// The mailbox that can be used to send this thread messages. Note /// this is a blocking queue so if it is full you will get errors (or block). @@ -83,7 +83,7 @@ flags: packed struct { /// is up to the caller to start the thread with the threadMain entrypoint. pub fn init( alloc: Allocator, - impl: *termio.Impl, + t: *termio.Termio, ) !Thread { // Create our event loop. var loop = try xev.Loop.init(.{}); @@ -116,7 +116,7 @@ pub fn init( .stop = stop_h, .coalesce = coalesce_h, .sync_reset = sync_reset_h, - .impl = impl, + .termio = t, .mailbox = mailbox, }; } @@ -150,9 +150,9 @@ pub fn threadMain(self: *Thread) void { // 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. - self.impl.renderer_state.mutex.lock(); - defer self.impl.renderer_state.mutex.unlock(); - const t = self.impl.renderer_state.terminal; + self.termio.renderer_state.mutex.lock(); + defer self.termio.renderer_state.mutex.unlock(); + const t = self.termio.renderer_state.terminal; // Hide the cursor t.modes.set(.cursor_visible, false); @@ -226,9 +226,9 @@ fn threadMain_(self: *Thread) !void { // Run our thread start/end callbacks. This allows the implementation // to hook into the event loop as needed. - var data = try self.impl.threadEnter(self); + var data = try self.termio.threadEnter(self); defer data.deinit(); - defer self.impl.threadExit(data); + defer self.termio.threadExit(data); // Run log.debug("starting IO thread", .{}); @@ -256,21 +256,21 @@ fn drainMailbox(self: *Thread) !void { switch (message) { .change_config => |config| { defer config.alloc.destroy(config.ptr); - try self.impl.changeConfig(config.ptr); + try self.termio.changeConfig(config.ptr); }, .inspector => |v| self.flags.has_inspector = v, .resize => |v| self.handleResize(v), - .clear_screen => |v| try self.impl.clearScreen(v.history), - .scroll_viewport => |v| try self.impl.scrollViewport(v), - .jump_to_prompt => |v| try self.impl.jumpToPrompt(v), + .clear_screen => |v| try self.termio.clearScreen(v.history), + .scroll_viewport => |v| try self.termio.scrollViewport(v), + .jump_to_prompt => |v| try self.termio.jumpToPrompt(v), .start_synchronized_output => self.startSynchronizedOutput(), .linefeed_mode => |v| self.flags.linefeed_mode = v, - .child_exited_abnormally => |v| try self.impl.childExitedAbnormally(v.exit_code, v.runtime_ms), - .write_small => |v| try self.impl.queueWrite(v.data[0..v.len], self.flags.linefeed_mode), - .write_stable => |v| try self.impl.queueWrite(v, self.flags.linefeed_mode), + .child_exited_abnormally => |v| try self.termio.childExitedAbnormally(v.exit_code, v.runtime_ms), + .write_small => |v| try self.termio.queueWrite(v.data[0..v.len], self.flags.linefeed_mode), + .write_stable => |v| try self.termio.queueWrite(v, self.flags.linefeed_mode), .write_alloc => |v| { defer v.alloc.free(v.data); - try self.impl.queueWrite(v.data, self.flags.linefeed_mode); + try self.termio.queueWrite(v.data, self.flags.linefeed_mode); }, } } @@ -278,7 +278,7 @@ fn drainMailbox(self: *Thread) !void { // Trigger a redraw after we've drained so we don't waste cyces // messaging a redraw. if (redraw) { - try self.impl.renderer_wakeup.notify(); + try self.termio.renderer_wakeup.notify(); } } @@ -328,7 +328,7 @@ fn syncResetCallback( }; const self = self_ orelse return .disarm; - self.impl.resetSynchronizedOutput(); + self.termio.resetSynchronizedOutput(); return .disarm; } @@ -350,7 +350,7 @@ fn coalesceCallback( if (self.coalesce_data.resize) |v| { self.coalesce_data.resize = null; - self.impl.resize(v.grid_size, v.screen_size, v.padding) catch |err| { + self.termio.resize(v.grid_size, v.screen_size, v.padding) catch |err| { log.warn("error during resize err={}", .{err}); }; } diff --git a/src/termio/message.zig b/src/termio/message.zig index c91f3b5d6..31b203f05 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -33,7 +33,7 @@ pub const Message = union(enum) { /// is allocated via the allocator and is expected to be freed when done. change_config: struct { alloc: Allocator, - ptr: *termio.Impl.DerivedConfig, + ptr: *termio.Termio.DerivedConfig, }, /// Activate or deactivate the inspector. From c4484938c565030bd10b10a77a8e80602e3001ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 10:35:46 -0700 Subject: [PATCH 02/32] termio: wip but it builds --- src/termio.zig | 2 +- src/termio/Old.zig | 2965 ++++++++++++++++++++++++++++++++++++++++ src/termio/Termio.zig | 2987 +++++++++++++++++++++++++++++++++++++++++ src/termio/Thread.zig | 61 +- src/termio/reader.zig | 48 + 5 files changed, 6043 insertions(+), 20 deletions(-) create mode 100644 src/termio/Old.zig create mode 100644 src/termio/Termio.zig create mode 100644 src/termio/reader.zig diff --git a/src/termio.zig b/src/termio.zig index e0b3efb28..b2c2c1a63 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -3,7 +3,7 @@ //! with the terminal. pub usingnamespace @import("termio/message.zig"); -pub const Exec = @import("termio/Exec.zig"); +pub const reader = @import("termio/reader.zig"); pub const Options = @import("termio/Options.zig"); pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); diff --git a/src/termio/Old.zig b/src/termio/Old.zig new file mode 100644 index 000000000..a93f858e2 --- /dev/null +++ b/src/termio/Old.zig @@ -0,0 +1,2965 @@ +//! Primary terminal IO ("termio") state. This maintains the terminal state, +//! pty, subprocess, etc. This is flexible enough to be used in environments +//! that don't have a pty and simply provides the input/output using raw +//! bytes. +pub const Termio = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const EnvMap = std.process.EnvMap; +const posix = std.posix; +const termio = @import("../termio.zig"); +const Command = @import("../Command.zig"); +const Pty = @import("../pty.zig").Pty; +const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; +const terminal = @import("../terminal/main.zig"); +const terminfo = @import("../terminfo/main.zig"); +const xev = @import("xev"); +const renderer = @import("../renderer.zig"); +const apprt = @import("../apprt.zig"); +const fastmem = @import("../fastmem.zig"); +const internal_os = @import("../os/main.zig"); +const windows = internal_os.windows; +const configpkg = @import("../config.zig"); +const shell_integration = @import("shell_integration.zig"); + +const log = std.log.scoped(.io_exec); + +const c = @cImport({ + @cInclude("errno.h"); + @cInclude("signal.h"); + @cInclude("unistd.h"); +}); + +/// True if we should disable the kitty keyboard protocol. We have to +/// disable this on GLFW because GLFW input events don't support the +/// correct granularity of events. +const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; + +/// Allocator +alloc: Allocator, + +/// This is the pty fd created for the subcommand. +subprocess: Subprocess, + +/// The derived configuration for this termio implementation. +config: DerivedConfig, + +/// The terminal emulator internal state. This is the abstract "terminal" +/// that manages input, grid updating, etc. and is renderer-agnostic. It +/// just stores internal state about a grid. +terminal: terminal.Terminal, + +/// The shared render state +renderer_state: *renderer.State, + +/// A handle to wake up the renderer. This hints to the renderer that that +/// a repaint should happen. +renderer_wakeup: xev.Async, + +/// The mailbox for notifying the renderer of things. +renderer_mailbox: *renderer.Thread.Mailbox, + +/// The mailbox for communicating with the surface. +surface_mailbox: apprt.surface.Mailbox, + +/// The cached grid size whenever a resize is called. +grid_size: renderer.GridSize, + +/// The data associated with the currently running thread. +data: ?*EventData, + +/// The configuration for this IO that is derived from the main +/// configuration. This must be exported so that we don't need to +/// pass around Config pointers which makes memory management a pain. +pub const DerivedConfig = struct { + arena: ArenaAllocator, + + palette: terminal.color.Palette, + image_storage_limit: usize, + cursor_style: terminal.CursorStyle, + cursor_blink: ?bool, + cursor_color: ?configpkg.Config.Color, + foreground: configpkg.Config.Color, + background: configpkg.Config.Color, + osc_color_report_format: configpkg.Config.OSCColorReportFormat, + term: []const u8, + grapheme_width_method: configpkg.Config.GraphemeWidthMethod, + abnormal_runtime_threshold_ms: u32, + wait_after_command: bool, + enquiry_response: []const u8, + + pub fn init( + alloc_gpa: Allocator, + config: *const configpkg.Config, + ) !DerivedConfig { + var arena = ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + return .{ + .palette = config.palette.value, + .image_storage_limit = config.@"image-storage-limit", + .cursor_style = config.@"cursor-style", + .cursor_blink = config.@"cursor-style-blink", + .cursor_color = config.@"cursor-color", + .foreground = config.foreground, + .background = config.background, + .osc_color_report_format = config.@"osc-color-report-format", + .term = try alloc.dupe(u8, config.term), + .grapheme_width_method = config.@"grapheme-width-method", + .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", + .wait_after_command = config.@"wait-after-command", + .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), + + // This has to be last so that we copy AFTER the arena allocations + // above happen (Zig assigns in order). + .arena = arena, + }; + } + + pub fn deinit(self: *DerivedConfig) void { + self.arena.deinit(); + } +}; + +/// Initialize the termio state. +/// +/// This will also start the child process if the termio is configured +/// to run a child process. +pub fn init(alloc: Allocator, opts: termio.Options) !Termio { + // Create our terminal + var term = try terminal.Terminal.init(alloc, .{ + .cols = opts.grid_size.columns, + .rows = opts.grid_size.rows, + .max_scrollback = opts.full_config.@"scrollback-limit", + }); + errdefer term.deinit(alloc); + term.default_palette = opts.config.palette; + term.color_palette.colors = opts.config.palette; + + // Setup our initial grapheme cluster support if enabled. We use a + // switch to ensure we get a compiler error if more cases are added. + switch (opts.config.grapheme_width_method) { + .unicode => term.modes.set(.grapheme_cluster, true), + .legacy => {}, + } + + // Set the image size limits + try term.screen.kitty_images.setLimit( + alloc, + &term.screen, + opts.config.image_storage_limit, + ); + try term.secondary_screen.kitty_images.setLimit( + alloc, + &term.secondary_screen, + opts.config.image_storage_limit, + ); + + // Set default cursor blink settings + term.modes.set( + .cursor_blinking, + opts.config.cursor_blink orelse true, + ); + + // Set our default cursor style + term.screen.cursor.cursor_style = opts.config.cursor_style; + + var subprocess = try Subprocess.init(alloc, opts); + errdefer subprocess.deinit(); + + // If we have an initial pwd requested by the subprocess, then we + // set that on the terminal now. This allows rapidly initializing + // new surfaces to use the proper pwd. + if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { + log.warn("error setting initial pwd err={}", .{err}); + }; + + // Initial width/height based on subprocess + term.width_px = subprocess.screen_size.width; + term.height_px = subprocess.screen_size.height; + + return .{ + .alloc = alloc, + .terminal = term, + .subprocess = subprocess, + .config = opts.config, + .renderer_state = opts.renderer_state, + .renderer_wakeup = opts.renderer_wakeup, + .renderer_mailbox = opts.renderer_mailbox, + .surface_mailbox = opts.surface_mailbox, + .grid_size = opts.grid_size, + .data = null, + }; +} + +pub fn deinit(self: *Termio) void { + self.subprocess.deinit(); + self.terminal.deinit(self.alloc); + self.config.deinit(); +} + +pub fn threadEnter(self: *Termio, thread: *termio.Thread) !ThreadData { + assert(self.data == null); + const alloc = self.alloc; + + // Start our subprocess + const pty_fds = self.subprocess.start(alloc) catch |err| { + // If we specifically got this error then we are in the forked + // process and our child failed to execute. In that case + if (err != error.Termio) return err; + + // Output an error message about the exec faililng and exit. + // This generally should NOT happen because we always wrap + // our command execution either in login (macOS) or /bin/sh + // (Linux) which are usually guaranteed to exist. Still, we + // want to handle this scenario. + self.execFailedInChild() catch {}; + posix.exit(1); + }; + errdefer self.subprocess.stop(); + const pid = pid: { + const command = self.subprocess.command orelse return error.ProcessNotStarted; + break :pid command.pid orelse return error.ProcessNoPid; + }; + + // Track our process start time so we know how long it was + // running for. + const process_start = try std.time.Instant.now(); + + // Create our pipe that we'll use to kill our read thread. + // pipe[0] is the read end, pipe[1] is the write end. + const pipe = try internal_os.pipe(); + errdefer posix.close(pipe[0]); + errdefer posix.close(pipe[1]); + + // Setup our data that is used for callbacks + var ev_data_ptr = try alloc.create(EventData); + errdefer alloc.destroy(ev_data_ptr); + + // Setup our stream so that we can write. + var stream = xev.Stream.initFd(pty_fds.write); + errdefer stream.deinit(); + + // Wakeup watcher for the writer thread. + var wakeup = try xev.Async.init(); + errdefer wakeup.deinit(); + + // Watcher to detect subprocess exit + var process = try xev.Process.init(pid); + errdefer process.deinit(); + + // Setup our event data before we start + ev_data_ptr.* = .{ + .writer_mailbox = thread.mailbox, + .writer_wakeup = thread.wakeup, + .surface_mailbox = self.surface_mailbox, + .renderer_state = self.renderer_state, + .renderer_wakeup = self.renderer_wakeup, + .renderer_mailbox = self.renderer_mailbox, + .process = process, + .process_start = process_start, + .data_stream = stream, + .loop = &thread.loop, + .terminal_stream = .{ + .handler = StreamHandler.init( + self.alloc, + ev_data_ptr, + &self.grid_size, + &self.terminal, + &self.config, + ), + .parser = .{ + .osc_parser = .{ + // Populate the OSC parser allocator (optional) because + // we want to support large OSC payloads such as OSC 52. + .alloc = self.alloc, + }, + }, + }, + .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, + .wait_after_command = self.config.wait_after_command, + }; + errdefer ev_data_ptr.deinit(self.alloc); + + // Store our data so our callbacks can access it + self.data = ev_data_ptr; + errdefer self.data = null; + + // Start our process watcher + process.wait( + ev_data_ptr.loop, + &ev_data_ptr.process_wait_c, + EventData, + ev_data_ptr, + processExit, + ); + + // Start our reader thread + const read_thread = try std.Thread.spawn( + .{}, + if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, + .{ pty_fds.read, ev_data_ptr, pipe[0] }, + ); + read_thread.setName("io-reader") catch {}; + + // Return our thread data + return ThreadData{ + .alloc = alloc, + .ev = ev_data_ptr, + .read_thread = read_thread, + .read_thread_pipe = pipe[1], + .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, + }; +} + +/// This outputs an error message when exec failed and we are the +/// child process. This returns so the caller should probably exit +/// after calling this. +/// +/// Note that this usually is only called under very very rare +/// circumstances because we wrap our command execution in login +/// (macOS) or /bin/sh (Linux). So this output can be pretty crude +/// because it should never happen. Notably, this is not the error +/// users see when `command` is invalid. +fn execFailedInChild(self: *Termio) !void { + _ = self; + const stderr = std.io.getStdErr().writer(); + try stderr.writeAll("exec failed\n"); + try stderr.writeAll("press any key to exit\n"); + + var buf: [1]u8 = undefined; + var reader = std.io.getStdIn().reader(); + _ = try reader.read(&buf); +} + +pub fn threadExit(self: *Termio, data: ThreadData) void { + // Clear out our data since we're not active anymore. + self.data = null; + + // Stop our subprocess + if (data.ev.process_exited) self.subprocess.externalExit(); + self.subprocess.stop(); + + // Quit our read thread after exiting the subprocess so that + // we don't get stuck waiting for data to stop flowing if it is + // a particularly noisy process. + _ = posix.write(data.read_thread_pipe, "x") catch |err| + log.warn("error writing to read thread quit pipe err={}", .{err}); + + if (comptime builtin.os.tag == .windows) { + // Interrupt the blocking read so the thread can see the quit message + if (windows.kernel32.CancelIoEx(data.read_thread_fd, null) == 0) { + switch (windows.kernel32.GetLastError()) { + .NOT_FOUND => {}, + else => |err| log.warn("error interrupting read thread err={}", .{err}), + } + } + } + + data.read_thread.join(); +} + +/// Update the configuration. +pub fn changeConfig(self: *Termio, config: *DerivedConfig) !void { + // The remainder of this function is modifying terminal state or + // the read thread data, all of which requires holding the renderer + // state lock. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Deinit our old config. We do this in the lock because the + // stream handler may be referencing the old config (i.e. enquiry resp) + self.config.deinit(); + self.config = config.*; + + // Update our stream handler. The stream handler uses the same + // renderer mutex so this is safe to do despite being executed + // from another thread. + if (self.data) |data| { + data.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; + data.wait_after_command = config.wait_after_command; + data.terminal_stream.handler.changeConfig(&self.config); + } + + // Update the configuration that we know about. + // + // Specific things we don't update: + // - command, working-directory: we never restart the underlying + // process so we don't care or need to know about these. + + // Update the default palette. Note this will only apply to new colors drawn + // since we decode all palette colors to RGB on usage. + self.terminal.default_palette = config.palette; + + // Update the active palette, except for any colors that were modified with + // OSC 4 + for (0..config.palette.len) |i| { + if (!self.terminal.color_palette.mask.isSet(i)) { + self.terminal.color_palette.colors[i] = config.palette[i]; + self.terminal.flags.dirty.palette = true; + } + } + + // Set the image size limits + try self.terminal.screen.kitty_images.setLimit( + self.alloc, + &self.terminal.screen, + config.image_storage_limit, + ); + try self.terminal.secondary_screen.kitty_images.setLimit( + self.alloc, + &self.terminal.secondary_screen, + config.image_storage_limit, + ); +} + +/// Resize the terminal. +pub fn resize( + self: *Termio, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + padding: renderer.Padding, +) !void { + // Update the size of our pty. + const padded_size = screen_size.subPadding(padding); + try self.subprocess.resize(grid_size, padded_size); + + // Update our cached grid size + self.grid_size = grid_size; + + // Enter the critical area that we want to keep small + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Update the size of our terminal state + try self.terminal.resize( + self.alloc, + grid_size.columns, + grid_size.rows, + ); + + // Update our pixel sizes + self.terminal.width_px = padded_size.width; + self.terminal.height_px = padded_size.height; + + // Disable synchronized output mode so that we show changes + // immediately for a resize. This is allowed by the spec. + self.terminal.modes.set(.synchronized_output, false); + + // Wake up our renderer so any changes will be shown asap + self.renderer_wakeup.notify() catch {}; + } +} + +/// Reset the synchronized output mode. This is usually called by timer +/// expiration from the termio thread. +pub fn resetSynchronizedOutput(self: *Termio) void { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.terminal.modes.set(.synchronized_output, false); + self.renderer_wakeup.notify() catch {}; +} + +/// Clear the screen. +pub fn clearScreen(self: *Termio, history: bool) !void { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // If we're on the alternate screen, we do not clear. Since this is an + // emulator-level screen clear, this messes up the running programs + // knowledge of where the cursor is and causes rendering issues. So, + // for alt screen, we do nothing. + if (self.terminal.active_screen == .alternate) return; + + // Clear our scrollback + if (history) self.terminal.eraseDisplay(.scrollback, false); + + // If we're not at a prompt, we just delete above the cursor. + if (!self.terminal.cursorIsAtPrompt()) { + if (self.terminal.screen.cursor.y > 0) { + self.terminal.screen.eraseRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + ); + } + + return; + } + + // At a prompt, we want to first fully clear the screen, and then after + // send a FF (0x0C) to the shell so that it can repaint the screen. + // Mark the current row as a not a prompt so we can properly + // clear the full screen in the next eraseDisplay call. + self.terminal.markSemanticPrompt(.command); + assert(!self.terminal.cursorIsAtPrompt()); + self.terminal.eraseDisplay(.complete, false); + } + + // If we reached here it means we're at a prompt, so we send a form-feed. + try self.queueWrite(&[_]u8{0x0C}, false); +} + +/// Scroll the viewport +pub fn scrollViewport(self: *Termio, scroll: terminal.Terminal.ScrollViewport) !void { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + try self.terminal.scrollViewport(scroll); +} + +/// Jump the viewport to the prompt. +pub fn jumpToPrompt(self: *Termio, delta: isize) !void { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.terminal.screen.scroll(.{ .delta_prompt = delta }); + } + + try self.renderer_wakeup.notify(); +} + +/// Called when the child process exited abnormally but before +/// the surface is notified. +pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", self.subprocess.args); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); + + // Modify the terminal to show our error message. This + // requires grabbing the renderer state lock. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t = self.renderer_state.terminal; + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + +pub inline fn queueWrite(self: *Termio, data: []const u8, linefeed: bool) !void { + const ev = self.data.?; + + // If our process is exited then we send our surface a message + // about it but we don't queue any more writes. + if (ev.process_exited) { + _ = ev.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + return; + } + + // We go through and chunk the data if necessary to fit into + // our cached buffers that we can queue to the stream. + var i: usize = 0; + while (i < data.len) { + const req = try ev.write_req_pool.getGrow(self.alloc); + const buf = try ev.write_buf_pool.getGrow(self.alloc); + const slice = slice: { + // The maximum end index is either the end of our data or + // the end of our buffer, whichever is smaller. + const max = @min(data.len, i + buf.len); + + // Fast + if (!linefeed) { + fastmem.copy(u8, buf, data[i..max]); + const len = max - i; + i = max; + break :slice buf[0..len]; + } + + // Slow, have to replace \r with \r\n + var buf_i: usize = 0; + while (i < data.len and buf_i < buf.len - 1) { + const ch = data[i]; + i += 1; + + if (ch != '\r') { + buf[buf_i] = ch; + buf_i += 1; + continue; + } + + // CRLF + buf[buf_i] = '\r'; + buf[buf_i + 1] = '\n'; + buf_i += 2; + } + + break :slice buf[0..buf_i]; + }; + + //for (slice) |b| log.warn("write: {x}", .{b}); + + ev.data_stream.queueWrite( + ev.loop, + &ev.write_queue, + req, + .{ .slice = slice }, + EventData, + ev, + ttyWrite, + ); + } +} + +const ThreadData = struct { + /// Allocator used for the event data + alloc: Allocator, + + /// The data that is attached to the callbacks. + ev: *EventData, + + /// Our read thread + read_thread: std.Thread, + read_thread_pipe: posix.fd_t, + read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, + + pub fn deinit(self: *ThreadData) void { + posix.close(self.read_thread_pipe); + self.ev.deinit(self.alloc); + self.alloc.destroy(self.ev); + self.* = undefined; + } +}; + +const EventData = struct { + // The preallocation size for the write request pool. This should be big + // enough to satisfy most write requests. It must be a power of 2. + const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); + + /// Mailbox for data to the writer thread. + writer_mailbox: *termio.Mailbox, + writer_wakeup: xev.Async, + + /// Mailbox for the surface. + surface_mailbox: apprt.surface.Mailbox, + + /// The stream parser. This parses the stream of escape codes and so on + /// from the child process and calls callbacks in the stream handler. + terminal_stream: terminal.Stream(StreamHandler), + + /// The shared render state + renderer_state: *renderer.State, + + /// A handle to wake up the renderer. This hints to the renderer that that + /// a repaint should happen. + renderer_wakeup: xev.Async, + + /// The mailbox for notifying the renderer of things. + renderer_mailbox: *renderer.Thread.Mailbox, + + /// The process watcher + process: xev.Process, + process_start: std.time.Instant, + process_exited: bool = false, + + /// This is used for both waiting for the process to exit and then + /// subsequently to wait for the data_stream to close. + process_wait_c: xev.Completion = .{}, + + /// The data stream is the main IO for the pty. + data_stream: xev.Stream, + + /// The event loop, + loop: *xev.Loop, + + /// The write queue for the data stream. + write_queue: xev.Stream.WriteQueue = .{}, + + /// This is the pool of available (unused) write requests. If you grab + /// one from the pool, you must put it back when you're done! + write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, + + /// The pool of available buffers for writing to the pty. + write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, + + /// Last time the cursor was reset. This is used to prevent message + /// flooding with cursor resets. + last_cursor_reset: i64 = 0, + + /// This is set to true when we've seen a title escape sequence. We use + /// this to determine if we need to default the window title. + seen_title: bool = false, + + /// The number of milliseconds below which we consider a process + /// exit to be abnormal. This is used to show an error message + /// when the process exits too quickly. + abnormal_runtime_threshold_ms: u32, + + /// If true, do not immediately send a child exited message to the + /// surface to close the surface when the command exits. + wait_after_command: bool, + + pub fn deinit(self: *EventData, alloc: Allocator) void { + // Clear our write pools. We know we aren't ever going to do + // any more IO since we stop our data stream below so we can just + // drop this. + self.write_req_pool.deinit(alloc); + self.write_buf_pool.deinit(alloc); + + // Stop our data stream + self.data_stream.deinit(); + + // Stop our process watcher + self.process.deinit(); + + // Clear any StreamHandler state + self.terminal_stream.handler.deinit(); + self.terminal_stream.deinit(); + } + + /// This queues a render operation with the renderer thread. The render + /// isn't guaranteed to happen immediately but it will happen as soon as + /// practical. + inline fn queueRender(self: *EventData) !void { + try self.renderer_wakeup.notify(); + } +}; + +fn processExit( + ev_: ?*EventData, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Process.WaitError!u32, +) xev.CallbackAction { + const exit_code = r catch unreachable; + + const ev = ev_.?; + ev.process_exited = true; + + // Determine how long the process was running for. + const runtime_ms: ?u64 = runtime: { + const process_end = std.time.Instant.now() catch break :runtime null; + const runtime_ns = process_end.since(ev.process_start); + const runtime_ms = runtime_ns / std.time.ns_per_ms; + break :runtime runtime_ms; + }; + log.debug( + "child process exited status={} runtime={}ms", + .{ exit_code, runtime_ms orelse 0 }, + ); + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (runtime_ms) |runtime| runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime !builtin.target.isDarwin()) { + // If our exit code is zero, then the command was successful + // and we don't ever consider it abnormal. + if (exit_code == 0) break :runtime; + } + + // Our runtime always has to be under the threshold to be + // considered abnormal. This is because a user can always + // manually do something like `exit 1` in their shell to + // force the exit code to be non-zero. We only want to detect + // abnormal exits that happen so quickly the user can't react. + if (runtime > ev.abnormal_runtime_threshold_ms) break :runtime; + log.warn("abnormal process exit detected, showing error message", .{}); + + // Notify our main writer thread which has access to more + // information so it can show a better error message. + _ = ev.writer_mailbox.push(.{ + .child_exited_abnormally = .{ + .exit_code = exit_code, + .runtime_ms = runtime, + }, + }, .{ .forever = {} }); + ev.writer_wakeup.notify() catch break :runtime; + + return .disarm; + } + + // If we're purposely waiting then we just return since the process + // exited flag is set to true. This allows the terminal window to remain + // open. + if (ev.wait_after_command) { + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. + terminal: { + ev.renderer_state.mutex.lock(); + defer ev.renderer_state.mutex.unlock(); + const t = ev.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + + return .disarm; + } + + // Notify our surface we want to close + _ = ev.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + + return .disarm; +} + +fn ttyWrite( + ev_: ?*EventData, + _: *xev.Loop, + _: *xev.Completion, + _: xev.Stream, + _: xev.WriteBuffer, + r: xev.Stream.WriteError!usize, +) xev.CallbackAction { + const ev = ev_.?; + ev.write_req_pool.put(); + ev.write_buf_pool.put(); + + const d = r catch |err| { + log.err("write error: {}", .{err}); + return .disarm; + }; + _ = d; + //log.info("WROTE: {d}", .{d}); + + return .disarm; +} + +/// Subprocess manages the lifecycle of the shell subprocess. +const Subprocess = struct { + /// If we build with flatpak support then we have to keep track of + /// a potential execution on the host. + const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; + + arena: std.heap.ArenaAllocator, + cwd: ?[]const u8, + env: EnvMap, + args: [][]const u8, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + pty: ?Pty = null, + command: ?Command = null, + flatpak_command: ?FlatpakHostCommand = null, + linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, + + /// Initialize the subprocess. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { + // We have a lot of maybe-allocations that all share the same lifetime + // so use an arena so we don't end up in an accounting nightmare. + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Set our env vars. For Flatpak builds running in Flatpak we don't + // inherit our environment because the login shell on the host side + // will get it. + var env = env: { + if (comptime build_config.flatpak) { + if (internal_os.isFlatpak()) { + break :env std.process.EnvMap.init(alloc); + } + } + + break :env try std.process.getEnvMap(alloc); + }; + errdefer env.deinit(); + + // If we have a resources dir then set our env var + if (opts.resources_dir) |dir| { + log.info("found Ghostty resources dir: {s}", .{dir}); + try env.put("GHOSTTY_RESOURCES_DIR", dir); + } + + // Set our TERM var. This is a bit complicated because we want to use + // the ghostty TERM value but we want to only do that if we have + // ghostty in the TERMINFO database. + // + // For now, we just look up a bundled dir but in the future we should + // also load the terminfo database and look for it. + if (opts.resources_dir) |base| { + try env.put("TERM", opts.config.term); + try env.put("COLORTERM", "truecolor"); + + // Assume that the resources directory is adjacent to the terminfo + // database + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ + std.fs.path.dirname(base) orelse unreachable, + }); + try env.put("TERMINFO", dir); + } else { + if (comptime builtin.target.isDarwin()) { + log.warn("ghostty terminfo not found, using xterm-256color", .{}); + log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); + log.warn("you're using a valid app bundle.", .{}); + } + + try env.put("TERM", "xterm-256color"); + try env.put("COLORTERM", "truecolor"); + } + + // Add our binary to the path if we can find it. + ghostty_path: { + var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { + log.warn("failed to get ghostty exe path err={}", .{err}); + break :ghostty_path; + }; + const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; + log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); + + // We always set this so that if the shell overwrites the path + // scripts still have a way to find the Ghostty binary when + // running in Ghostty. + try env.put("GHOSTTY_BIN_DIR", exe_dir); + + // Append if we have a path. We want to append so that ghostty is + // the last priority in the path. If we don't have a path set + // then we just set it to the directory of the binary. + if (env.get("PATH")) |path| { + // Verify that our path doesn't already contain this entry + var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); + while (it.next()) |entry| { + if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; + } + + try env.put( + "PATH", + try internal_os.appendEnv(alloc, path, exe_dir), + ); + } else { + try env.put("PATH", exe_dir); + } + } + + // Add the man pages from our application bundle to MANPATH. + if (comptime builtin.target.isDarwin()) { + if (opts.resources_dir) |resources_dir| man: { + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { + log.warn("error building manpath, man pages may not be available err={}", .{err}); + break :man; + }; + + if (env.get("MANPATH")) |manpath| { + // Append to the existing MANPATH. It's very unlikely that our bundle's + // resources directory already appears here so we don't spend the time + // searching for it. + try env.put( + "MANPATH", + try internal_os.appendEnv(alloc, manpath, dir), + ); + } else { + try env.put("MANPATH", dir); + } + } + } + + // Set environment variables used by some programs (such as neovim) to detect + // which terminal emulator and version they're running under. + try env.put("TERM_PROGRAM", "ghostty"); + try env.put("TERM_PROGRAM_VERSION", build_config.version_string); + + // When embedding in macOS and running via XCode, XCode injects + // a bunch of things that break our shell process. We remove those. + if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { + if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { + env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); + env.remove("__XPC_DYLD_LIBRARY_PATH"); + env.remove("DYLD_FRAMEWORK_PATH"); + env.remove("DYLD_INSERT_LIBRARIES"); + env.remove("DYLD_LIBRARY_PATH"); + env.remove("LD_LIBRARY_PATH"); + env.remove("SECURITYSESSIONID"); + env.remove("XPC_SERVICE_NAME"); + } + + // Remove this so that running `ghostty` within Ghostty works. + env.remove("GHOSTTY_MAC_APP"); + } + + // Don't leak these environment variables to child processes. + if (comptime build_config.app_runtime == .gtk) { + env.remove("GDK_DEBUG"); + env.remove("GSK_RENDERER"); + } + + // Setup our shell integration, if we can. + const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { + const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { + .windows => "cmd.exe", + else => "sh", + }; + + const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + .none => break :shell .{ null, default_shell_command }, + .detect => null, + .bash => .bash, + .elvish => .elvish, + .fish => .fish, + .zsh => .zsh, + }; + + const dir = opts.resources_dir orelse break :shell .{ + null, + default_shell_command, + }; + + const integration = try shell_integration.setup( + alloc, + dir, + default_shell_command, + &env, + force, + opts.full_config.@"shell-integration-features", + ) orelse break :shell .{ null, default_shell_command }; + + break :shell .{ integration.shell, integration.command }; + }; + + if (integrated_shell) |shell| { + log.info( + "shell integration automatically injected shell={}", + .{shell}, + ); + } else if (opts.full_config.@"shell-integration" != .none) { + log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); + } + + // Build our args list + const args = args: { + const cap = 9; // the most we'll ever use + var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); + defer args.deinit(); + + // If we're on macOS, we have to use `login(1)` to get all of + // the proper environment variables set, a login shell, and proper + // hushlogin behavior. + if (comptime builtin.target.isDarwin()) darwin: { + const passwd = internal_os.passwd.get(alloc) catch |err| { + log.warn("failed to read passwd, not using a login shell err={}", .{err}); + break :darwin; + }; + + const username = passwd.name orelse { + log.warn("failed to get username, not using a login shell", .{}); + break :darwin; + }; + + const hush = if (passwd.home) |home| hush: { + var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { + log.warn( + "failed to open home dir, not checking for hushlogin err={}", + .{err}, + ); + break :hush false; + }; + defer dir.close(); + + break :hush if (dir.access(".hushlogin", .{})) true else |_| false; + } else false; + + const cmd = try std.fmt.allocPrint( + alloc, + "exec -l {s}", + .{shell_command}, + ); + + // The reason for executing login this way is unclear. This + // comment will attempt to explain but prepare for a truly + // unhinged reality. + // + // The first major issue is that on macOS, a lot of users + // put shell configurations in ~/.bash_profile instead of + // ~/.bashrc (or equivalent for another shell). This file is only + // loaded for a login shell so macOS users expect all their terminals + // to be login shells. No other platform behaves this way and its + // totally braindead but somehow the entire dev community on + // macOS has cargo culted their way to this reality so we have to + // do it... + // + // To get a login shell, you COULD just prepend argv0 with a `-` + // but that doesn't fully work because `getlogin()` C API will + // return the wrong value, SHELL won't be set, and various + // other login behaviors that macOS users expect. + // + // The proper way is to use `login(1)`. But login(1) forces + // the working directory to change to the home directory, + // which we may not want. If we specify "-l" then we can avoid + // this behavior but now the shell isn't a login shell. + // + // There is another issue: `login(1)` only checks for ".hushlogin" + // in the working directory. This means that if we specify "-l" + // then we won't get hushlogin honored if its in the home + // directory (which is standard). To get around this, we + // check for hushlogin ourselves and if present specify the + // "-q" flag to login(1). + // + // So to get all the behaviors we want, we specify "-l" but + // execute "bash" (which is built-in to macOS). We then use + // the bash builtin "exec" to replace the process with a login + // shell ("-l" on exec) with the command we really want. + // + // We use "bash" instead of other shells that ship with macOS + // because as of macOS Sonoma, we found with a microbenchmark + // that bash can `exec` into the desired command ~2x faster + // than zsh. + // + // To figure out a lot of this logic I read the login.c + // source code in the OSS distribution Apple provides for + // macOS. + // + // Awesome. + try args.append("/usr/bin/login"); + if (hush) try args.append("-q"); + try args.append("-flp"); + + // We execute bash with "--noprofile --norc" so that it doesn't + // load startup files so that (1) our shell integration doesn't + // break and (2) user configuration doesn't mess this process + // up. + try args.append(username); + try args.append("/bin/bash"); + try args.append("--noprofile"); + try args.append("--norc"); + try args.append("-c"); + try args.append(cmd); + break :args try args.toOwnedSlice(); + } + + if (comptime builtin.os.tag == .windows) { + // We run our shell wrapped in `cmd.exe` so that we don't have + // to parse the command line ourselves if it has arguments. + + // Note we don't free any of the memory below since it is + // allocated in the arena. + const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); + const cmd = try std.fs.path.join(alloc, &[_][]const u8{ + windir, + "System32", + "cmd.exe", + }); + + try args.append(cmd); + try args.append("/C"); + } else { + // We run our shell wrapped in `/bin/sh` so that we don't have + // to parse the command line ourselves if it has arguments. + // Additionally, some environments (NixOS, I found) use /bin/sh + // to setup some environment variables that are important to + // have set. + try args.append("/bin/sh"); + if (internal_os.isFlatpak()) try args.append("-l"); + try args.append("-c"); + } + + try args.append(shell_command); + break :args try args.toOwnedSlice(); + }; + + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| + try alloc.dupe(u8, cwd) + else + null; + + // If we have a cgroup, then we copy that into our arena so the + // memory remains valid when we start. + const linux_cgroup: Command.LinuxCgroup = cgroup: { + const default = Command.linux_cgroup_default; + if (comptime builtin.os.tag != .linux) break :cgroup default; + const path = opts.linux_cgroup orelse break :cgroup default; + break :cgroup try alloc.dupe(u8, path); + }; + + // Our screen size should be our padded size + const padded_size = opts.screen_size.subPadding(opts.padding); + + return .{ + .arena = arena, + .env = env, + .cwd = cwd, + .args = args, + .grid_size = opts.grid_size, + .screen_size = padded_size, + .linux_cgroup = linux_cgroup, + }; + } + + /// Clean up the subprocess. This will stop the subprocess if it is started. + pub fn deinit(self: *Subprocess) void { + self.stop(); + if (self.pty) |*pty| pty.deinit(); + self.arena.deinit(); + self.* = undefined; + } + + /// Start the subprocess. If the subprocess is already started this + /// will crash. + pub fn start(self: *Subprocess, alloc: Allocator) !struct { + read: Pty.Fd, + write: Pty.Fd, + } { + assert(self.pty == null and self.command == null); + + // Create our pty + var pty = try Pty.open(.{ + .ws_row = @intCast(self.grid_size.rows), + .ws_col = @intCast(self.grid_size.columns), + .ws_xpixel = @intCast(self.screen_size.width), + .ws_ypixel = @intCast(self.screen_size.height), + }); + self.pty = pty; + errdefer { + pty.deinit(); + self.pty = null; + } + + log.debug("starting command command={s}", .{self.args}); + + // In flatpak, we use the HostCommand to execute our shell. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but flatpak support not built-in", .{}); + break :flatpak; + } + + // Flatpak command must have a stable pointer. + self.flatpak_command = .{ + .argv = self.args, + .env = &self.env, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + var cmd = &self.flatpak_command.?; + const pid = try cmd.spawn(alloc); + errdefer killCommandFlatpak(cmd); + + log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ + self.args[0], + pid, + }); + + // Once started, we can close the pty child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = posix.close(pty.slave); + + return .{ + .read = pty.master, + .write = pty.master, + }; + } + + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { + if (std.fs.accessAbsolute(proposed, .{})) { + break :cwd proposed; + } else |err| { + log.warn("cannot access cwd, ignoring: {}", .{err}); + break :cwd null; + } + } else null; + + // Build our subcommand + var cmd: Command = .{ + .path = self.args[0], + .args = self.args, + .env = &self.env, + .cwd = cwd, + .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, + .pre_exec = if (builtin.os.tag == .windows) null else (struct { + fn callback(cmd: *Command) void { + const sp = cmd.getData(Subprocess) orelse unreachable; + sp.childPreExec() catch |err| log.err( + "error initializing child: {}", + .{err}, + ); + } + }).callback, + .data = self, + .linux_cgroup = self.linux_cgroup, + }; + try cmd.start(alloc); + errdefer killCommand(&cmd) catch |err| { + log.warn("error killing command during cleanup err={}", .{err}); + }; + log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); + if (comptime builtin.os.tag == .linux) { + log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); + } + + self.command = cmd; + return switch (builtin.os.tag) { + .windows => .{ + .read = pty.out_pipe, + .write = pty.in_pipe, + }, + + else => .{ + .read = pty.master, + .write = pty.master, + }, + }; + } + + /// This should be called after fork but before exec in the child process. + /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before + /// exec is called; it does NOT run in the main Ghostty process. + fn childPreExec(self: *Subprocess) !void { + // Setup our pty + try self.pty.?.childPreExec(); + } + + /// Called to notify that we exited externally so we can unset our + /// running state. + pub fn externalExit(self: *Subprocess) void { + self.command = null; + } + + /// Stop the subprocess. This is safe to call anytime. This will wait + /// for the subprocess to register that it has been signalled, but not + /// for it to terminate, so it will not block. + /// This does not close the pty. + pub fn stop(self: *Subprocess) void { + // Kill our command + if (self.command) |*cmd| { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + killCommand(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + self.command = null; + } + + // Kill our Flatpak command + if (FlatpakHostCommand != void) { + if (self.flatpak_command) |*cmd| { + killCommandFlatpak(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + _ = cmd.wait() catch |err| + log.err("error waiting for command to exit: {}", .{err}); + self.flatpak_command = null; + } + } + } + + /// Resize the pty subprocess. This is safe to call anytime. + pub fn resize( + self: *Subprocess, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + ) !void { + self.grid_size = grid_size; + self.screen_size = screen_size; + + if (self.pty) |*pty| { + try pty.setSize(.{ + .ws_row = @intCast(grid_size.rows), + .ws_col = @intCast(grid_size.columns), + .ws_xpixel = @intCast(screen_size.width), + .ws_ypixel = @intCast(screen_size.height), + }); + } + } + + /// Kill the underlying subprocess. This sends a SIGHUP to the child + /// process. This also waits for the command to exit and will return the + /// exit code. + fn killCommand(command: *Command) !void { + if (command.pid) |pid| { + switch (builtin.os.tag) { + .windows => { + if (windows.kernel32.TerminateProcess(pid, 0) == 0) { + return windows.unexpectedError(windows.kernel32.GetLastError()); + } + + _ = try command.wait(false); + }, + + else => if (getpgid(pid)) |pgid| { + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + while (true) { + if (c.killpg(pgid, c.SIGHUP) < 0) { + log.warn("error killing process group pgid={}", .{pgid}); + return error.KillFailed; + } + + // See Command.zig wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + const res = posix.waitpid(pid, std.c.W.NOHANG); + if (res.pid != 0) break; + std.time.sleep(10 * std.time.ns_per_ms); + } + }, + } + } + } + + fn getpgid(pid: c.pid_t) ?c.pid_t { + // Get our process group ID. Before the child pid calls setsid + // the pgid will be ours because we forked it. Its possible that + // we may be calling this before setsid if we are killing a surface + // VERY quickly after starting it. + const my_pgid = c.getpgid(0); + + // We loop while pgid == my_pgid. The expectation if we have a valid + // pid is that setsid will eventually be called because it is the + // FIRST thing the child process does and as far as I can tell, + // setsid cannot fail. I'm sure that's not true, but I'd rather + // have a bug reported than defensively program against it now. + while (true) { + const pgid = c.getpgid(pid); + if (pgid == my_pgid) { + log.warn("pgid is our own, retrying", .{}); + std.time.sleep(10 * std.time.ns_per_ms); + continue; + } + + // Don't know why it would be zero but its not a valid pid + if (pgid == 0) return null; + + // If the pid doesn't exist then... we're done! + if (pgid == c.ESRCH) return null; + + // If we have an error we're done. + if (pgid < 0) { + log.warn("error getting pgid for kill", .{}); + return null; + } + + return pgid; + } + } + + /// Kill the underlying process started via Flatpak host command. + /// This sends a signal via the Flatpak API. + fn killCommandFlatpak(command: *FlatpakHostCommand) !void { + try command.signal(c.SIGHUP, true); + } +}; + +/// The read thread sits in a loop doing the following pseudo code: +/// +/// while (true) { blocking_read(); exit_if_eof(); process(); } +/// +/// Almost all terminal-modifying activity is from the pty read, so +/// putting this on a dedicated thread keeps performance very predictable +/// while also almost optimal. "Locking is fast, lock contention is slow." +/// and since we rarely have contention, this is fast. +/// +/// This is also empirically fast compared to putting the read into +/// an async mechanism like io_uring/epoll because the reads are generally +/// small. +/// +/// We use a basic poll syscall here because we are only monitoring two +/// fds and this is still much faster and lower overhead than any async +/// mechanism. +const ReadThread = struct { + fn threadMainPosix(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { + // Always close our end of the pipe when we exit. + defer posix.close(quit); + + // First thing, we want to set the fd to non-blocking. We do this + // so that we can try to read from the fd in a tight loop and only + // check the quit fd occasionally. + if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { + _ = posix.fcntl( + fd, + posix.F.SETFL, + flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), + ) catch |err| { + log.warn("read thread failed to set flags err={}", .{err}); + log.warn("this isn't a fatal error, but may cause performance issues", .{}); + }; + } else |err| { + log.warn("read thread failed to get flags err={}", .{err}); + log.warn("this isn't a fatal error, but may cause performance issues", .{}); + } + + // Build up the list of fds we're going to poll. We are looking + // for data on the pty and our quit notification. + var pollfds: [2]posix.pollfd = .{ + .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, + .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, + }; + + var buf: [1024]u8 = undefined; + while (true) { + // We try to read from the file descriptor as long as possible + // to maximize performance. We only check the quit fd if the + // main fd blocks. This optimizes for the realistic scenario that + // the data will eventually stop while we're trying to quit. This + // is always true because we kill the process. + while (true) { + const n = posix.read(fd, &buf) catch |err| { + switch (err) { + // This means our pty is closed. We're probably + // gracefully shutting down. + error.NotOpenForReading, + error.InputOutput, + => { + log.info("io reader exiting", .{}); + return; + }, + + // No more data, fall back to poll and check for + // exit conditions. + error.WouldBlock => break, + + else => { + log.err("io reader error err={}", .{err}); + unreachable; + }, + } + }; + + // This happens on macOS instead of WouldBlock when the + // child process dies. To be safe, we just break the loop + // and let our poll happen. + if (n == 0) break; + + // log.info("DATA: {d}", .{n}); + @call(.always_inline, process, .{ ev, buf[0..n] }); + } + + // Wait for data. + _ = posix.poll(&pollfds, -1) catch |err| { + log.warn("poll failed on read thread, exiting early err={}", .{err}); + return; + }; + + // If our quit fd is set, we're done. + if (pollfds[1].revents & posix.POLL.IN != 0) { + log.info("read thread got quit signal", .{}); + return; + } + } + } + + fn threadMainWindows(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { + // Always close our end of the pipe when we exit. + defer posix.close(quit); + + var buf: [1024]u8 = undefined; + while (true) { + while (true) { + var n: windows.DWORD = 0; + if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { + const err = windows.kernel32.GetLastError(); + switch (err) { + // Check for a quit signal + .OPERATION_ABORTED => break, + + else => { + log.err("io reader error err={}", .{err}); + unreachable; + }, + } + } + + @call(.always_inline, process, .{ ev, buf[0..n] }); + } + + var quit_bytes: windows.DWORD = 0; + if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { + const err = windows.kernel32.GetLastError(); + log.err("quit pipe reader error err={}", .{err}); + unreachable; + } + + if (quit_bytes > 0) { + log.info("read thread got quit signal", .{}); + return; + } + } + } + + fn process( + ev: *EventData, + buf: []const u8, + ) void { + // log.info("DATA: {d}", .{n}); + // log.info("DATA: {any}", .{buf[0..@intCast(usize, n)]}); + + // Whenever a character is typed, we ensure the cursor is in the + // non-blink state so it is rendered if visible. If we're under + // HEAVY read load, we don't want to send a ton of these so we + // use a timer under the covers + const now = ev.loop.now(); + if (now - ev.last_cursor_reset > 500) { + ev.last_cursor_reset = now; + _ = ev.renderer_mailbox.push(.{ + .reset_cursor_blink = {}, + }, .{ .forever = {} }); + } + + // We are modifying terminal state from here on out + ev.renderer_state.mutex.lock(); + defer ev.renderer_state.mutex.unlock(); + + // Schedule a render + ev.queueRender() catch unreachable; + + // If we have an inspector, we enter SLOW MODE because we need to + // process a byte at a time alternating between the inspector handler + // and the termio handler. This is very slow compared to our optimizations + // below but at least users only pay for it if they're using the inspector. + if (ev.renderer_state.inspector) |insp| { + for (buf, 0..) |byte, i| { + insp.recordPtyRead(buf[i .. i + 1]) catch |err| { + log.err("error recording pty read in inspector err={}", .{err}); + }; + + ev.terminal_stream.next(byte) catch |err| + log.err("error processing terminal data: {}", .{err}); + } + } else { + ev.terminal_stream.nextSlice(buf) catch |err| + log.err("error processing terminal data: {}", .{err}); + } + + // If our stream handling caused messages to be sent to the writer + // thread, then we need to wake it up so that it processes them. + if (ev.terminal_stream.handler.writer_messaged) { + ev.terminal_stream.handler.writer_messaged = false; + ev.writer_wakeup.notify() catch |err| { + log.warn("failed to wake up writer thread err={}", .{err}); + }; + } + } +}; + +/// This is used as the handler for the terminal.Stream type. This is +/// stateful and is expected to live for the entire lifetime of the terminal. +/// It is NOT VALID to stop a stream handler, create a new one, and use that +/// unless all of the member fields are copied. +const StreamHandler = struct { + ev: *EventData, + alloc: Allocator, + grid_size: *renderer.GridSize, + terminal: *terminal.Terminal, + + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + apc: terminal.apc.Handler = .{}, + + /// The DCS handler maintains DCS state. DCS is like CSI or OSC, + /// but requires more stateful parsing. This is used by functionality + /// such as XTGETTCAP. + dcs: terminal.dcs.Handler = .{}, + + /// This is set to true when a message was written to the writer + /// mailbox. This can be used by callers to determine if they need + /// to wake up the writer. + writer_messaged: bool = false, + + /// The default cursor state. This is used with CSI q. This is + /// set to true when we're currently in the default cursor state. + default_cursor: bool = true, + default_cursor_style: terminal.CursorStyle, + default_cursor_blink: ?bool, + default_cursor_color: ?terminal.color.RGB, + + /// Actual cursor color. This can be changed with OSC 12. + cursor_color: ?terminal.color.RGB, + + /// The default foreground and background color are those set by the user's + /// config file. These can be overridden by terminal applications using OSC + /// 10 and OSC 11, respectively. + default_foreground_color: terminal.color.RGB, + default_background_color: terminal.color.RGB, + + /// The actual foreground and background color. Normally this will be the + /// same as the default foreground and background color, unless changed by a + /// terminal application. + foreground_color: terminal.color.RGB, + background_color: terminal.color.RGB, + + /// The response to use for ENQ requests. The memory is owned by + /// whoever owns StreamHandler. + enquiry_response: []const u8, + + osc_color_report_format: configpkg.Config.OSCColorReportFormat, + + pub fn init( + alloc: Allocator, + ev: *EventData, + grid_size: *renderer.GridSize, + t: *terminal.Terminal, + config: *const DerivedConfig, + ) StreamHandler { + const default_cursor_color = if (config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + return .{ + .alloc = alloc, + .ev = ev, + .grid_size = grid_size, + .terminal = t, + .osc_color_report_format = config.osc_color_report_format, + .enquiry_response = config.enquiry_response, + .default_foreground_color = config.foreground.toTerminalRGB(), + .default_background_color = config.background.toTerminalRGB(), + .default_cursor_style = config.cursor_style, + .default_cursor_blink = config.cursor_blink, + .default_cursor_color = default_cursor_color, + .cursor_color = default_cursor_color, + .foreground_color = config.foreground.toTerminalRGB(), + .background_color = config.background.toTerminalRGB(), + }; + } + + pub fn deinit(self: *StreamHandler) void { + self.apc.deinit(); + self.dcs.deinit(); + } + + /// Change the configuration for this handler. + pub fn changeConfig(self: *StreamHandler, config: *DerivedConfig) void { + self.osc_color_report_format = config.osc_color_report_format; + self.enquiry_response = config.enquiry_response; + self.default_foreground_color = config.foreground.toTerminalRGB(); + self.default_background_color = config.background.toTerminalRGB(); + self.default_cursor_style = config.cursor_style; + self.default_cursor_blink = config.cursor_blink; + self.default_cursor_color = if (config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + // If our cursor is the default, then we update it immediately. + if (self.default_cursor) self.setCursorStyle(.default) catch |err| { + log.warn("failed to set default cursor style: {}", .{err}); + }; + } + + inline fn queueRender(self: *StreamHandler) !void { + try self.ev.queueRender(); + } + + inline fn surfaceMessageWriter( + self: *StreamHandler, + msg: apprt.surface.Message, + ) void { + // See messageWriter which has similar logic and explains why + // we may have to do this. + if (self.ev.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { + self.ev.renderer_state.mutex.unlock(); + defer self.ev.renderer_state.mutex.lock(); + _ = self.ev.surface_mailbox.push(msg, .{ .forever = {} }); + } + } + + inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { + // Try to write to the mailbox with an instant timeout. This is the + // fast path because we can queue without a lock. + if (self.ev.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { + // If we enter this conditional, the mailbox is full. We wake up + // the writer thread so that it can process messages to clear up + // space. However, the writer thread may require the renderer + // lock so we need to unlock. + self.ev.writer_wakeup.notify() catch |err| { + log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + return; + }; + + // Unlock the renderer state so the writer thread can acquire it. + // Then try to queue our message before continuing. This is a very + // slow path because we are having a lot of contention for data. + // But this only gets triggered in certain pathological cases. + // + // Note that writes themselves don't require a lock, but there + // are other messages in the writer mailbox (resize, focus) that + // could acquire the lock. This is why we have to release our lock + // here. + self.ev.renderer_state.mutex.unlock(); + defer self.ev.renderer_state.mutex.lock(); + _ = self.ev.writer_mailbox.push(msg, .{ .forever = {} }); + } + + // Normally, we just flag this true to wake up the writer thread + // once per batch of data. + self.writer_messaged = true; + } + + pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { + self.dcs.hook(self.alloc, dcs); + } + + pub fn dcsPut(self: *StreamHandler, byte: u8) !void { + self.dcs.put(byte); + } + + pub fn dcsUnhook(self: *StreamHandler) !void { + var cmd = self.dcs.unhook() orelse return; + defer cmd.deinit(); + + // log.warn("DCS command: {}", .{cmd}); + switch (cmd) { + .xtgettcap => |*gettcap| { + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + const response = map.get(key) orelse continue; + self.messageWriter(.{ .write_stable = response }); + } + }, + .decrqss => |decrqss| { + var response: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&response); + const writer = stream.writer(); + + // Offset the stream position to just past the response prefix. + // We will write the "payload" (if any) below. If no payload is + // written then we send an invalid DECRPSS response. + const prefix_fmt = "\x1bP{d}$r"; + const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; + stream.pos = prefix_len; + + switch (decrqss) { + // Invalid or unhandled request + .none => {}, + + .sgr => { + const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); + + // printAttributes wrote into our buffer, so adjust the stream + // position + stream.pos += buf.len; + + try writer.writeByte('m'); + }, + + .decscusr => { + const blink = self.terminal.modes.get(.cursor_blinking); + const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { + .block => if (blink) 1 else 2, + .underline => if (blink) 3 else 4, + .bar => if (blink) 5 else 6, + }; + try writer.print("{d} q", .{style}); + }, + + .decstbm => { + try writer.print("{d};{d}r", .{ + self.terminal.scrolling_region.top + 1, + self.terminal.scrolling_region.bottom + 1, + }); + }, + + .decslrm => { + // We only send a valid response when left and right + // margin mode (DECLRMM) is enabled. + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try writer.print("{d};{d}s", .{ + self.terminal.scrolling_region.left + 1, + self.terminal.scrolling_region.right + 1, + }); + } + }, + } + + // Our response is valid if we have a response payload + const valid = stream.pos > prefix_len; + + // Write the terminator + try writer.writeAll("\x1b\\"); + + // Write the response prefix into the buffer + _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); + const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); + self.messageWriter(msg); + }, + } + } + + pub fn apcStart(self: *StreamHandler) !void { + self.apc.start(); + } + + pub fn apcPut(self: *StreamHandler, byte: u8) !void { + self.apc.feed(self.alloc, byte); + } + + pub fn apcEnd(self: *StreamHandler) !void { + var cmd = self.apc.end() orelse return; + defer cmd.deinit(self.alloc); + + // log.warn("APC command: {}", .{cmd}); + switch (cmd) { + .kitty => |*kitty_cmd| { + if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { + var buf: [1024]u8 = undefined; + var buf_stream = std.io.fixedBufferStream(&buf); + try resp.encode(buf_stream.writer()); + const final = buf_stream.getWritten(); + if (final.len > 2) { + // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); + self.messageWriter(try termio.Message.writeReq(self.alloc, final)); + } + } + }, + } + } + + pub fn print(self: *StreamHandler, ch: u21) !void { + try self.terminal.print(ch); + } + + pub fn printRepeat(self: *StreamHandler, count: usize) !void { + try self.terminal.printRepeat(count); + } + + pub fn bell(self: StreamHandler) !void { + _ = self; + log.info("BELL", .{}); + } + + pub fn backspace(self: *StreamHandler) !void { + self.terminal.backspace(); + } + + pub fn horizontalTab(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTab(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTabBack(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn linefeed(self: *StreamHandler) !void { + // Small optimization: call index instead of linefeed because they're + // identical and this avoids one layer of function call overhead. + try self.terminal.index(); + } + + pub fn carriageReturn(self: *StreamHandler) !void { + self.terminal.carriageReturn(); + } + + pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorLeft(amount); + } + + pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorRight(amount); + } + + pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorDown(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorUp(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorCol(self: *StreamHandler, col: u16) !void { + self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); + } + + pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1, + self.terminal.screen.cursor.x + 1 +| offset, + ); + } + + pub fn setCursorRow(self: *StreamHandler, row: u16) !void { + self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); + } + + pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1 +| offset, + self.terminal.screen.cursor.x + 1, + ); + } + + pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { + self.terminal.setCursorPos(row, col); + } + + pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { + if (mode == .complete) { + // Whenever we erase the full display, scroll to bottom. + try self.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); + } + + self.terminal.eraseDisplay(mode, protected); + } + + pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { + self.terminal.eraseLine(mode, protected); + } + + pub fn deleteChars(self: *StreamHandler, count: usize) !void { + self.terminal.deleteChars(count); + } + + pub fn eraseChars(self: *StreamHandler, count: usize) !void { + self.terminal.eraseChars(count); + } + + pub fn insertLines(self: *StreamHandler, count: usize) !void { + self.terminal.insertLines(count); + } + + pub fn insertBlanks(self: *StreamHandler, count: usize) !void { + self.terminal.insertBlanks(count); + } + + pub fn deleteLines(self: *StreamHandler, count: usize) !void { + self.terminal.deleteLines(count); + } + + pub fn reverseIndex(self: *StreamHandler) !void { + self.terminal.reverseIndex(); + } + + pub fn index(self: *StreamHandler) !void { + try self.terminal.index(); + } + + pub fn nextLine(self: *StreamHandler) !void { + try self.terminal.index(); + self.terminal.carriageReturn(); + } + + pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { + self.terminal.setTopAndBottomMargin(top, bot); + } + + pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try self.setLeftAndRightMargin(0, 0); + } else { + try self.saveCursor(); + } + } + + pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { + self.terminal.setLeftAndRightMargin(left, right); + } + + pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { + self.terminal.flags.modify_other_keys_2 = false; + switch (format) { + .other_keys => |v| switch (v) { + .numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + }, + else => {}, + } + } + + pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { + // Get the mode value and respond. + const code: u8 = code: { + const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; + if (self.terminal.modes.get(mode)) break :code 1; + break :code 2; + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[{s}{};{}$y", + .{ + if (ansi) "" else "?", + mode_raw, + code, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { + // log.debug("save mode={}", .{mode}); + self.terminal.modes.save(mode); + } + + pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + const v = self.terminal.modes.restore(mode); + // log.debug("restore mode={} v={}", .{ mode, v }); + try self.setMode(mode, v); + } + + pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { + // Note: this function doesn't need to grab the render state or + // terminal locks because it is only called from process() which + // grabs the lock. + + // If we are setting cursor blinking, we ignore it if we have + // a default cursor blink setting set. This is a really weird + // behavior so this comment will go deep into trying to explain it. + // + // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) + // and DEC mode 12. DECSCUSR is the modern approach and has a + // way to revert to the "default" (as defined by the terminal) + // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls + // blinking and is either on or off and has no way to set a + // default. DEC mode 12 is also the more antiquated approach. + // + // The problem is that if the user specifies a desired default + // cursor blink with `cursor-style-blink`, the moment a running + // program uses DEC mode 12, the cursor blink can never be reset + // to the default without an explicit DECSCUSR. But if a program + // is using mode 12, it is by definition not using DECSCUSR. + // This makes for somewhat annoying interactions where a poorly + // (or legacy) behaved program will stop blinking, and it simply + // never restarts. + // + // To get around this, we have a special case where if the user + // specifies some explicit default cursor blink desire, we ignore + // DEC mode 12. We allow DECSCUSR to still set the cursor blink + // because programs using DECSCUSR usually are well behaved and + // reset the cursor blink to the default when they exit. + // + // To be extra safe, users can also add a manual `CSI 0 q` to + // their shell config when they render prompts to ensure the + // cursor is exactly as they request. + if (mode == .cursor_blinking and + self.default_cursor_blink != null) + { + return; + } + + // We first always set the raw mode on our mode state. + self.terminal.modes.set(mode, enabled); + + // And then some modes require additional processing. + switch (mode) { + // Just noting here that autorepeat has no effect on + // the terminal. xterm ignores this mode and so do we. + // We know about just so that we don't log that it is + // an unknown mode. + .autorepeat => {}, + + // Schedule a render since we changed colors + .reverse_colors => { + self.terminal.flags.dirty.reverse_colors = true; + try self.queueRender(); + }, + + // Origin resets cursor pos. This is called whether or not + // we're enabling or disabling origin mode and whether or + // not the value changed. + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + // When we disable left/right margin mode we need to + // reset the left/right margins. + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = false, + .clear_on_enter = false, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + .alt_screen_save_cursor_clear_enter => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = true, + .clear_on_enter = true, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + // Force resize back to the window size + .enable_mode_3 => self.terminal.resize( + self.alloc, + self.grid_size.columns, + self.grid_size.rows, + ) catch |err| { + log.err("error updating terminal size: {}", .{err}); + }, + + .@"132_column" => try self.terminal.deccolm( + self.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + // We need to start a timer to prevent the emulator being hung + // forever. + .synchronized_output => { + if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); + try self.queueRender(); + }, + + .linefeed => { + self.messageWriter(.{ .linefeed_mode = enabled }); + }, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } + + pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { + self.terminal.flags.mouse_shift_capture = if (v) .true else .false; + } + + pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { + switch (attr) { + .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), + + else => self.terminal.setAttribute(attr) catch |err| + log.warn("error setting attribute {}: {}", .{ attr, err }), + } + } + + pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screen.startHyperlink(uri, id); + } + + pub fn endHyperlink(self: *StreamHandler) !void { + self.terminal.screen.endHyperlink(); + } + + pub fn deviceAttributes( + self: *StreamHandler, + req: terminal.DeviceAttributeReq, + params: []const u16, + ) !void { + _ = params; + + // For the below, we quack as a VT220. We don't quack as + // a 420 because we don't support DCS sequences. + switch (req) { + .primary => self.messageWriter(.{ + .write_stable = "\x1B[?62;22c", + }), + + .secondary => self.messageWriter(.{ + .write_stable = "\x1B[>1;10;0c", + }), + + else => log.warn("unimplemented device attributes req: {}", .{req}), + } + } + + pub fn deviceStatusReport( + self: *StreamHandler, + req: terminal.device_status.Request, + ) !void { + switch (req) { + .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), + + .cursor_position => { + const pos: struct { + x: usize, + y: usize, + } = if (self.terminal.modes.get(.origin)) .{ + .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, + } else .{ + .x = self.terminal.screen.cursor.x, + .y = self.terminal.screen.cursor.y, + }; + + // Response always is at least 4 chars, so this leaves the + // remainder for the row/column as base-10 numbers. This + // will support a very large terminal. + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ + pos.y + 1, + pos.x + 1, + }); + msg.write_small.len = @intCast(resp.len); + + self.messageWriter(msg); + }, + + .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), + } + } + + pub fn setCursorStyle( + self: *StreamHandler, + style: terminal.CursorStyleReq, + ) !void { + // Assume we're setting to a non-default. + self.default_cursor = false; + + switch (style) { + .default => { + self.default_cursor = true; + self.terminal.screen.cursor.cursor_style = self.default_cursor_style; + self.terminal.modes.set( + .cursor_blinking, + self.default_cursor_blink orelse true, + ); + }, + + .blinking_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, false); + }, + + else => log.warn("unimplemented cursor style: {}", .{style}), + } + } + + pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { + self.terminal.setProtectedMode(mode); + } + + pub fn decaln(self: *StreamHandler) !void { + try self.terminal.decaln(); + } + + pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { + self.terminal.tabClear(cmd); + } + + pub fn tabSet(self: *StreamHandler) !void { + self.terminal.tabSet(); + } + + pub fn tabReset(self: *StreamHandler) !void { + self.terminal.tabReset(); + } + + pub fn saveCursor(self: *StreamHandler) !void { + self.terminal.saveCursor(); + } + + pub fn restoreCursor(self: *StreamHandler) !void { + try self.terminal.restoreCursor(); + } + + pub fn enquiry(self: *StreamHandler) !void { + log.debug("sending enquiry response={s}", .{self.enquiry_response}); + self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); + } + + pub fn scrollDown(self: *StreamHandler, count: usize) !void { + self.terminal.scrollDown(count); + } + + pub fn scrollUp(self: *StreamHandler, count: usize) !void { + self.terminal.scrollUp(count); + } + + pub fn setActiveStatusDisplay( + self: *StreamHandler, + req: terminal.StatusDisplay, + ) !void { + self.terminal.status_display = req; + } + + pub fn configureCharset( + self: *StreamHandler, + slot: terminal.CharsetSlot, + set: terminal.Charset, + ) !void { + self.terminal.configureCharset(slot, set); + } + + pub fn invokeCharset( + self: *StreamHandler, + active: terminal.CharsetActiveSlot, + slot: terminal.CharsetSlot, + single: bool, + ) !void { + self.terminal.invokeCharset(active, slot, single); + } + + pub fn fullReset( + self: *StreamHandler, + ) !void { + self.terminal.fullReset(); + try self.setMouseShape(.text); + } + + pub fn queryKittyKeyboard(self: *StreamHandler) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("querying kitty keyboard mode", .{}); + var data: termio.Message.WriteReq.Small.Array = undefined; + const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ + self.terminal.screen.kitty_keyboard.current().int(), + }); + + self.messageWriter(.{ + .write_small = .{ + .data = data, + .len = @intCast(resp.len), + }, + }); + } + + pub fn pushKittyKeyboard( + self: *StreamHandler, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("pushing kitty keyboard mode: {}", .{flags}); + self.terminal.screen.kitty_keyboard.push(flags); + } + + pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("popping kitty keyboard mode n={}", .{n}); + self.terminal.screen.kitty_keyboard.pop(@intCast(n)); + } + + pub fn setKittyKeyboard( + self: *StreamHandler, + mode: terminal.kitty.KeySetMode, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); + self.terminal.screen.kitty_keyboard.set(mode, flags); + } + + pub fn reportXtversion( + self: *StreamHandler, + ) !void { + log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); + var buf: [288]u8 = undefined; + const resp = try std.fmt.bufPrint( + &buf, + "\x1BP>|{s} {s}\x1B\\", + .{ + "ghostty", + build_config.version_string, + }, + ); + const msg = try termio.Message.writeReq(self.alloc, resp); + self.messageWriter(msg); + } + + //------------------------------------------------------------------------- + // OSC + + pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { + var buf: [256]u8 = undefined; + if (title.len >= buf.len) { + log.warn("change title requested larger than our buffer size, ignoring", .{}); + return; + } + + @memcpy(buf[0..title.len], title); + buf[title.len] = 0; + + // Mark that we've seen a title + self.ev.seen_title = true; + self.surfaceMessageWriter(.{ .set_title = buf }); + } + + pub fn setMouseShape( + self: *StreamHandler, + shape: terminal.MouseShape, + ) !void { + // Avoid changing the shape it it is already set to avoid excess + // cross-thread messaging. + if (self.terminal.mouse_shape == shape) return; + + self.terminal.mouse_shape = shape; + self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); + } + + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + // Note: we ignore the "kind" field and always use the standard clipboard. + // iTerm also appears to do this but other terminals seem to only allow + // certain. Let's investigate more. + + const clipboard_type: apprt.Clipboard = switch (kind) { + 'c' => .standard, + 's' => .selection, + 'p' => .primary, + else => .standard, + }; + + // Get clipboard contents + if (data.len == 1 and data[0] == '?') { + self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); + return; + } + + // Write clipboard contents + self.surfaceMessageWriter(.{ + .clipboard_write = .{ + .req = try apprt.surface.Message.WriteReq.init( + self.alloc, + data, + ), + .clipboard_type = clipboard_type, + }, + }); + } + + pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt); + self.terminal.flags.shell_redraws_prompt = redraw; + } + + pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt_continuation); + } + + pub fn promptEnd(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.input); + } + + pub fn endOfInput(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.command); + } + + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + if (builtin.os.tag == .windows) { + log.warn("reportPwd unimplemented on windows", .{}); + return; + } + + const uri = std.Uri.parse(url) catch |e| { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + }; + + if (!std.mem.eql(u8, "file", uri.scheme) and + !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) + { + log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + return; + } + + // OSC 7 is a little sketchy because anyone can send any value from + // any host (such an SSH session). The best practice terminals follow + // is to valid the hostname to be local. + const host_valid = host_valid: { + const host_component = uri.host orelse break :host_valid false; + + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const host = switch (host_component) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { + break :host_valid true; + } + + // Otherwise, it must match our hostname. + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const hostname = posix.gethostname(&buf) catch |err| { + log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); + break :host_valid false; + }; + + break :host_valid std.mem.eql(u8, host, hostname); + }; + if (!host_valid) { + log.warn("OSC 7 host must be local", .{}); + return; + } + + // We need to unescape the path. We first try to unescape onto + // the stack and fall back to heap allocation if we have to. + var pathBuf: [1024]u8 = undefined; + const path, const heap = path: { + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const path = switch (uri.path) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + + // If the path doesn't have any escapes, we can use it directly. + if (std.mem.indexOfScalar(u8, path, '%') == null) + break :path .{ path, false }; + + // First try to stack-allocate + var fba = std.heap.FixedBufferAllocator.init(&pathBuf); + if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| + break :path .{ v, false } + else |_| {} + + // Fall back to heap + if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| + break :path .{ v, true } + else |_| {} + + // Fall back to using it directly... + log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); + break :path .{ path, false }; + }; + defer if (heap) self.alloc.free(path); + + log.debug("terminal pwd: {s}", .{path}); + try self.terminal.setPwd(path); + + // If we haven't seen a title, use our pwd as the title. + if (!self.ev.seen_title) { + try self.changeWindowTitle(path); + self.ev.seen_title = false; + } + } + + /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, + /// default foreground color, and background color respectively. + pub fn reportColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + terminator: terminal.osc.Terminator, + ) !void { + if (self.osc_color_report_format == .none) return; + + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color, + .background => self.background_color, + .cursor => self.cursor_color orelse self.foreground_color, + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + }, + .none => unreachable, // early return above + }; + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn setColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + const color = try terminal.color.RGB.parse(value); + + switch (kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = color; + _ = self.ev.renderer_mailbox.push(.{ + .foreground_color = color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = color; + _ = self.ev.renderer_mailbox.push(.{ + .background_color = color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = color; + _ = self.ev.renderer_mailbox.push(.{ + .cursor_color = color, + }, .{ .forever = {} }); + }, + } + } + + pub fn resetColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + switch (kind) { + .palette => { + const mask = &self.terminal.color_palette.mask; + if (value.len == 0) { + // Find all bit positions in the mask which are set and + // reset those indices to the default palette + var it = mask.iterator(.{}); + while (it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } else { + var it = std.mem.tokenizeScalar(u8, value, ';'); + while (it.next()) |param| { + // Skip invalid parameters + const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; + if (mask.isSet(i)) { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } + } + }, + .foreground => { + self.foreground_color = self.default_foreground_color; + _ = self.ev.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = self.default_background_color; + _ = self.ev.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = self.default_cursor_color; + _ = self.ev.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + }, + } + } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + var message = apprt.surface.Message{ .desktop_notification = undefined }; + + const title_len = @min(title.len, message.desktop_notification.title.len); + @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); + message.desktop_notification.title[title_len] = 0; + + const body_len = @min(body.len, message.desktop_notification.body.len); + @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); + message.desktop_notification.body[body_len] = 0; + + self.surfaceMessageWriter(message); + } +}; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig new file mode 100644 index 000000000..51a6b03c2 --- /dev/null +++ b/src/termio/Termio.zig @@ -0,0 +1,2987 @@ +//! Primary terminal IO ("termio") state. This maintains the terminal state, +//! pty, subprocess, etc. This is flexible enough to be used in environments +//! that don't have a pty and simply provides the input/output using raw +//! bytes. +pub const Termio = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const EnvMap = std.process.EnvMap; +const posix = std.posix; +const termio = @import("../termio.zig"); +const Command = @import("../Command.zig"); +const Pty = @import("../pty.zig").Pty; +const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; +const terminal = @import("../terminal/main.zig"); +const terminfo = @import("../terminfo/main.zig"); +const xev = @import("xev"); +const renderer = @import("../renderer.zig"); +const apprt = @import("../apprt.zig"); +const fastmem = @import("../fastmem.zig"); +const internal_os = @import("../os/main.zig"); +const windows = internal_os.windows; +const configpkg = @import("../config.zig"); +const shell_integration = @import("shell_integration.zig"); + +const log = std.log.scoped(.io_exec); + +const c = @cImport({ + @cInclude("errno.h"); + @cInclude("signal.h"); + @cInclude("unistd.h"); +}); + +/// True if we should disable the kitty keyboard protocol. We have to +/// disable this on GLFW because GLFW input events don't support the +/// correct granularity of events. +const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; + +/// Allocator +alloc: Allocator, + +/// This is the pty fd created for the subcommand. +subprocess: Subprocess, + +/// The derived configuration for this termio implementation. +config: DerivedConfig, + +/// The terminal emulator internal state. This is the abstract "terminal" +/// that manages input, grid updating, etc. and is renderer-agnostic. It +/// just stores internal state about a grid. +terminal: terminal.Terminal, + +/// The shared render state +renderer_state: *renderer.State, + +/// A handle to wake up the renderer. This hints to the renderer that that +/// a repaint should happen. +renderer_wakeup: xev.Async, + +/// The mailbox for notifying the renderer of things. +renderer_mailbox: *renderer.Thread.Mailbox, + +/// The mailbox for communicating with the surface. +surface_mailbox: apprt.surface.Mailbox, + +/// The cached grid size whenever a resize is called. +grid_size: renderer.GridSize, + +/// The data associated with the currently running thread. +data: ?*EventData, + +/// The configuration for this IO that is derived from the main +/// configuration. This must be exported so that we don't need to +/// pass around Config pointers which makes memory management a pain. +pub const DerivedConfig = struct { + arena: ArenaAllocator, + + palette: terminal.color.Palette, + image_storage_limit: usize, + cursor_style: terminal.CursorStyle, + cursor_blink: ?bool, + cursor_color: ?configpkg.Config.Color, + foreground: configpkg.Config.Color, + background: configpkg.Config.Color, + osc_color_report_format: configpkg.Config.OSCColorReportFormat, + term: []const u8, + grapheme_width_method: configpkg.Config.GraphemeWidthMethod, + abnormal_runtime_threshold_ms: u32, + wait_after_command: bool, + enquiry_response: []const u8, + + pub fn init( + alloc_gpa: Allocator, + config: *const configpkg.Config, + ) !DerivedConfig { + var arena = ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + return .{ + .palette = config.palette.value, + .image_storage_limit = config.@"image-storage-limit", + .cursor_style = config.@"cursor-style", + .cursor_blink = config.@"cursor-style-blink", + .cursor_color = config.@"cursor-color", + .foreground = config.foreground, + .background = config.background, + .osc_color_report_format = config.@"osc-color-report-format", + .term = try alloc.dupe(u8, config.term), + .grapheme_width_method = config.@"grapheme-width-method", + .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", + .wait_after_command = config.@"wait-after-command", + .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), + + // This has to be last so that we copy AFTER the arena allocations + // above happen (Zig assigns in order). + .arena = arena, + }; + } + + pub fn deinit(self: *DerivedConfig) void { + self.arena.deinit(); + } +}; + +/// Initialize the termio state. +/// +/// This will also start the child process if the termio is configured +/// to run a child process. +pub fn init(alloc: Allocator, opts: termio.Options) !Termio { + // Create our terminal + var term = try terminal.Terminal.init(alloc, .{ + .cols = opts.grid_size.columns, + .rows = opts.grid_size.rows, + .max_scrollback = opts.full_config.@"scrollback-limit", + }); + errdefer term.deinit(alloc); + term.default_palette = opts.config.palette; + term.color_palette.colors = opts.config.palette; + + // Setup our initial grapheme cluster support if enabled. We use a + // switch to ensure we get a compiler error if more cases are added. + switch (opts.config.grapheme_width_method) { + .unicode => term.modes.set(.grapheme_cluster, true), + .legacy => {}, + } + + // Set the image size limits + try term.screen.kitty_images.setLimit( + alloc, + &term.screen, + opts.config.image_storage_limit, + ); + try term.secondary_screen.kitty_images.setLimit( + alloc, + &term.secondary_screen, + opts.config.image_storage_limit, + ); + + // Set default cursor blink settings + term.modes.set( + .cursor_blinking, + opts.config.cursor_blink orelse true, + ); + + // Set our default cursor style + term.screen.cursor.cursor_style = opts.config.cursor_style; + + var subprocess = try Subprocess.init(alloc, opts); + errdefer subprocess.deinit(); + + // If we have an initial pwd requested by the subprocess, then we + // set that on the terminal now. This allows rapidly initializing + // new surfaces to use the proper pwd. + if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { + log.warn("error setting initial pwd err={}", .{err}); + }; + + // Initial width/height based on subprocess + term.width_px = subprocess.screen_size.width; + term.height_px = subprocess.screen_size.height; + + return .{ + .alloc = alloc, + .terminal = term, + .subprocess = subprocess, + .config = opts.config, + .renderer_state = opts.renderer_state, + .renderer_wakeup = opts.renderer_wakeup, + .renderer_mailbox = opts.renderer_mailbox, + .surface_mailbox = opts.surface_mailbox, + .grid_size = opts.grid_size, + .data = null, + }; +} + +pub fn deinit(self: *Termio) void { + self.subprocess.deinit(); + self.terminal.deinit(self.alloc); + self.config.deinit(); +} + +pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { + assert(self.data == null); + const alloc = self.alloc; + + // Start our subprocess + const pty_fds = self.subprocess.start(alloc) catch |err| { + // If we specifically got this error then we are in the forked + // process and our child failed to execute. In that case + if (err != error.Termio) return err; + + // Output an error message about the exec faililng and exit. + // This generally should NOT happen because we always wrap + // our command execution either in login (macOS) or /bin/sh + // (Linux) which are usually guaranteed to exist. Still, we + // want to handle this scenario. + self.execFailedInChild() catch {}; + posix.exit(1); + }; + errdefer self.subprocess.stop(); + const pid = pid: { + const command = self.subprocess.command orelse return error.ProcessNotStarted; + break :pid command.pid orelse return error.ProcessNoPid; + }; + + // Track our process start time so we know how long it was + // running for. + const process_start = try std.time.Instant.now(); + + // Create our pipe that we'll use to kill our read thread. + // pipe[0] is the read end, pipe[1] is the write end. + const pipe = try internal_os.pipe(); + errdefer posix.close(pipe[0]); + errdefer posix.close(pipe[1]); + + // Setup our data that is used for callbacks + var ev_data_ptr = try alloc.create(EventData); + errdefer alloc.destroy(ev_data_ptr); + + // Setup our stream so that we can write. + var stream = xev.Stream.initFd(pty_fds.write); + errdefer stream.deinit(); + + // Wakeup watcher for the writer thread. + var wakeup = try xev.Async.init(); + errdefer wakeup.deinit(); + + // Watcher to detect subprocess exit + var process = try xev.Process.init(pid); + errdefer process.deinit(); + + // Setup our event data before we start + ev_data_ptr.* = .{ + .writer_mailbox = thread.mailbox, + .writer_wakeup = thread.wakeup, + .surface_mailbox = self.surface_mailbox, + .renderer_state = self.renderer_state, + .renderer_wakeup = self.renderer_wakeup, + .renderer_mailbox = self.renderer_mailbox, + .process = process, + .data_stream = stream, + .loop = &thread.loop, + .terminal_stream = .{ + .handler = StreamHandler.init( + self.alloc, + ev_data_ptr, + &self.grid_size, + &self.terminal, + &self.config, + ), + .parser = .{ + .osc_parser = .{ + // Populate the OSC parser allocator (optional) because + // we want to support large OSC payloads such as OSC 52. + .alloc = self.alloc, + }, + }, + }, + }; + errdefer ev_data_ptr.deinit(self.alloc); + + // Store our data so our callbacks can access it + self.data = ev_data_ptr; + errdefer self.data = null; + + // Start our process watcher + process.wait( + ev_data_ptr.loop, + &ev_data_ptr.process_wait_c, + ThreadData, + data, + processExit, + ); + + // Start our reader thread + const read_thread = try std.Thread.spawn( + .{}, + if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, + .{ pty_fds.read, ev_data_ptr, pipe[0] }, + ); + read_thread.setName("io-reader") catch {}; + + // Return our thread data + data.* = .{ + .alloc = alloc, + .ev = ev_data_ptr, + .reader = .{ .exec = .{ + .start = process_start, + .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, + .wait_after_command = self.config.wait_after_command, + } }, + .read_thread = read_thread, + .read_thread_pipe = pipe[1], + .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, + }; +} + +/// This outputs an error message when exec failed and we are the +/// child process. This returns so the caller should probably exit +/// after calling this. +/// +/// Note that this usually is only called under very very rare +/// circumstances because we wrap our command execution in login +/// (macOS) or /bin/sh (Linux). So this output can be pretty crude +/// because it should never happen. Notably, this is not the error +/// users see when `command` is invalid. +fn execFailedInChild(self: *Termio) !void { + _ = self; + const stderr = std.io.getStdErr().writer(); + try stderr.writeAll("exec failed\n"); + try stderr.writeAll("press any key to exit\n"); + + var buf: [1]u8 = undefined; + var reader = std.io.getStdIn().reader(); + _ = try reader.read(&buf); +} + +pub fn threadExit(self: *Termio, data: *ThreadData) void { + // Clear out our data since we're not active anymore. + self.data = null; + + // Stop our reader + switch (data.reader) { + .manual => {}, + + .exec => |exec| { + if (exec.exited) self.subprocess.externalExit(); + self.subprocess.stop(); + + // Quit our read thread after exiting the subprocess so that + // we don't get stuck waiting for data to stop flowing if it is + // a particularly noisy process. + _ = posix.write(data.read_thread_pipe, "x") catch |err| + log.warn("error writing to read thread quit pipe err={}", .{err}); + + if (comptime builtin.os.tag == .windows) { + // Interrupt the blocking read so the thread can see the quit message + if (windows.kernel32.CancelIoEx(data.read_thread_fd, null) == 0) { + switch (windows.kernel32.GetLastError()) { + .NOT_FOUND => {}, + else => |err| log.warn("error interrupting read thread err={}", .{err}), + } + } + } + + data.read_thread.join(); + }, + } +} + +/// Update the configuration. +pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !void { + // The remainder of this function is modifying terminal state or + // the read thread data, all of which requires holding the renderer + // state lock. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Deinit our old config. We do this in the lock because the + // stream handler may be referencing the old config (i.e. enquiry resp) + self.config.deinit(); + self.config = config.*; + + // Update our stream handler. The stream handler uses the same + // renderer mutex so this is safe to do despite being executed + // from another thread. + td.ev.terminal_stream.handler.changeConfig(&self.config); + switch (td.reader) { + .manual => {}, + .exec => |*exec| { + exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; + exec.wait_after_command = config.wait_after_command; + }, + } + + // Update the configuration that we know about. + // + // Specific things we don't update: + // - command, working-directory: we never restart the underlying + // process so we don't care or need to know about these. + + // Update the default palette. Note this will only apply to new colors drawn + // since we decode all palette colors to RGB on usage. + self.terminal.default_palette = config.palette; + + // Update the active palette, except for any colors that were modified with + // OSC 4 + for (0..config.palette.len) |i| { + if (!self.terminal.color_palette.mask.isSet(i)) { + self.terminal.color_palette.colors[i] = config.palette[i]; + self.terminal.flags.dirty.palette = true; + } + } + + // Set the image size limits + try self.terminal.screen.kitty_images.setLimit( + self.alloc, + &self.terminal.screen, + config.image_storage_limit, + ); + try self.terminal.secondary_screen.kitty_images.setLimit( + self.alloc, + &self.terminal.secondary_screen, + config.image_storage_limit, + ); +} + +/// Resize the terminal. +pub fn resize( + self: *Termio, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + padding: renderer.Padding, +) !void { + // Update the size of our pty. + const padded_size = screen_size.subPadding(padding); + try self.subprocess.resize(grid_size, padded_size); + + // Update our cached grid size + self.grid_size = grid_size; + + // Enter the critical area that we want to keep small + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Update the size of our terminal state + try self.terminal.resize( + self.alloc, + grid_size.columns, + grid_size.rows, + ); + + // Update our pixel sizes + self.terminal.width_px = padded_size.width; + self.terminal.height_px = padded_size.height; + + // Disable synchronized output mode so that we show changes + // immediately for a resize. This is allowed by the spec. + self.terminal.modes.set(.synchronized_output, false); + + // Wake up our renderer so any changes will be shown asap + self.renderer_wakeup.notify() catch {}; + } +} + +/// Reset the synchronized output mode. This is usually called by timer +/// expiration from the termio thread. +pub fn resetSynchronizedOutput(self: *Termio) void { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.terminal.modes.set(.synchronized_output, false); + self.renderer_wakeup.notify() catch {}; +} + +/// Clear the screen. +pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // If we're on the alternate screen, we do not clear. Since this is an + // emulator-level screen clear, this messes up the running programs + // knowledge of where the cursor is and causes rendering issues. So, + // for alt screen, we do nothing. + if (self.terminal.active_screen == .alternate) return; + + // Clear our scrollback + if (history) self.terminal.eraseDisplay(.scrollback, false); + + // If we're not at a prompt, we just delete above the cursor. + if (!self.terminal.cursorIsAtPrompt()) { + if (self.terminal.screen.cursor.y > 0) { + self.terminal.screen.eraseRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + ); + } + + return; + } + + // At a prompt, we want to first fully clear the screen, and then after + // send a FF (0x0C) to the shell so that it can repaint the screen. + // Mark the current row as a not a prompt so we can properly + // clear the full screen in the next eraseDisplay call. + self.terminal.markSemanticPrompt(.command); + assert(!self.terminal.cursorIsAtPrompt()); + self.terminal.eraseDisplay(.complete, false); + } + + // If we reached here it means we're at a prompt, so we send a form-feed. + try self.queueWrite(td, &[_]u8{0x0C}, false); +} + +/// Scroll the viewport +pub fn scrollViewport(self: *Termio, scroll: terminal.Terminal.ScrollViewport) !void { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + try self.terminal.scrollViewport(scroll); +} + +/// Jump the viewport to the prompt. +pub fn jumpToPrompt(self: *Termio, delta: isize) !void { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.terminal.screen.scroll(.{ .delta_prompt = delta }); + } + + try self.renderer_wakeup.notify(); +} + +/// Called when the child process exited abnormally but before +/// the surface is notified. +pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", self.subprocess.args); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); + + // Modify the terminal to show our error message. This + // requires grabbing the renderer state lock. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t = self.renderer_state.terminal; + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + +pub inline fn queueWrite( + self: *Termio, + td: *ThreadData, + data: []const u8, + linefeed: bool, +) !void { + const ev = td.ev; + + // If our process is exited then we send our surface a message + // about it but we don't queue any more writes. + switch (td.reader) { + .manual => {}, + .exec => |exec| { + if (exec.exited) { + _ = ev.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + return; + } + }, + } + + // We go through and chunk the data if necessary to fit into + // our cached buffers that we can queue to the stream. + var i: usize = 0; + while (i < data.len) { + const req = try ev.write_req_pool.getGrow(self.alloc); + const buf = try ev.write_buf_pool.getGrow(self.alloc); + const slice = slice: { + // The maximum end index is either the end of our data or + // the end of our buffer, whichever is smaller. + const max = @min(data.len, i + buf.len); + + // Fast + if (!linefeed) { + fastmem.copy(u8, buf, data[i..max]); + const len = max - i; + i = max; + break :slice buf[0..len]; + } + + // Slow, have to replace \r with \r\n + var buf_i: usize = 0; + while (i < data.len and buf_i < buf.len - 1) { + const ch = data[i]; + i += 1; + + if (ch != '\r') { + buf[buf_i] = ch; + buf_i += 1; + continue; + } + + // CRLF + buf[buf_i] = '\r'; + buf[buf_i + 1] = '\n'; + buf_i += 2; + } + + break :slice buf[0..buf_i]; + }; + + //for (slice) |b| log.warn("write: {x}", .{b}); + + ev.data_stream.queueWrite( + ev.loop, + &ev.write_queue, + req, + .{ .slice = slice }, + EventData, + ev, + ttyWrite, + ); + } +} + +fn readInternal( + ev: *EventData, + buf: []const u8, +) void { + // log.info("DATA: {d}", .{n}); + // log.info("DATA: {any}", .{buf[0..@intCast(usize, n)]}); + + // We are modifying terminal state from here on out + ev.renderer_state.mutex.lock(); + defer ev.renderer_state.mutex.unlock(); + + // Schedule a render. We can call this first because we have the lock. + ev.queueRender() catch unreachable; + + // Whenever a character is typed, we ensure the cursor is in the + // non-blink state so it is rendered if visible. If we're under + // HEAVY read load, we don't want to send a ton of these so we + // use a timer under the covers + const now = ev.loop.now(); + if (now - ev.last_cursor_reset > 500) { + ev.last_cursor_reset = now; + _ = ev.renderer_mailbox.push(.{ + .reset_cursor_blink = {}, + }, .{ .forever = {} }); + } + + // If we have an inspector, we enter SLOW MODE because we need to + // process a byte at a time alternating between the inspector handler + // and the termio handler. This is very slow compared to our optimizations + // below but at least users only pay for it if they're using the inspector. + if (ev.renderer_state.inspector) |insp| { + for (buf, 0..) |byte, i| { + insp.recordPtyRead(buf[i .. i + 1]) catch |err| { + log.err("error recording pty read in inspector err={}", .{err}); + }; + + ev.terminal_stream.next(byte) catch |err| + log.err("error processing terminal data: {}", .{err}); + } + } else { + ev.terminal_stream.nextSlice(buf) catch |err| + log.err("error processing terminal data: {}", .{err}); + } + + // If our stream handling caused messages to be sent to the writer + // thread, then we need to wake it up so that it processes them. + if (ev.terminal_stream.handler.writer_messaged) { + ev.terminal_stream.handler.writer_messaged = false; + ev.writer_wakeup.notify() catch |err| { + log.warn("failed to wake up writer thread err={}", .{err}); + }; + } +} + +/// ThreadData is the data created and stored in the termio thread +/// when the thread is started and destroyed when the thread is +/// stopped. +/// +/// All of the fields in this struct should only be read/written by +/// the termio thread. As such, a lock is not necessary. +pub const ThreadData = struct { + /// Allocator used for the event data + alloc: Allocator, + + /// The data that is attached to the callbacks. + ev: *EventData, + + /// Data associated with the reader implementation (i.e. pty/exec state) + reader: termio.reader.ThreadData, + + /// Our read thread + read_thread: std.Thread, + read_thread_pipe: posix.fd_t, + read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, + + pub fn deinit(self: *ThreadData) void { + posix.close(self.read_thread_pipe); + self.ev.deinit(self.alloc); + self.alloc.destroy(self.ev); + self.* = undefined; + } +}; + +const EventData = struct { + // The preallocation size for the write request pool. This should be big + // enough to satisfy most write requests. It must be a power of 2. + const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); + + /// Mailbox for data to the writer thread. + writer_mailbox: *termio.Mailbox, + writer_wakeup: xev.Async, + + /// Mailbox for the surface. + surface_mailbox: apprt.surface.Mailbox, + + /// The stream parser. This parses the stream of escape codes and so on + /// from the child process and calls callbacks in the stream handler. + terminal_stream: terminal.Stream(StreamHandler), + + /// The shared render state + renderer_state: *renderer.State, + + /// A handle to wake up the renderer. This hints to the renderer that that + /// a repaint should happen. + renderer_wakeup: xev.Async, + + /// The mailbox for notifying the renderer of things. + renderer_mailbox: *renderer.Thread.Mailbox, + + /// The process watcher + process: xev.Process, + + /// This is used for both waiting for the process to exit and then + /// subsequently to wait for the data_stream to close. + process_wait_c: xev.Completion = .{}, + + /// The data stream is the main IO for the pty. + data_stream: xev.Stream, + + /// The event loop, + loop: *xev.Loop, + + /// The write queue for the data stream. + write_queue: xev.Stream.WriteQueue = .{}, + + /// This is the pool of available (unused) write requests. If you grab + /// one from the pool, you must put it back when you're done! + write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, + + /// The pool of available buffers for writing to the pty. + write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, + + /// Last time the cursor was reset. This is used to prevent message + /// flooding with cursor resets. + last_cursor_reset: i64 = 0, + + /// This is set to true when we've seen a title escape sequence. We use + /// this to determine if we need to default the window title. + seen_title: bool = false, + + pub fn deinit(self: *EventData, alloc: Allocator) void { + // Clear our write pools. We know we aren't ever going to do + // any more IO since we stop our data stream below so we can just + // drop this. + self.write_req_pool.deinit(alloc); + self.write_buf_pool.deinit(alloc); + + // Stop our data stream + self.data_stream.deinit(); + + // Stop our process watcher + self.process.deinit(); + + // Clear any StreamHandler state + self.terminal_stream.handler.deinit(); + self.terminal_stream.deinit(); + } + + /// This queues a render operation with the renderer thread. The render + /// isn't guaranteed to happen immediately but it will happen as soon as + /// practical. + inline fn queueRender(self: *EventData) !void { + try self.renderer_wakeup.notify(); + } +}; + +fn processExit( + td_: ?*ThreadData, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Process.WaitError!u32, +) xev.CallbackAction { + const exit_code = r catch unreachable; + + const td = td_.?; + assert(td.reader == .exec); + const ev = td.ev; + const execdata = &td.reader.exec; + execdata.exited = true; + + // Determine how long the process was running for. + const runtime_ms: ?u64 = runtime: { + const process_end = std.time.Instant.now() catch break :runtime null; + const runtime_ns = process_end.since(execdata.start); + const runtime_ms = runtime_ns / std.time.ns_per_ms; + break :runtime runtime_ms; + }; + log.debug( + "child process exited status={} runtime={}ms", + .{ exit_code, runtime_ms orelse 0 }, + ); + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (runtime_ms) |runtime| runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime !builtin.target.isDarwin()) { + // If our exit code is zero, then the command was successful + // and we don't ever consider it abnormal. + if (exit_code == 0) break :runtime; + } + + // Our runtime always has to be under the threshold to be + // considered abnormal. This is because a user can always + // manually do something like `exit 1` in their shell to + // force the exit code to be non-zero. We only want to detect + // abnormal exits that happen so quickly the user can't react. + if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime; + log.warn("abnormal process exit detected, showing error message", .{}); + + // Notify our main writer thread which has access to more + // information so it can show a better error message. + _ = ev.writer_mailbox.push(.{ + .child_exited_abnormally = .{ + .exit_code = exit_code, + .runtime_ms = runtime, + }, + }, .{ .forever = {} }); + ev.writer_wakeup.notify() catch break :runtime; + + return .disarm; + } + + // If we're purposely waiting then we just return since the process + // exited flag is set to true. This allows the terminal window to remain + // open. + if (execdata.wait_after_command) { + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. + terminal: { + ev.renderer_state.mutex.lock(); + defer ev.renderer_state.mutex.unlock(); + const t = ev.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + + return .disarm; + } + + // Notify our surface we want to close + _ = ev.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + + return .disarm; +} + +fn ttyWrite( + ev_: ?*EventData, + _: *xev.Loop, + _: *xev.Completion, + _: xev.Stream, + _: xev.WriteBuffer, + r: xev.Stream.WriteError!usize, +) xev.CallbackAction { + const ev = ev_.?; + ev.write_req_pool.put(); + ev.write_buf_pool.put(); + + const d = r catch |err| { + log.err("write error: {}", .{err}); + return .disarm; + }; + _ = d; + //log.info("WROTE: {d}", .{d}); + + return .disarm; +} + +/// Subprocess manages the lifecycle of the shell subprocess. +const Subprocess = struct { + /// If we build with flatpak support then we have to keep track of + /// a potential execution on the host. + const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; + + arena: std.heap.ArenaAllocator, + cwd: ?[]const u8, + env: EnvMap, + args: [][]const u8, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + pty: ?Pty = null, + command: ?Command = null, + flatpak_command: ?FlatpakHostCommand = null, + linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, + + /// Initialize the subprocess. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { + // We have a lot of maybe-allocations that all share the same lifetime + // so use an arena so we don't end up in an accounting nightmare. + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Set our env vars. For Flatpak builds running in Flatpak we don't + // inherit our environment because the login shell on the host side + // will get it. + var env = env: { + if (comptime build_config.flatpak) { + if (internal_os.isFlatpak()) { + break :env std.process.EnvMap.init(alloc); + } + } + + break :env try std.process.getEnvMap(alloc); + }; + errdefer env.deinit(); + + // If we have a resources dir then set our env var + if (opts.resources_dir) |dir| { + log.info("found Ghostty resources dir: {s}", .{dir}); + try env.put("GHOSTTY_RESOURCES_DIR", dir); + } + + // Set our TERM var. This is a bit complicated because we want to use + // the ghostty TERM value but we want to only do that if we have + // ghostty in the TERMINFO database. + // + // For now, we just look up a bundled dir but in the future we should + // also load the terminfo database and look for it. + if (opts.resources_dir) |base| { + try env.put("TERM", opts.config.term); + try env.put("COLORTERM", "truecolor"); + + // Assume that the resources directory is adjacent to the terminfo + // database + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ + std.fs.path.dirname(base) orelse unreachable, + }); + try env.put("TERMINFO", dir); + } else { + if (comptime builtin.target.isDarwin()) { + log.warn("ghostty terminfo not found, using xterm-256color", .{}); + log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); + log.warn("you're using a valid app bundle.", .{}); + } + + try env.put("TERM", "xterm-256color"); + try env.put("COLORTERM", "truecolor"); + } + + // Add our binary to the path if we can find it. + ghostty_path: { + var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { + log.warn("failed to get ghostty exe path err={}", .{err}); + break :ghostty_path; + }; + const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; + log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); + + // We always set this so that if the shell overwrites the path + // scripts still have a way to find the Ghostty binary when + // running in Ghostty. + try env.put("GHOSTTY_BIN_DIR", exe_dir); + + // Append if we have a path. We want to append so that ghostty is + // the last priority in the path. If we don't have a path set + // then we just set it to the directory of the binary. + if (env.get("PATH")) |path| { + // Verify that our path doesn't already contain this entry + var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); + while (it.next()) |entry| { + if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; + } + + try env.put( + "PATH", + try internal_os.appendEnv(alloc, path, exe_dir), + ); + } else { + try env.put("PATH", exe_dir); + } + } + + // Add the man pages from our application bundle to MANPATH. + if (comptime builtin.target.isDarwin()) { + if (opts.resources_dir) |resources_dir| man: { + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { + log.warn("error building manpath, man pages may not be available err={}", .{err}); + break :man; + }; + + if (env.get("MANPATH")) |manpath| { + // Append to the existing MANPATH. It's very unlikely that our bundle's + // resources directory already appears here so we don't spend the time + // searching for it. + try env.put( + "MANPATH", + try internal_os.appendEnv(alloc, manpath, dir), + ); + } else { + try env.put("MANPATH", dir); + } + } + } + + // Set environment variables used by some programs (such as neovim) to detect + // which terminal emulator and version they're running under. + try env.put("TERM_PROGRAM", "ghostty"); + try env.put("TERM_PROGRAM_VERSION", build_config.version_string); + + // When embedding in macOS and running via XCode, XCode injects + // a bunch of things that break our shell process. We remove those. + if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { + if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { + env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); + env.remove("__XPC_DYLD_LIBRARY_PATH"); + env.remove("DYLD_FRAMEWORK_PATH"); + env.remove("DYLD_INSERT_LIBRARIES"); + env.remove("DYLD_LIBRARY_PATH"); + env.remove("LD_LIBRARY_PATH"); + env.remove("SECURITYSESSIONID"); + env.remove("XPC_SERVICE_NAME"); + } + + // Remove this so that running `ghostty` within Ghostty works. + env.remove("GHOSTTY_MAC_APP"); + } + + // Don't leak these environment variables to child processes. + if (comptime build_config.app_runtime == .gtk) { + env.remove("GDK_DEBUG"); + env.remove("GSK_RENDERER"); + } + + // Setup our shell integration, if we can. + const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { + const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { + .windows => "cmd.exe", + else => "sh", + }; + + const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + .none => break :shell .{ null, default_shell_command }, + .detect => null, + .bash => .bash, + .elvish => .elvish, + .fish => .fish, + .zsh => .zsh, + }; + + const dir = opts.resources_dir orelse break :shell .{ + null, + default_shell_command, + }; + + const integration = try shell_integration.setup( + alloc, + dir, + default_shell_command, + &env, + force, + opts.full_config.@"shell-integration-features", + ) orelse break :shell .{ null, default_shell_command }; + + break :shell .{ integration.shell, integration.command }; + }; + + if (integrated_shell) |shell| { + log.info( + "shell integration automatically injected shell={}", + .{shell}, + ); + } else if (opts.full_config.@"shell-integration" != .none) { + log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); + } + + // Build our args list + const args = args: { + const cap = 9; // the most we'll ever use + var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); + defer args.deinit(); + + // If we're on macOS, we have to use `login(1)` to get all of + // the proper environment variables set, a login shell, and proper + // hushlogin behavior. + if (comptime builtin.target.isDarwin()) darwin: { + const passwd = internal_os.passwd.get(alloc) catch |err| { + log.warn("failed to read passwd, not using a login shell err={}", .{err}); + break :darwin; + }; + + const username = passwd.name orelse { + log.warn("failed to get username, not using a login shell", .{}); + break :darwin; + }; + + const hush = if (passwd.home) |home| hush: { + var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { + log.warn( + "failed to open home dir, not checking for hushlogin err={}", + .{err}, + ); + break :hush false; + }; + defer dir.close(); + + break :hush if (dir.access(".hushlogin", .{})) true else |_| false; + } else false; + + const cmd = try std.fmt.allocPrint( + alloc, + "exec -l {s}", + .{shell_command}, + ); + + // The reason for executing login this way is unclear. This + // comment will attempt to explain but prepare for a truly + // unhinged reality. + // + // The first major issue is that on macOS, a lot of users + // put shell configurations in ~/.bash_profile instead of + // ~/.bashrc (or equivalent for another shell). This file is only + // loaded for a login shell so macOS users expect all their terminals + // to be login shells. No other platform behaves this way and its + // totally braindead but somehow the entire dev community on + // macOS has cargo culted their way to this reality so we have to + // do it... + // + // To get a login shell, you COULD just prepend argv0 with a `-` + // but that doesn't fully work because `getlogin()` C API will + // return the wrong value, SHELL won't be set, and various + // other login behaviors that macOS users expect. + // + // The proper way is to use `login(1)`. But login(1) forces + // the working directory to change to the home directory, + // which we may not want. If we specify "-l" then we can avoid + // this behavior but now the shell isn't a login shell. + // + // There is another issue: `login(1)` only checks for ".hushlogin" + // in the working directory. This means that if we specify "-l" + // then we won't get hushlogin honored if its in the home + // directory (which is standard). To get around this, we + // check for hushlogin ourselves and if present specify the + // "-q" flag to login(1). + // + // So to get all the behaviors we want, we specify "-l" but + // execute "bash" (which is built-in to macOS). We then use + // the bash builtin "exec" to replace the process with a login + // shell ("-l" on exec) with the command we really want. + // + // We use "bash" instead of other shells that ship with macOS + // because as of macOS Sonoma, we found with a microbenchmark + // that bash can `exec` into the desired command ~2x faster + // than zsh. + // + // To figure out a lot of this logic I read the login.c + // source code in the OSS distribution Apple provides for + // macOS. + // + // Awesome. + try args.append("/usr/bin/login"); + if (hush) try args.append("-q"); + try args.append("-flp"); + + // We execute bash with "--noprofile --norc" so that it doesn't + // load startup files so that (1) our shell integration doesn't + // break and (2) user configuration doesn't mess this process + // up. + try args.append(username); + try args.append("/bin/bash"); + try args.append("--noprofile"); + try args.append("--norc"); + try args.append("-c"); + try args.append(cmd); + break :args try args.toOwnedSlice(); + } + + if (comptime builtin.os.tag == .windows) { + // We run our shell wrapped in `cmd.exe` so that we don't have + // to parse the command line ourselves if it has arguments. + + // Note we don't free any of the memory below since it is + // allocated in the arena. + const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); + const cmd = try std.fs.path.join(alloc, &[_][]const u8{ + windir, + "System32", + "cmd.exe", + }); + + try args.append(cmd); + try args.append("/C"); + } else { + // We run our shell wrapped in `/bin/sh` so that we don't have + // to parse the command line ourselves if it has arguments. + // Additionally, some environments (NixOS, I found) use /bin/sh + // to setup some environment variables that are important to + // have set. + try args.append("/bin/sh"); + if (internal_os.isFlatpak()) try args.append("-l"); + try args.append("-c"); + } + + try args.append(shell_command); + break :args try args.toOwnedSlice(); + }; + + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| + try alloc.dupe(u8, cwd) + else + null; + + // If we have a cgroup, then we copy that into our arena so the + // memory remains valid when we start. + const linux_cgroup: Command.LinuxCgroup = cgroup: { + const default = Command.linux_cgroup_default; + if (comptime builtin.os.tag != .linux) break :cgroup default; + const path = opts.linux_cgroup orelse break :cgroup default; + break :cgroup try alloc.dupe(u8, path); + }; + + // Our screen size should be our padded size + const padded_size = opts.screen_size.subPadding(opts.padding); + + return .{ + .arena = arena, + .env = env, + .cwd = cwd, + .args = args, + .grid_size = opts.grid_size, + .screen_size = padded_size, + .linux_cgroup = linux_cgroup, + }; + } + + /// Clean up the subprocess. This will stop the subprocess if it is started. + pub fn deinit(self: *Subprocess) void { + self.stop(); + if (self.pty) |*pty| pty.deinit(); + self.arena.deinit(); + self.* = undefined; + } + + /// Start the subprocess. If the subprocess is already started this + /// will crash. + pub fn start(self: *Subprocess, alloc: Allocator) !struct { + read: Pty.Fd, + write: Pty.Fd, + } { + assert(self.pty == null and self.command == null); + + // Create our pty + var pty = try Pty.open(.{ + .ws_row = @intCast(self.grid_size.rows), + .ws_col = @intCast(self.grid_size.columns), + .ws_xpixel = @intCast(self.screen_size.width), + .ws_ypixel = @intCast(self.screen_size.height), + }); + self.pty = pty; + errdefer { + pty.deinit(); + self.pty = null; + } + + log.debug("starting command command={s}", .{self.args}); + + // In flatpak, we use the HostCommand to execute our shell. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but flatpak support not built-in", .{}); + break :flatpak; + } + + // Flatpak command must have a stable pointer. + self.flatpak_command = .{ + .argv = self.args, + .env = &self.env, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + var cmd = &self.flatpak_command.?; + const pid = try cmd.spawn(alloc); + errdefer killCommandFlatpak(cmd); + + log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ + self.args[0], + pid, + }); + + // Once started, we can close the pty child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = posix.close(pty.slave); + + return .{ + .read = pty.master, + .write = pty.master, + }; + } + + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { + if (std.fs.accessAbsolute(proposed, .{})) { + break :cwd proposed; + } else |err| { + log.warn("cannot access cwd, ignoring: {}", .{err}); + break :cwd null; + } + } else null; + + // Build our subcommand + var cmd: Command = .{ + .path = self.args[0], + .args = self.args, + .env = &self.env, + .cwd = cwd, + .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, + .pre_exec = if (builtin.os.tag == .windows) null else (struct { + fn callback(cmd: *Command) void { + const sp = cmd.getData(Subprocess) orelse unreachable; + sp.childPreExec() catch |err| log.err( + "error initializing child: {}", + .{err}, + ); + } + }).callback, + .data = self, + .linux_cgroup = self.linux_cgroup, + }; + try cmd.start(alloc); + errdefer killCommand(&cmd) catch |err| { + log.warn("error killing command during cleanup err={}", .{err}); + }; + log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); + if (comptime builtin.os.tag == .linux) { + log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); + } + + self.command = cmd; + return switch (builtin.os.tag) { + .windows => .{ + .read = pty.out_pipe, + .write = pty.in_pipe, + }, + + else => .{ + .read = pty.master, + .write = pty.master, + }, + }; + } + + /// This should be called after fork but before exec in the child process. + /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before + /// exec is called; it does NOT run in the main Ghostty process. + fn childPreExec(self: *Subprocess) !void { + // Setup our pty + try self.pty.?.childPreExec(); + } + + /// Called to notify that we exited externally so we can unset our + /// running state. + pub fn externalExit(self: *Subprocess) void { + self.command = null; + } + + /// Stop the subprocess. This is safe to call anytime. This will wait + /// for the subprocess to register that it has been signalled, but not + /// for it to terminate, so it will not block. + /// This does not close the pty. + pub fn stop(self: *Subprocess) void { + // Kill our command + if (self.command) |*cmd| { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + killCommand(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + self.command = null; + } + + // Kill our Flatpak command + if (FlatpakHostCommand != void) { + if (self.flatpak_command) |*cmd| { + killCommandFlatpak(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + _ = cmd.wait() catch |err| + log.err("error waiting for command to exit: {}", .{err}); + self.flatpak_command = null; + } + } + } + + /// Resize the pty subprocess. This is safe to call anytime. + pub fn resize( + self: *Subprocess, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + ) !void { + self.grid_size = grid_size; + self.screen_size = screen_size; + + if (self.pty) |*pty| { + try pty.setSize(.{ + .ws_row = @intCast(grid_size.rows), + .ws_col = @intCast(grid_size.columns), + .ws_xpixel = @intCast(screen_size.width), + .ws_ypixel = @intCast(screen_size.height), + }); + } + } + + /// Kill the underlying subprocess. This sends a SIGHUP to the child + /// process. This also waits for the command to exit and will return the + /// exit code. + fn killCommand(command: *Command) !void { + if (command.pid) |pid| { + switch (builtin.os.tag) { + .windows => { + if (windows.kernel32.TerminateProcess(pid, 0) == 0) { + return windows.unexpectedError(windows.kernel32.GetLastError()); + } + + _ = try command.wait(false); + }, + + else => if (getpgid(pid)) |pgid| { + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + while (true) { + if (c.killpg(pgid, c.SIGHUP) < 0) { + log.warn("error killing process group pgid={}", .{pgid}); + return error.KillFailed; + } + + // See Command.zig wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + const res = posix.waitpid(pid, std.c.W.NOHANG); + if (res.pid != 0) break; + std.time.sleep(10 * std.time.ns_per_ms); + } + }, + } + } + } + + fn getpgid(pid: c.pid_t) ?c.pid_t { + // Get our process group ID. Before the child pid calls setsid + // the pgid will be ours because we forked it. Its possible that + // we may be calling this before setsid if we are killing a surface + // VERY quickly after starting it. + const my_pgid = c.getpgid(0); + + // We loop while pgid == my_pgid. The expectation if we have a valid + // pid is that setsid will eventually be called because it is the + // FIRST thing the child process does and as far as I can tell, + // setsid cannot fail. I'm sure that's not true, but I'd rather + // have a bug reported than defensively program against it now. + while (true) { + const pgid = c.getpgid(pid); + if (pgid == my_pgid) { + log.warn("pgid is our own, retrying", .{}); + std.time.sleep(10 * std.time.ns_per_ms); + continue; + } + + // Don't know why it would be zero but its not a valid pid + if (pgid == 0) return null; + + // If the pid doesn't exist then... we're done! + if (pgid == c.ESRCH) return null; + + // If we have an error we're done. + if (pgid < 0) { + log.warn("error getting pgid for kill", .{}); + return null; + } + + return pgid; + } + } + + /// Kill the underlying process started via Flatpak host command. + /// This sends a signal via the Flatpak API. + fn killCommandFlatpak(command: *FlatpakHostCommand) !void { + try command.signal(c.SIGHUP, true); + } +}; + +/// The read thread sits in a loop doing the following pseudo code: +/// +/// while (true) { blocking_read(); exit_if_eof(); process(); } +/// +/// Almost all terminal-modifying activity is from the pty read, so +/// putting this on a dedicated thread keeps performance very predictable +/// while also almost optimal. "Locking is fast, lock contention is slow." +/// and since we rarely have contention, this is fast. +/// +/// This is also empirically fast compared to putting the read into +/// an async mechanism like io_uring/epoll because the reads are generally +/// small. +/// +/// We use a basic poll syscall here because we are only monitoring two +/// fds and this is still much faster and lower overhead than any async +/// mechanism. +const ReadThread = struct { + fn threadMainPosix(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { + // Always close our end of the pipe when we exit. + defer posix.close(quit); + + // First thing, we want to set the fd to non-blocking. We do this + // so that we can try to read from the fd in a tight loop and only + // check the quit fd occasionally. + if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { + _ = posix.fcntl( + fd, + posix.F.SETFL, + flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), + ) catch |err| { + log.warn("read thread failed to set flags err={}", .{err}); + log.warn("this isn't a fatal error, but may cause performance issues", .{}); + }; + } else |err| { + log.warn("read thread failed to get flags err={}", .{err}); + log.warn("this isn't a fatal error, but may cause performance issues", .{}); + } + + // Build up the list of fds we're going to poll. We are looking + // for data on the pty and our quit notification. + var pollfds: [2]posix.pollfd = .{ + .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, + .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, + }; + + var buf: [1024]u8 = undefined; + while (true) { + // We try to read from the file descriptor as long as possible + // to maximize performance. We only check the quit fd if the + // main fd blocks. This optimizes for the realistic scenario that + // the data will eventually stop while we're trying to quit. This + // is always true because we kill the process. + while (true) { + const n = posix.read(fd, &buf) catch |err| { + switch (err) { + // This means our pty is closed. We're probably + // gracefully shutting down. + error.NotOpenForReading, + error.InputOutput, + => { + log.info("io reader exiting", .{}); + return; + }, + + // No more data, fall back to poll and check for + // exit conditions. + error.WouldBlock => break, + + else => { + log.err("io reader error err={}", .{err}); + unreachable; + }, + } + }; + + // This happens on macOS instead of WouldBlock when the + // child process dies. To be safe, we just break the loop + // and let our poll happen. + if (n == 0) break; + + // log.info("DATA: {d}", .{n}); + @call(.always_inline, readInternal, .{ ev, buf[0..n] }); + } + + // Wait for data. + _ = posix.poll(&pollfds, -1) catch |err| { + log.warn("poll failed on read thread, exiting early err={}", .{err}); + return; + }; + + // If our quit fd is set, we're done. + if (pollfds[1].revents & posix.POLL.IN != 0) { + log.info("read thread got quit signal", .{}); + return; + } + } + } + + fn threadMainWindows(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { + // Always close our end of the pipe when we exit. + defer posix.close(quit); + + var buf: [1024]u8 = undefined; + while (true) { + while (true) { + var n: windows.DWORD = 0; + if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { + const err = windows.kernel32.GetLastError(); + switch (err) { + // Check for a quit signal + .OPERATION_ABORTED => break, + + else => { + log.err("io reader error err={}", .{err}); + unreachable; + }, + } + } + + @call(.always_inline, readInternal, .{ ev, buf[0..n] }); + } + + var quit_bytes: windows.DWORD = 0; + if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { + const err = windows.kernel32.GetLastError(); + log.err("quit pipe reader error err={}", .{err}); + unreachable; + } + + if (quit_bytes > 0) { + log.info("read thread got quit signal", .{}); + return; + } + } + } +}; + +/// This is used as the handler for the terminal.Stream type. This is +/// stateful and is expected to live for the entire lifetime of the terminal. +/// It is NOT VALID to stop a stream handler, create a new one, and use that +/// unless all of the member fields are copied. +const StreamHandler = struct { + ev: *EventData, + alloc: Allocator, + grid_size: *renderer.GridSize, + terminal: *terminal.Terminal, + + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + apc: terminal.apc.Handler = .{}, + + /// The DCS handler maintains DCS state. DCS is like CSI or OSC, + /// but requires more stateful parsing. This is used by functionality + /// such as XTGETTCAP. + dcs: terminal.dcs.Handler = .{}, + + /// This is set to true when a message was written to the writer + /// mailbox. This can be used by callers to determine if they need + /// to wake up the writer. + writer_messaged: bool = false, + + /// The default cursor state. This is used with CSI q. This is + /// set to true when we're currently in the default cursor state. + default_cursor: bool = true, + default_cursor_style: terminal.CursorStyle, + default_cursor_blink: ?bool, + default_cursor_color: ?terminal.color.RGB, + + /// Actual cursor color. This can be changed with OSC 12. + cursor_color: ?terminal.color.RGB, + + /// The default foreground and background color are those set by the user's + /// config file. These can be overridden by terminal applications using OSC + /// 10 and OSC 11, respectively. + default_foreground_color: terminal.color.RGB, + default_background_color: terminal.color.RGB, + + /// The actual foreground and background color. Normally this will be the + /// same as the default foreground and background color, unless changed by a + /// terminal application. + foreground_color: terminal.color.RGB, + background_color: terminal.color.RGB, + + /// The response to use for ENQ requests. The memory is owned by + /// whoever owns StreamHandler. + enquiry_response: []const u8, + + osc_color_report_format: configpkg.Config.OSCColorReportFormat, + + pub fn init( + alloc: Allocator, + ev: *EventData, + grid_size: *renderer.GridSize, + t: *terminal.Terminal, + config: *const DerivedConfig, + ) StreamHandler { + const default_cursor_color = if (config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + return .{ + .alloc = alloc, + .ev = ev, + .grid_size = grid_size, + .terminal = t, + .osc_color_report_format = config.osc_color_report_format, + .enquiry_response = config.enquiry_response, + .default_foreground_color = config.foreground.toTerminalRGB(), + .default_background_color = config.background.toTerminalRGB(), + .default_cursor_style = config.cursor_style, + .default_cursor_blink = config.cursor_blink, + .default_cursor_color = default_cursor_color, + .cursor_color = default_cursor_color, + .foreground_color = config.foreground.toTerminalRGB(), + .background_color = config.background.toTerminalRGB(), + }; + } + + pub fn deinit(self: *StreamHandler) void { + self.apc.deinit(); + self.dcs.deinit(); + } + + /// Change the configuration for this handler. + pub fn changeConfig(self: *StreamHandler, config: *DerivedConfig) void { + self.osc_color_report_format = config.osc_color_report_format; + self.enquiry_response = config.enquiry_response; + self.default_foreground_color = config.foreground.toTerminalRGB(); + self.default_background_color = config.background.toTerminalRGB(); + self.default_cursor_style = config.cursor_style; + self.default_cursor_blink = config.cursor_blink; + self.default_cursor_color = if (config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + // If our cursor is the default, then we update it immediately. + if (self.default_cursor) self.setCursorStyle(.default) catch |err| { + log.warn("failed to set default cursor style: {}", .{err}); + }; + } + + inline fn queueRender(self: *StreamHandler) !void { + try self.ev.queueRender(); + } + + inline fn surfaceMessageWriter( + self: *StreamHandler, + msg: apprt.surface.Message, + ) void { + // See messageWriter which has similar logic and explains why + // we may have to do this. + if (self.ev.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { + self.ev.renderer_state.mutex.unlock(); + defer self.ev.renderer_state.mutex.lock(); + _ = self.ev.surface_mailbox.push(msg, .{ .forever = {} }); + } + } + + inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { + // Try to write to the mailbox with an instant timeout. This is the + // fast path because we can queue without a lock. + if (self.ev.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { + // If we enter this conditional, the mailbox is full. We wake up + // the writer thread so that it can process messages to clear up + // space. However, the writer thread may require the renderer + // lock so we need to unlock. + self.ev.writer_wakeup.notify() catch |err| { + log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + return; + }; + + // Unlock the renderer state so the writer thread can acquire it. + // Then try to queue our message before continuing. This is a very + // slow path because we are having a lot of contention for data. + // But this only gets triggered in certain pathological cases. + // + // Note that writes themselves don't require a lock, but there + // are other messages in the writer mailbox (resize, focus) that + // could acquire the lock. This is why we have to release our lock + // here. + self.ev.renderer_state.mutex.unlock(); + defer self.ev.renderer_state.mutex.lock(); + _ = self.ev.writer_mailbox.push(msg, .{ .forever = {} }); + } + + // Normally, we just flag this true to wake up the writer thread + // once per batch of data. + self.writer_messaged = true; + } + + pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { + self.dcs.hook(self.alloc, dcs); + } + + pub fn dcsPut(self: *StreamHandler, byte: u8) !void { + self.dcs.put(byte); + } + + pub fn dcsUnhook(self: *StreamHandler) !void { + var cmd = self.dcs.unhook() orelse return; + defer cmd.deinit(); + + // log.warn("DCS command: {}", .{cmd}); + switch (cmd) { + .xtgettcap => |*gettcap| { + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + const response = map.get(key) orelse continue; + self.messageWriter(.{ .write_stable = response }); + } + }, + .decrqss => |decrqss| { + var response: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&response); + const writer = stream.writer(); + + // Offset the stream position to just past the response prefix. + // We will write the "payload" (if any) below. If no payload is + // written then we send an invalid DECRPSS response. + const prefix_fmt = "\x1bP{d}$r"; + const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; + stream.pos = prefix_len; + + switch (decrqss) { + // Invalid or unhandled request + .none => {}, + + .sgr => { + const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); + + // printAttributes wrote into our buffer, so adjust the stream + // position + stream.pos += buf.len; + + try writer.writeByte('m'); + }, + + .decscusr => { + const blink = self.terminal.modes.get(.cursor_blinking); + const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { + .block => if (blink) 1 else 2, + .underline => if (blink) 3 else 4, + .bar => if (blink) 5 else 6, + }; + try writer.print("{d} q", .{style}); + }, + + .decstbm => { + try writer.print("{d};{d}r", .{ + self.terminal.scrolling_region.top + 1, + self.terminal.scrolling_region.bottom + 1, + }); + }, + + .decslrm => { + // We only send a valid response when left and right + // margin mode (DECLRMM) is enabled. + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try writer.print("{d};{d}s", .{ + self.terminal.scrolling_region.left + 1, + self.terminal.scrolling_region.right + 1, + }); + } + }, + } + + // Our response is valid if we have a response payload + const valid = stream.pos > prefix_len; + + // Write the terminator + try writer.writeAll("\x1b\\"); + + // Write the response prefix into the buffer + _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); + const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); + self.messageWriter(msg); + }, + } + } + + pub fn apcStart(self: *StreamHandler) !void { + self.apc.start(); + } + + pub fn apcPut(self: *StreamHandler, byte: u8) !void { + self.apc.feed(self.alloc, byte); + } + + pub fn apcEnd(self: *StreamHandler) !void { + var cmd = self.apc.end() orelse return; + defer cmd.deinit(self.alloc); + + // log.warn("APC command: {}", .{cmd}); + switch (cmd) { + .kitty => |*kitty_cmd| { + if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { + var buf: [1024]u8 = undefined; + var buf_stream = std.io.fixedBufferStream(&buf); + try resp.encode(buf_stream.writer()); + const final = buf_stream.getWritten(); + if (final.len > 2) { + // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); + self.messageWriter(try termio.Message.writeReq(self.alloc, final)); + } + } + }, + } + } + + pub fn print(self: *StreamHandler, ch: u21) !void { + try self.terminal.print(ch); + } + + pub fn printRepeat(self: *StreamHandler, count: usize) !void { + try self.terminal.printRepeat(count); + } + + pub fn bell(self: StreamHandler) !void { + _ = self; + log.info("BELL", .{}); + } + + pub fn backspace(self: *StreamHandler) !void { + self.terminal.backspace(); + } + + pub fn horizontalTab(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTab(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTabBack(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn linefeed(self: *StreamHandler) !void { + // Small optimization: call index instead of linefeed because they're + // identical and this avoids one layer of function call overhead. + try self.terminal.index(); + } + + pub fn carriageReturn(self: *StreamHandler) !void { + self.terminal.carriageReturn(); + } + + pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorLeft(amount); + } + + pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorRight(amount); + } + + pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorDown(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorUp(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorCol(self: *StreamHandler, col: u16) !void { + self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); + } + + pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1, + self.terminal.screen.cursor.x + 1 +| offset, + ); + } + + pub fn setCursorRow(self: *StreamHandler, row: u16) !void { + self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); + } + + pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1 +| offset, + self.terminal.screen.cursor.x + 1, + ); + } + + pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { + self.terminal.setCursorPos(row, col); + } + + pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { + if (mode == .complete) { + // Whenever we erase the full display, scroll to bottom. + try self.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); + } + + self.terminal.eraseDisplay(mode, protected); + } + + pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { + self.terminal.eraseLine(mode, protected); + } + + pub fn deleteChars(self: *StreamHandler, count: usize) !void { + self.terminal.deleteChars(count); + } + + pub fn eraseChars(self: *StreamHandler, count: usize) !void { + self.terminal.eraseChars(count); + } + + pub fn insertLines(self: *StreamHandler, count: usize) !void { + self.terminal.insertLines(count); + } + + pub fn insertBlanks(self: *StreamHandler, count: usize) !void { + self.terminal.insertBlanks(count); + } + + pub fn deleteLines(self: *StreamHandler, count: usize) !void { + self.terminal.deleteLines(count); + } + + pub fn reverseIndex(self: *StreamHandler) !void { + self.terminal.reverseIndex(); + } + + pub fn index(self: *StreamHandler) !void { + try self.terminal.index(); + } + + pub fn nextLine(self: *StreamHandler) !void { + try self.terminal.index(); + self.terminal.carriageReturn(); + } + + pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { + self.terminal.setTopAndBottomMargin(top, bot); + } + + pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try self.setLeftAndRightMargin(0, 0); + } else { + try self.saveCursor(); + } + } + + pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { + self.terminal.setLeftAndRightMargin(left, right); + } + + pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { + self.terminal.flags.modify_other_keys_2 = false; + switch (format) { + .other_keys => |v| switch (v) { + .numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + }, + else => {}, + } + } + + pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { + // Get the mode value and respond. + const code: u8 = code: { + const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; + if (self.terminal.modes.get(mode)) break :code 1; + break :code 2; + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[{s}{};{}$y", + .{ + if (ansi) "" else "?", + mode_raw, + code, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { + // log.debug("save mode={}", .{mode}); + self.terminal.modes.save(mode); + } + + pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + const v = self.terminal.modes.restore(mode); + // log.debug("restore mode={} v={}", .{ mode, v }); + try self.setMode(mode, v); + } + + pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { + // Note: this function doesn't need to grab the render state or + // terminal locks because it is only called from process() which + // grabs the lock. + + // If we are setting cursor blinking, we ignore it if we have + // a default cursor blink setting set. This is a really weird + // behavior so this comment will go deep into trying to explain it. + // + // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) + // and DEC mode 12. DECSCUSR is the modern approach and has a + // way to revert to the "default" (as defined by the terminal) + // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls + // blinking and is either on or off and has no way to set a + // default. DEC mode 12 is also the more antiquated approach. + // + // The problem is that if the user specifies a desired default + // cursor blink with `cursor-style-blink`, the moment a running + // program uses DEC mode 12, the cursor blink can never be reset + // to the default without an explicit DECSCUSR. But if a program + // is using mode 12, it is by definition not using DECSCUSR. + // This makes for somewhat annoying interactions where a poorly + // (or legacy) behaved program will stop blinking, and it simply + // never restarts. + // + // To get around this, we have a special case where if the user + // specifies some explicit default cursor blink desire, we ignore + // DEC mode 12. We allow DECSCUSR to still set the cursor blink + // because programs using DECSCUSR usually are well behaved and + // reset the cursor blink to the default when they exit. + // + // To be extra safe, users can also add a manual `CSI 0 q` to + // their shell config when they render prompts to ensure the + // cursor is exactly as they request. + if (mode == .cursor_blinking and + self.default_cursor_blink != null) + { + return; + } + + // We first always set the raw mode on our mode state. + self.terminal.modes.set(mode, enabled); + + // And then some modes require additional processing. + switch (mode) { + // Just noting here that autorepeat has no effect on + // the terminal. xterm ignores this mode and so do we. + // We know about just so that we don't log that it is + // an unknown mode. + .autorepeat => {}, + + // Schedule a render since we changed colors + .reverse_colors => { + self.terminal.flags.dirty.reverse_colors = true; + try self.queueRender(); + }, + + // Origin resets cursor pos. This is called whether or not + // we're enabling or disabling origin mode and whether or + // not the value changed. + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + // When we disable left/right margin mode we need to + // reset the left/right margins. + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = false, + .clear_on_enter = false, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + .alt_screen_save_cursor_clear_enter => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = true, + .clear_on_enter = true, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + // Force resize back to the window size + .enable_mode_3 => self.terminal.resize( + self.alloc, + self.grid_size.columns, + self.grid_size.rows, + ) catch |err| { + log.err("error updating terminal size: {}", .{err}); + }, + + .@"132_column" => try self.terminal.deccolm( + self.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + // We need to start a timer to prevent the emulator being hung + // forever. + .synchronized_output => { + if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); + try self.queueRender(); + }, + + .linefeed => { + self.messageWriter(.{ .linefeed_mode = enabled }); + }, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } + + pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { + self.terminal.flags.mouse_shift_capture = if (v) .true else .false; + } + + pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { + switch (attr) { + .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), + + else => self.terminal.setAttribute(attr) catch |err| + log.warn("error setting attribute {}: {}", .{ attr, err }), + } + } + + pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screen.startHyperlink(uri, id); + } + + pub fn endHyperlink(self: *StreamHandler) !void { + self.terminal.screen.endHyperlink(); + } + + pub fn deviceAttributes( + self: *StreamHandler, + req: terminal.DeviceAttributeReq, + params: []const u16, + ) !void { + _ = params; + + // For the below, we quack as a VT220. We don't quack as + // a 420 because we don't support DCS sequences. + switch (req) { + .primary => self.messageWriter(.{ + .write_stable = "\x1B[?62;22c", + }), + + .secondary => self.messageWriter(.{ + .write_stable = "\x1B[>1;10;0c", + }), + + else => log.warn("unimplemented device attributes req: {}", .{req}), + } + } + + pub fn deviceStatusReport( + self: *StreamHandler, + req: terminal.device_status.Request, + ) !void { + switch (req) { + .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), + + .cursor_position => { + const pos: struct { + x: usize, + y: usize, + } = if (self.terminal.modes.get(.origin)) .{ + .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, + } else .{ + .x = self.terminal.screen.cursor.x, + .y = self.terminal.screen.cursor.y, + }; + + // Response always is at least 4 chars, so this leaves the + // remainder for the row/column as base-10 numbers. This + // will support a very large terminal. + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ + pos.y + 1, + pos.x + 1, + }); + msg.write_small.len = @intCast(resp.len); + + self.messageWriter(msg); + }, + + .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), + } + } + + pub fn setCursorStyle( + self: *StreamHandler, + style: terminal.CursorStyleReq, + ) !void { + // Assume we're setting to a non-default. + self.default_cursor = false; + + switch (style) { + .default => { + self.default_cursor = true; + self.terminal.screen.cursor.cursor_style = self.default_cursor_style; + self.terminal.modes.set( + .cursor_blinking, + self.default_cursor_blink orelse true, + ); + }, + + .blinking_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, false); + }, + + else => log.warn("unimplemented cursor style: {}", .{style}), + } + } + + pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { + self.terminal.setProtectedMode(mode); + } + + pub fn decaln(self: *StreamHandler) !void { + try self.terminal.decaln(); + } + + pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { + self.terminal.tabClear(cmd); + } + + pub fn tabSet(self: *StreamHandler) !void { + self.terminal.tabSet(); + } + + pub fn tabReset(self: *StreamHandler) !void { + self.terminal.tabReset(); + } + + pub fn saveCursor(self: *StreamHandler) !void { + self.terminal.saveCursor(); + } + + pub fn restoreCursor(self: *StreamHandler) !void { + try self.terminal.restoreCursor(); + } + + pub fn enquiry(self: *StreamHandler) !void { + log.debug("sending enquiry response={s}", .{self.enquiry_response}); + self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); + } + + pub fn scrollDown(self: *StreamHandler, count: usize) !void { + self.terminal.scrollDown(count); + } + + pub fn scrollUp(self: *StreamHandler, count: usize) !void { + self.terminal.scrollUp(count); + } + + pub fn setActiveStatusDisplay( + self: *StreamHandler, + req: terminal.StatusDisplay, + ) !void { + self.terminal.status_display = req; + } + + pub fn configureCharset( + self: *StreamHandler, + slot: terminal.CharsetSlot, + set: terminal.Charset, + ) !void { + self.terminal.configureCharset(slot, set); + } + + pub fn invokeCharset( + self: *StreamHandler, + active: terminal.CharsetActiveSlot, + slot: terminal.CharsetSlot, + single: bool, + ) !void { + self.terminal.invokeCharset(active, slot, single); + } + + pub fn fullReset( + self: *StreamHandler, + ) !void { + self.terminal.fullReset(); + try self.setMouseShape(.text); + } + + pub fn queryKittyKeyboard(self: *StreamHandler) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("querying kitty keyboard mode", .{}); + var data: termio.Message.WriteReq.Small.Array = undefined; + const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ + self.terminal.screen.kitty_keyboard.current().int(), + }); + + self.messageWriter(.{ + .write_small = .{ + .data = data, + .len = @intCast(resp.len), + }, + }); + } + + pub fn pushKittyKeyboard( + self: *StreamHandler, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("pushing kitty keyboard mode: {}", .{flags}); + self.terminal.screen.kitty_keyboard.push(flags); + } + + pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("popping kitty keyboard mode n={}", .{n}); + self.terminal.screen.kitty_keyboard.pop(@intCast(n)); + } + + pub fn setKittyKeyboard( + self: *StreamHandler, + mode: terminal.kitty.KeySetMode, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); + self.terminal.screen.kitty_keyboard.set(mode, flags); + } + + pub fn reportXtversion( + self: *StreamHandler, + ) !void { + log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); + var buf: [288]u8 = undefined; + const resp = try std.fmt.bufPrint( + &buf, + "\x1BP>|{s} {s}\x1B\\", + .{ + "ghostty", + build_config.version_string, + }, + ); + const msg = try termio.Message.writeReq(self.alloc, resp); + self.messageWriter(msg); + } + + //------------------------------------------------------------------------- + // OSC + + pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { + var buf: [256]u8 = undefined; + if (title.len >= buf.len) { + log.warn("change title requested larger than our buffer size, ignoring", .{}); + return; + } + + @memcpy(buf[0..title.len], title); + buf[title.len] = 0; + + // Mark that we've seen a title + self.ev.seen_title = true; + self.surfaceMessageWriter(.{ .set_title = buf }); + } + + pub fn setMouseShape( + self: *StreamHandler, + shape: terminal.MouseShape, + ) !void { + // Avoid changing the shape it it is already set to avoid excess + // cross-thread messaging. + if (self.terminal.mouse_shape == shape) return; + + self.terminal.mouse_shape = shape; + self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); + } + + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + // Note: we ignore the "kind" field and always use the standard clipboard. + // iTerm also appears to do this but other terminals seem to only allow + // certain. Let's investigate more. + + const clipboard_type: apprt.Clipboard = switch (kind) { + 'c' => .standard, + 's' => .selection, + 'p' => .primary, + else => .standard, + }; + + // Get clipboard contents + if (data.len == 1 and data[0] == '?') { + self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); + return; + } + + // Write clipboard contents + self.surfaceMessageWriter(.{ + .clipboard_write = .{ + .req = try apprt.surface.Message.WriteReq.init( + self.alloc, + data, + ), + .clipboard_type = clipboard_type, + }, + }); + } + + pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt); + self.terminal.flags.shell_redraws_prompt = redraw; + } + + pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt_continuation); + } + + pub fn promptEnd(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.input); + } + + pub fn endOfInput(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.command); + } + + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + if (builtin.os.tag == .windows) { + log.warn("reportPwd unimplemented on windows", .{}); + return; + } + + const uri = std.Uri.parse(url) catch |e| { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + }; + + if (!std.mem.eql(u8, "file", uri.scheme) and + !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) + { + log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + return; + } + + // OSC 7 is a little sketchy because anyone can send any value from + // any host (such an SSH session). The best practice terminals follow + // is to valid the hostname to be local. + const host_valid = host_valid: { + const host_component = uri.host orelse break :host_valid false; + + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const host = switch (host_component) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { + break :host_valid true; + } + + // Otherwise, it must match our hostname. + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const hostname = posix.gethostname(&buf) catch |err| { + log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); + break :host_valid false; + }; + + break :host_valid std.mem.eql(u8, host, hostname); + }; + if (!host_valid) { + log.warn("OSC 7 host must be local", .{}); + return; + } + + // We need to unescape the path. We first try to unescape onto + // the stack and fall back to heap allocation if we have to. + var pathBuf: [1024]u8 = undefined; + const path, const heap = path: { + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const path = switch (uri.path) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + + // If the path doesn't have any escapes, we can use it directly. + if (std.mem.indexOfScalar(u8, path, '%') == null) + break :path .{ path, false }; + + // First try to stack-allocate + var fba = std.heap.FixedBufferAllocator.init(&pathBuf); + if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| + break :path .{ v, false } + else |_| {} + + // Fall back to heap + if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| + break :path .{ v, true } + else |_| {} + + // Fall back to using it directly... + log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); + break :path .{ path, false }; + }; + defer if (heap) self.alloc.free(path); + + log.debug("terminal pwd: {s}", .{path}); + try self.terminal.setPwd(path); + + // If we haven't seen a title, use our pwd as the title. + if (!self.ev.seen_title) { + try self.changeWindowTitle(path); + self.ev.seen_title = false; + } + } + + /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, + /// default foreground color, and background color respectively. + pub fn reportColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + terminator: terminal.osc.Terminator, + ) !void { + if (self.osc_color_report_format == .none) return; + + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color, + .background => self.background_color, + .cursor => self.cursor_color orelse self.foreground_color, + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + }, + .none => unreachable, // early return above + }; + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn setColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + const color = try terminal.color.RGB.parse(value); + + switch (kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = color; + _ = self.ev.renderer_mailbox.push(.{ + .foreground_color = color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = color; + _ = self.ev.renderer_mailbox.push(.{ + .background_color = color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = color; + _ = self.ev.renderer_mailbox.push(.{ + .cursor_color = color, + }, .{ .forever = {} }); + }, + } + } + + pub fn resetColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + switch (kind) { + .palette => { + const mask = &self.terminal.color_palette.mask; + if (value.len == 0) { + // Find all bit positions in the mask which are set and + // reset those indices to the default palette + var it = mask.iterator(.{}); + while (it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } else { + var it = std.mem.tokenizeScalar(u8, value, ';'); + while (it.next()) |param| { + // Skip invalid parameters + const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; + if (mask.isSet(i)) { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } + } + }, + .foreground => { + self.foreground_color = self.default_foreground_color; + _ = self.ev.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = self.default_background_color; + _ = self.ev.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = self.default_cursor_color; + _ = self.ev.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + }, + } + } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + var message = apprt.surface.Message{ .desktop_notification = undefined }; + + const title_len = @min(title.len, message.desktop_notification.title.len); + @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); + message.desktop_notification.title[title_len] = 0; + + const body_len = @min(body.len, message.desktop_notification.body.len); + @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); + message.desktop_notification.body[body_len] = 0; + + self.surfaceMessageWriter(message); + } +}; diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index aceeaa636..97acb2acf 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -219,16 +219,21 @@ pub fn threadMain(self: *Thread) void { fn threadMain_(self: *Thread) !void { defer log.debug("IO thread exited", .{}); - // Start the async handlers. We start these first so that they're - // registered even if anything below fails so we can drain the mailbox. - self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); - self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + // 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 }; // Run our thread start/end callbacks. This allows the implementation - // to hook into the event loop as needed. - var data = try self.termio.threadEnter(self); - defer data.deinit(); - defer self.termio.threadExit(data); + // 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 self.termio.threadEnter(self, &cb.data); + defer cb.data.deinit(); + defer self.termio.threadExit(&cb.data); + + // Start the async handlers. + self.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", .{}); @@ -236,8 +241,14 @@ fn threadMain_(self: *Thread) !void { try self.loop.run(.until_done); } +/// This is the data passed to xev callbacks on the thread. +const CallbackData = struct { + self: *Thread, + data: termio.Termio.ThreadData = undefined, +}; + /// Drain the mailbox, handling all the messages in our terminal implementation. -fn drainMailbox(self: *Thread) !void { +fn drainMailbox(self: *Thread, data: *termio.Termio.ThreadData) !void { // If we're draining, we just drain the mailbox and return. if (self.flags.drain) { while (self.mailbox.pop()) |_| {} @@ -256,21 +267,33 @@ fn drainMailbox(self: *Thread) !void { switch (message) { .change_config => |config| { defer config.alloc.destroy(config.ptr); - try self.termio.changeConfig(config.ptr); + try self.termio.changeConfig(data, config.ptr); }, .inspector => |v| self.flags.has_inspector = v, .resize => |v| self.handleResize(v), - .clear_screen => |v| try self.termio.clearScreen(v.history), + .clear_screen => |v| try self.termio.clearScreen(data, v.history), .scroll_viewport => |v| try self.termio.scrollViewport(v), .jump_to_prompt => |v| try self.termio.jumpToPrompt(v), .start_synchronized_output => self.startSynchronizedOutput(), .linefeed_mode => |v| self.flags.linefeed_mode = v, .child_exited_abnormally => |v| try self.termio.childExitedAbnormally(v.exit_code, v.runtime_ms), - .write_small => |v| try self.termio.queueWrite(v.data[0..v.len], self.flags.linefeed_mode), - .write_stable => |v| try self.termio.queueWrite(v, self.flags.linefeed_mode), + .write_small => |v| try self.termio.queueWrite( + data, + v.data[0..v.len], + self.flags.linefeed_mode, + ), + .write_stable => |v| try self.termio.queueWrite( + data, + v, + self.flags.linefeed_mode, + ), .write_alloc => |v| { defer v.alloc.free(v.data); - try self.termio.queueWrite(v.data, self.flags.linefeed_mode); + try self.termio.queueWrite( + data, + v.data, + self.flags.linefeed_mode, + ); }, } } @@ -359,7 +382,7 @@ fn coalesceCallback( } fn wakeupCallback( - self_: ?*Thread, + cb_: ?*CallbackData, _: *xev.Loop, _: *xev.Completion, r: xev.Async.WaitError!void, @@ -369,23 +392,23 @@ fn wakeupCallback( return .rearm; }; - const t = self_.?; + const cb = cb_ orelse return .rearm; // When we wake up, we check the mailbox. Mailbox producers should // wake up our thread after publishing. - t.drainMailbox() catch |err| + cb.self.drainMailbox(&cb.data) catch |err| log.err("error draining mailbox err={}", .{err}); return .rearm; } fn stopCallback( - self_: ?*Thread, + cb_: ?*CallbackData, _: *xev.Loop, _: *xev.Completion, r: xev.Async.WaitError!void, ) xev.CallbackAction { _ = r catch unreachable; - self_.?.loop.stop(); + cb_.?.self.loop.stop(); return .disarm; } diff --git a/src/termio/reader.zig b/src/termio/reader.zig new file mode 100644 index 000000000..b83083030 --- /dev/null +++ b/src/termio/reader.zig @@ -0,0 +1,48 @@ +const std = @import("std"); +const configpkg = @import("../config.zig"); +const Command = @import("../Command.zig"); + +/// The kinds of readers. +pub const Kind = std.meta.Tag(Config); + +/// Configuration for the various reader types. +pub const Config = union(enum) { + /// Manual means that the termio caller will handle reading input + /// and passing it to the termio implementation. Note that even if you + /// select a different reader, you can always still manually provide input; + /// this config just makes it so that it is ONLY manual input. + manual: void, + + /// Exec uses posix exec to run a command with a pty. + exec: Exec, + + pub const Exec = struct { + command: ?[]const u8 = null, + shell_integration: configpkg.Config.ShellIntegration = .detect, + shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, + working_directory: ?[]const u8 = null, + linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, + }; +}; + +/// Termio thread data. See termio.ThreadData for docs. +pub const ThreadData = union(Kind) { + manual: void, + + exec: struct { + /// Process start time and boolean of whether its already exited. + start: std.time.Instant, + exited: bool = false, + + /// The number of milliseconds below which we consider a process + /// exit to be abnormal. This is used to show an error message + /// when the process exits too quickly. + abnormal_runtime_threshold_ms: u32, + + /// If true, do not immediately send a child exited message to the + /// surface to close the surface when the command exits. If this is + /// false we'll show a process exited message and wait for user input + /// to close the surface. + wait_after_command: bool, + }, +}; From 4e6e0f90c755790d97f1a3e84e2459fbc20d9874 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 10:42:55 -0700 Subject: [PATCH 03/32] termio: remove data field --- src/termio/Termio.zig | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 51a6b03c2..f1c500f0a 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -70,9 +70,6 @@ surface_mailbox: apprt.surface.Mailbox, /// The cached grid size whenever a resize is called. grid_size: renderer.GridSize, -/// The data associated with the currently running thread. -data: ?*EventData, - /// The configuration for this IO that is derived from the main /// configuration. This must be exported so that we don't need to /// pass around Config pointers which makes memory management a pain. @@ -194,7 +191,6 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio { .renderer_mailbox = opts.renderer_mailbox, .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, - .data = null, }; } @@ -205,7 +201,6 @@ pub fn deinit(self: *Termio) void { } pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { - assert(self.data == null); const alloc = self.alloc; // Start our subprocess @@ -284,10 +279,6 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo }; errdefer ev_data_ptr.deinit(self.alloc); - // Store our data so our callbacks can access it - self.data = ev_data_ptr; - errdefer self.data = null; - // Start our process watcher process.wait( ev_data_ptr.loop, @@ -341,9 +332,6 @@ fn execFailedInChild(self: *Termio) !void { } pub fn threadExit(self: *Termio, data: *ThreadData) void { - // Clear out our data since we're not active anymore. - self.data = null; - // Stop our reader switch (data.reader) { .manual => {}, From e51180e4a9bfde747aa268a43a19ad5996055acb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 10:54:04 -0700 Subject: [PATCH 04/32] termio: changeConfig on reader data --- src/termio.zig | 1 + src/termio/Termio.zig | 8 +------- src/termio/reader.zig | 11 +++++++++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/termio.zig b/src/termio.zig index b2c2c1a63..d151e46dd 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -7,6 +7,7 @@ pub const reader = @import("termio/reader.zig"); pub const Options = @import("termio/Options.zig"); pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); +pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = Thread.Mailbox; test { diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f1c500f0a..432ce158b 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -378,13 +378,7 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // renderer mutex so this is safe to do despite being executed // from another thread. td.ev.terminal_stream.handler.changeConfig(&self.config); - switch (td.reader) { - .manual => {}, - .exec => |*exec| { - exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; - exec.wait_after_command = config.wait_after_command; - }, - } + td.reader.changeConfig(&self.config); // Update the configuration that we know about. // diff --git a/src/termio/reader.zig b/src/termio/reader.zig index b83083030..99f78b561 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -1,5 +1,6 @@ const std = @import("std"); const configpkg = @import("../config.zig"); +const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); /// The kinds of readers. @@ -45,4 +46,14 @@ pub const ThreadData = union(Kind) { /// to close the surface. wait_after_command: bool, }, + + pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void { + switch (self.*) { + .manual => {}, + .exec => |*exec| { + exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; + exec.wait_after_command = config.wait_after_command; + }, + } + } }; From ffaf020576e76e67def887a02bb297744c774863 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 14:29:47 -0700 Subject: [PATCH 05/32] termio: move stream handler to dedicated file, remove dep on EventData --- src/termio.zig | 3 + src/termio/Termio.zig | 1305 +-------------------------------- src/termio/stream_handler.zig | 1273 ++++++++++++++++++++++++++++++++ 3 files changed, 1311 insertions(+), 1270 deletions(-) create mode 100644 src/termio/stream_handler.zig diff --git a/src/termio.zig b/src/termio.zig index d151e46dd..2d663addd 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -2,6 +2,8 @@ //! for taking the config, spinning up a child process, and handling IO //! with the terminal. +const stream_handler = @import("termio/stream_handler.zig"); + pub usingnamespace @import("termio/message.zig"); pub const reader = @import("termio/reader.zig"); pub const Options = @import("termio/Options.zig"); @@ -9,6 +11,7 @@ pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = Thread.Mailbox; +pub const StreamHandler = stream_handler.StreamHandler; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 432ce158b..0481a3126 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -27,6 +27,8 @@ const windows = internal_os.windows; const configpkg = @import("../config.zig"); const shell_integration = @import("shell_integration.zig"); +const StreamHandler = @import("stream_handler.zig").StreamHandler; + const log = std.log.scoped(.io_exec); const c = @cImport({ @@ -249,6 +251,36 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo var process = try xev.Process.init(pid); errdefer process.deinit(); + // Create our stream handler + const handler: StreamHandler = handler: { + const default_cursor_color = if (self.config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + break :handler .{ + .alloc = self.alloc, + .writer_mailbox = thread.mailbox, + .writer_wakeup = thread.wakeup, + .surface_mailbox = self.surface_mailbox, + .renderer_state = self.renderer_state, + .renderer_wakeup = self.renderer_wakeup, + .renderer_mailbox = self.renderer_mailbox, + .grid_size = &self.grid_size, + .terminal = &self.terminal, + .osc_color_report_format = self.config.osc_color_report_format, + .enquiry_response = self.config.enquiry_response, + .default_foreground_color = self.config.foreground.toTerminalRGB(), + .default_background_color = self.config.background.toTerminalRGB(), + .default_cursor_style = self.config.cursor_style, + .default_cursor_blink = self.config.cursor_blink, + .default_cursor_color = default_cursor_color, + .cursor_color = default_cursor_color, + .foreground_color = self.config.foreground.toTerminalRGB(), + .background_color = self.config.background.toTerminalRGB(), + }; + }; + // Setup our event data before we start ev_data_ptr.* = .{ .writer_mailbox = thread.mailbox, @@ -261,13 +293,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .data_stream = stream, .loop = &thread.loop, .terminal_stream = .{ - .handler = StreamHandler.init( - self.alloc, - ev_data_ptr, - &self.grid_size, - &self.terminal, - &self.config, - ), + .handler = handler, .parser = .{ .osc_parser = .{ // Populate the OSC parser allocator (optional) because @@ -754,7 +780,7 @@ pub const ThreadData = struct { } }; -const EventData = struct { +pub const EventData = struct { // The preallocation size for the write request pool. This should be big // enough to satisfy most write requests. It must be a power of 2. const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); @@ -807,10 +833,6 @@ const EventData = struct { /// flooding with cursor resets. last_cursor_reset: i64 = 0, - /// This is set to true when we've seen a title escape sequence. We use - /// this to determine if we need to default the window title. - seen_title: bool = false, - pub fn deinit(self: *EventData, alloc: Allocator) void { // Clear our write pools. We know we aren't ever going to do // any more IO since we stop our data stream below so we can just @@ -832,7 +854,7 @@ const EventData = struct { /// This queues a render operation with the renderer thread. The render /// isn't guaranteed to happen immediately but it will happen as soon as /// practical. - inline fn queueRender(self: *EventData) !void { + pub inline fn queueRender(self: *EventData) !void { try self.renderer_wakeup.notify(); } }; @@ -1710,1260 +1732,3 @@ const ReadThread = struct { } } }; - -/// This is used as the handler for the terminal.Stream type. This is -/// stateful and is expected to live for the entire lifetime of the terminal. -/// It is NOT VALID to stop a stream handler, create a new one, and use that -/// unless all of the member fields are copied. -const StreamHandler = struct { - ev: *EventData, - alloc: Allocator, - grid_size: *renderer.GridSize, - terminal: *terminal.Terminal, - - /// The APC command handler maintains the APC state. APC is like - /// CSI or OSC, but it is a private escape sequence that is used - /// to send commands to the terminal emulator. This is used by - /// the kitty graphics protocol. - apc: terminal.apc.Handler = .{}, - - /// The DCS handler maintains DCS state. DCS is like CSI or OSC, - /// but requires more stateful parsing. This is used by functionality - /// such as XTGETTCAP. - dcs: terminal.dcs.Handler = .{}, - - /// This is set to true when a message was written to the writer - /// mailbox. This can be used by callers to determine if they need - /// to wake up the writer. - writer_messaged: bool = false, - - /// The default cursor state. This is used with CSI q. This is - /// set to true when we're currently in the default cursor state. - default_cursor: bool = true, - default_cursor_style: terminal.CursorStyle, - default_cursor_blink: ?bool, - default_cursor_color: ?terminal.color.RGB, - - /// Actual cursor color. This can be changed with OSC 12. - cursor_color: ?terminal.color.RGB, - - /// The default foreground and background color are those set by the user's - /// config file. These can be overridden by terminal applications using OSC - /// 10 and OSC 11, respectively. - default_foreground_color: terminal.color.RGB, - default_background_color: terminal.color.RGB, - - /// The actual foreground and background color. Normally this will be the - /// same as the default foreground and background color, unless changed by a - /// terminal application. - foreground_color: terminal.color.RGB, - background_color: terminal.color.RGB, - - /// The response to use for ENQ requests. The memory is owned by - /// whoever owns StreamHandler. - enquiry_response: []const u8, - - osc_color_report_format: configpkg.Config.OSCColorReportFormat, - - pub fn init( - alloc: Allocator, - ev: *EventData, - grid_size: *renderer.GridSize, - t: *terminal.Terminal, - config: *const DerivedConfig, - ) StreamHandler { - const default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - return .{ - .alloc = alloc, - .ev = ev, - .grid_size = grid_size, - .terminal = t, - .osc_color_report_format = config.osc_color_report_format, - .enquiry_response = config.enquiry_response, - .default_foreground_color = config.foreground.toTerminalRGB(), - .default_background_color = config.background.toTerminalRGB(), - .default_cursor_style = config.cursor_style, - .default_cursor_blink = config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = default_cursor_color, - .foreground_color = config.foreground.toTerminalRGB(), - .background_color = config.background.toTerminalRGB(), - }; - } - - pub fn deinit(self: *StreamHandler) void { - self.apc.deinit(); - self.dcs.deinit(); - } - - /// Change the configuration for this handler. - pub fn changeConfig(self: *StreamHandler, config: *DerivedConfig) void { - self.osc_color_report_format = config.osc_color_report_format; - self.enquiry_response = config.enquiry_response; - self.default_foreground_color = config.foreground.toTerminalRGB(); - self.default_background_color = config.background.toTerminalRGB(); - self.default_cursor_style = config.cursor_style; - self.default_cursor_blink = config.cursor_blink; - self.default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - // If our cursor is the default, then we update it immediately. - if (self.default_cursor) self.setCursorStyle(.default) catch |err| { - log.warn("failed to set default cursor style: {}", .{err}); - }; - } - - inline fn queueRender(self: *StreamHandler) !void { - try self.ev.queueRender(); - } - - inline fn surfaceMessageWriter( - self: *StreamHandler, - msg: apprt.surface.Message, - ) void { - // See messageWriter which has similar logic and explains why - // we may have to do this. - if (self.ev.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.surface_mailbox.push(msg, .{ .forever = {} }); - } - } - - inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { - // Try to write to the mailbox with an instant timeout. This is the - // fast path because we can queue without a lock. - if (self.ev.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { - // If we enter this conditional, the mailbox is full. We wake up - // the writer thread so that it can process messages to clear up - // space. However, the writer thread may require the renderer - // lock so we need to unlock. - self.ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer, data will be dropped err={}", .{err}); - return; - }; - - // Unlock the renderer state so the writer thread can acquire it. - // Then try to queue our message before continuing. This is a very - // slow path because we are having a lot of contention for data. - // But this only gets triggered in certain pathological cases. - // - // Note that writes themselves don't require a lock, but there - // are other messages in the writer mailbox (resize, focus) that - // could acquire the lock. This is why we have to release our lock - // here. - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.writer_mailbox.push(msg, .{ .forever = {} }); - } - - // Normally, we just flag this true to wake up the writer thread - // once per batch of data. - self.writer_messaged = true; - } - - pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - self.dcs.hook(self.alloc, dcs); - } - - pub fn dcsPut(self: *StreamHandler, byte: u8) !void { - self.dcs.put(byte); - } - - pub fn dcsUnhook(self: *StreamHandler) !void { - var cmd = self.dcs.unhook() orelse return; - defer cmd.deinit(); - - // log.warn("DCS command: {}", .{cmd}); - switch (cmd) { - .xtgettcap => |*gettcap| { - const map = comptime terminfo.ghostty.xtgettcapMap(); - while (gettcap.next()) |key| { - const response = map.get(key) orelse continue; - self.messageWriter(.{ .write_stable = response }); - } - }, - .decrqss => |decrqss| { - var response: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&response); - const writer = stream.writer(); - - // Offset the stream position to just past the response prefix. - // We will write the "payload" (if any) below. If no payload is - // written then we send an invalid DECRPSS response. - const prefix_fmt = "\x1bP{d}$r"; - const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; - stream.pos = prefix_len; - - switch (decrqss) { - // Invalid or unhandled request - .none => {}, - - .sgr => { - const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); - - // printAttributes wrote into our buffer, so adjust the stream - // position - stream.pos += buf.len; - - try writer.writeByte('m'); - }, - - .decscusr => { - const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { - .block => if (blink) 1 else 2, - .underline => if (blink) 3 else 4, - .bar => if (blink) 5 else 6, - }; - try writer.print("{d} q", .{style}); - }, - - .decstbm => { - try writer.print("{d};{d}r", .{ - self.terminal.scrolling_region.top + 1, - self.terminal.scrolling_region.bottom + 1, - }); - }, - - .decslrm => { - // We only send a valid response when left and right - // margin mode (DECLRMM) is enabled. - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try writer.print("{d};{d}s", .{ - self.terminal.scrolling_region.left + 1, - self.terminal.scrolling_region.right + 1, - }); - } - }, - } - - // Our response is valid if we have a response payload - const valid = stream.pos > prefix_len; - - // Write the terminator - try writer.writeAll("\x1b\\"); - - // Write the response prefix into the buffer - _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); - const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); - self.messageWriter(msg); - }, - } - } - - pub fn apcStart(self: *StreamHandler) !void { - self.apc.start(); - } - - pub fn apcPut(self: *StreamHandler, byte: u8) !void { - self.apc.feed(self.alloc, byte); - } - - pub fn apcEnd(self: *StreamHandler) !void { - var cmd = self.apc.end() orelse return; - defer cmd.deinit(self.alloc); - - // log.warn("APC command: {}", .{cmd}); - switch (cmd) { - .kitty => |*kitty_cmd| { - if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { - var buf: [1024]u8 = undefined; - var buf_stream = std.io.fixedBufferStream(&buf); - try resp.encode(buf_stream.writer()); - const final = buf_stream.getWritten(); - if (final.len > 2) { - // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); - self.messageWriter(try termio.Message.writeReq(self.alloc, final)); - } - } - }, - } - } - - pub fn print(self: *StreamHandler, ch: u21) !void { - try self.terminal.print(ch); - } - - pub fn printRepeat(self: *StreamHandler, count: usize) !void { - try self.terminal.printRepeat(count); - } - - pub fn bell(self: StreamHandler) !void { - _ = self; - log.info("BELL", .{}); - } - - pub fn backspace(self: *StreamHandler) !void { - self.terminal.backspace(); - } - - pub fn horizontalTab(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn linefeed(self: *StreamHandler) !void { - // Small optimization: call index instead of linefeed because they're - // identical and this avoids one layer of function call overhead. - try self.terminal.index(); - } - - pub fn carriageReturn(self: *StreamHandler) !void { - self.terminal.carriageReturn(); - } - - pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorLeft(amount); - } - - pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorRight(amount); - } - - pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorDown(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorUp(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorCol(self: *StreamHandler, col: u16) !void { - self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); - } - - pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| offset, - ); - } - - pub fn setCursorRow(self: *StreamHandler, row: u16) !void { - self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); - } - - pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| offset, - self.terminal.screen.cursor.x + 1, - ); - } - - pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { - self.terminal.setCursorPos(row, col); - } - - pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { - if (mode == .complete) { - // Whenever we erase the full display, scroll to bottom. - try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - self.terminal.eraseDisplay(mode, protected); - } - - pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { - self.terminal.eraseLine(mode, protected); - } - - pub fn deleteChars(self: *StreamHandler, count: usize) !void { - self.terminal.deleteChars(count); - } - - pub fn eraseChars(self: *StreamHandler, count: usize) !void { - self.terminal.eraseChars(count); - } - - pub fn insertLines(self: *StreamHandler, count: usize) !void { - self.terminal.insertLines(count); - } - - pub fn insertBlanks(self: *StreamHandler, count: usize) !void { - self.terminal.insertBlanks(count); - } - - pub fn deleteLines(self: *StreamHandler, count: usize) !void { - self.terminal.deleteLines(count); - } - - pub fn reverseIndex(self: *StreamHandler) !void { - self.terminal.reverseIndex(); - } - - pub fn index(self: *StreamHandler) !void { - try self.terminal.index(); - } - - pub fn nextLine(self: *StreamHandler) !void { - try self.terminal.index(); - self.terminal.carriageReturn(); - } - - pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { - self.terminal.setTopAndBottomMargin(top, bot); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try self.setLeftAndRightMargin(0, 0); - } else { - try self.saveCursor(); - } - } - - pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { - self.terminal.setLeftAndRightMargin(left, right); - } - - pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { - self.terminal.flags.modify_other_keys_2 = false; - switch (format) { - .other_keys => |v| switch (v) { - .numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - }, - else => {}, - } - } - - pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - // Get the mode value and respond. - const code: u8 = code: { - const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; - if (self.terminal.modes.get(mode)) break :code 1; - break :code 2; - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B[{s}{};{}$y", - .{ - if (ansi) "" else "?", - mode_raw, - code, - }, - ); - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { - // log.debug("save mode={}", .{mode}); - self.terminal.modes.save(mode); - } - - pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { - // For restore mode we have to restore but if we set it, we - // always have to call setMode because setting some modes have - // side effects and we want to make sure we process those. - const v = self.terminal.modes.restore(mode); - // log.debug("restore mode={} v={}", .{ mode, v }); - try self.setMode(mode, v); - } - - pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { - // Note: this function doesn't need to grab the render state or - // terminal locks because it is only called from process() which - // grabs the lock. - - // If we are setting cursor blinking, we ignore it if we have - // a default cursor blink setting set. This is a really weird - // behavior so this comment will go deep into trying to explain it. - // - // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) - // and DEC mode 12. DECSCUSR is the modern approach and has a - // way to revert to the "default" (as defined by the terminal) - // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls - // blinking and is either on or off and has no way to set a - // default. DEC mode 12 is also the more antiquated approach. - // - // The problem is that if the user specifies a desired default - // cursor blink with `cursor-style-blink`, the moment a running - // program uses DEC mode 12, the cursor blink can never be reset - // to the default without an explicit DECSCUSR. But if a program - // is using mode 12, it is by definition not using DECSCUSR. - // This makes for somewhat annoying interactions where a poorly - // (or legacy) behaved program will stop blinking, and it simply - // never restarts. - // - // To get around this, we have a special case where if the user - // specifies some explicit default cursor blink desire, we ignore - // DEC mode 12. We allow DECSCUSR to still set the cursor blink - // because programs using DECSCUSR usually are well behaved and - // reset the cursor blink to the default when they exit. - // - // To be extra safe, users can also add a manual `CSI 0 q` to - // their shell config when they render prompts to ensure the - // cursor is exactly as they request. - if (mode == .cursor_blinking and - self.default_cursor_blink != null) - { - return; - } - - // We first always set the raw mode on our mode state. - self.terminal.modes.set(mode, enabled); - - // And then some modes require additional processing. - switch (mode) { - // Just noting here that autorepeat has no effect on - // the terminal. xterm ignores this mode and so do we. - // We know about just so that we don't log that it is - // an unknown mode. - .autorepeat => {}, - - // Schedule a render since we changed colors - .reverse_colors => { - self.terminal.flags.dirty.reverse_colors = true; - try self.queueRender(); - }, - - // Origin resets cursor pos. This is called whether or not - // we're enabling or disabling origin mode and whether or - // not the value changed. - .origin => self.terminal.setCursorPos(1, 1), - - .enable_left_and_right_margin => if (!enabled) { - // When we disable left/right margin mode we need to - // reset the left/right margins. - self.terminal.scrolling_region.left = 0; - self.terminal.scrolling_region.right = self.terminal.cols - 1; - }, - - .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - // Force resize back to the window size - .enable_mode_3 => self.terminal.resize( - self.alloc, - self.grid_size.columns, - self.grid_size.rows, - ) catch |err| { - log.err("error updating terminal size: {}", .{err}); - }, - - .@"132_column" => try self.terminal.deccolm( - self.alloc, - if (enabled) .@"132_cols" else .@"80_cols", - ), - - // We need to start a timer to prevent the emulator being hung - // forever. - .synchronized_output => { - if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); - try self.queueRender(); - }, - - .linefeed => { - self.messageWriter(.{ .linefeed_mode = enabled }); - }, - - .mouse_event_x10 => { - if (enabled) { - self.terminal.flags.mouse_event = .x10; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_normal => { - if (enabled) { - self.terminal.flags.mouse_event = .normal; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_button => { - if (enabled) { - self.terminal.flags.mouse_event = .button; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_any => { - if (enabled) { - self.terminal.flags.mouse_event = .any; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - - .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, - .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, - .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, - .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, - - else => {}, - } - } - - pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { - self.terminal.flags.mouse_shift_capture = if (v) .true else .false; - } - - pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { - switch (attr) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - - else => self.terminal.setAttribute(attr) catch |err| - log.warn("error setting attribute {}: {}", .{ attr, err }), - } - } - - pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { - try self.terminal.screen.startHyperlink(uri, id); - } - - pub fn endHyperlink(self: *StreamHandler) !void { - self.terminal.screen.endHyperlink(); - } - - pub fn deviceAttributes( - self: *StreamHandler, - req: terminal.DeviceAttributeReq, - params: []const u16, - ) !void { - _ = params; - - // For the below, we quack as a VT220. We don't quack as - // a 420 because we don't support DCS sequences. - switch (req) { - .primary => self.messageWriter(.{ - .write_stable = "\x1B[?62;22c", - }), - - .secondary => self.messageWriter(.{ - .write_stable = "\x1B[>1;10;0c", - }), - - else => log.warn("unimplemented device attributes req: {}", .{req}), - } - } - - pub fn deviceStatusReport( - self: *StreamHandler, - req: terminal.device_status.Request, - ) !void { - switch (req) { - .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), - - .cursor_position => { - const pos: struct { - x: usize, - y: usize, - } = if (self.terminal.modes.get(.origin)) .{ - .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, - .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, - } else .{ - .x = self.terminal.screen.cursor.x, - .y = self.terminal.screen.cursor.y, - }; - - // Response always is at least 4 chars, so this leaves the - // remainder for the row/column as base-10 numbers. This - // will support a very large terminal. - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ - pos.y + 1, - pos.x + 1, - }); - msg.write_small.len = @intCast(resp.len); - - self.messageWriter(msg); - }, - - .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), - } - } - - pub fn setCursorStyle( - self: *StreamHandler, - style: terminal.CursorStyleReq, - ) !void { - // Assume we're setting to a non-default. - self.default_cursor = false; - - switch (style) { - .default => { - self.default_cursor = true; - self.terminal.screen.cursor.cursor_style = self.default_cursor_style; - self.terminal.modes.set( - .cursor_blinking, - self.default_cursor_blink orelse true, - ); - }, - - .blinking_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, false); - }, - - else => log.warn("unimplemented cursor style: {}", .{style}), - } - } - - pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { - self.terminal.setProtectedMode(mode); - } - - pub fn decaln(self: *StreamHandler) !void { - try self.terminal.decaln(); - } - - pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { - self.terminal.tabClear(cmd); - } - - pub fn tabSet(self: *StreamHandler) !void { - self.terminal.tabSet(); - } - - pub fn tabReset(self: *StreamHandler) !void { - self.terminal.tabReset(); - } - - pub fn saveCursor(self: *StreamHandler) !void { - self.terminal.saveCursor(); - } - - pub fn restoreCursor(self: *StreamHandler) !void { - try self.terminal.restoreCursor(); - } - - pub fn enquiry(self: *StreamHandler) !void { - log.debug("sending enquiry response={s}", .{self.enquiry_response}); - self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); - } - - pub fn scrollDown(self: *StreamHandler, count: usize) !void { - self.terminal.scrollDown(count); - } - - pub fn scrollUp(self: *StreamHandler, count: usize) !void { - self.terminal.scrollUp(count); - } - - pub fn setActiveStatusDisplay( - self: *StreamHandler, - req: terminal.StatusDisplay, - ) !void { - self.terminal.status_display = req; - } - - pub fn configureCharset( - self: *StreamHandler, - slot: terminal.CharsetSlot, - set: terminal.Charset, - ) !void { - self.terminal.configureCharset(slot, set); - } - - pub fn invokeCharset( - self: *StreamHandler, - active: terminal.CharsetActiveSlot, - slot: terminal.CharsetSlot, - single: bool, - ) !void { - self.terminal.invokeCharset(active, slot, single); - } - - pub fn fullReset( - self: *StreamHandler, - ) !void { - self.terminal.fullReset(); - try self.setMouseShape(.text); - } - - pub fn queryKittyKeyboard(self: *StreamHandler) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("querying kitty keyboard mode", .{}); - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ - self.terminal.screen.kitty_keyboard.current().int(), - }); - - self.messageWriter(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, - }); - } - - pub fn pushKittyKeyboard( - self: *StreamHandler, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("pushing kitty keyboard mode: {}", .{flags}); - self.terminal.screen.kitty_keyboard.push(flags); - } - - pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("popping kitty keyboard mode n={}", .{n}); - self.terminal.screen.kitty_keyboard.pop(@intCast(n)); - } - - pub fn setKittyKeyboard( - self: *StreamHandler, - mode: terminal.kitty.KeySetMode, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); - self.terminal.screen.kitty_keyboard.set(mode, flags); - } - - pub fn reportXtversion( - self: *StreamHandler, - ) !void { - log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); - var buf: [288]u8 = undefined; - const resp = try std.fmt.bufPrint( - &buf, - "\x1BP>|{s} {s}\x1B\\", - .{ - "ghostty", - build_config.version_string, - }, - ); - const msg = try termio.Message.writeReq(self.alloc, resp); - self.messageWriter(msg); - } - - //------------------------------------------------------------------------- - // OSC - - pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { - var buf: [256]u8 = undefined; - if (title.len >= buf.len) { - log.warn("change title requested larger than our buffer size, ignoring", .{}); - return; - } - - @memcpy(buf[0..title.len], title); - buf[title.len] = 0; - - // Mark that we've seen a title - self.ev.seen_title = true; - self.surfaceMessageWriter(.{ .set_title = buf }); - } - - pub fn setMouseShape( - self: *StreamHandler, - shape: terminal.MouseShape, - ) !void { - // Avoid changing the shape it it is already set to avoid excess - // cross-thread messaging. - if (self.terminal.mouse_shape == shape) return; - - self.terminal.mouse_shape = shape; - self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); - } - - pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { - // Note: we ignore the "kind" field and always use the standard clipboard. - // iTerm also appears to do this but other terminals seem to only allow - // certain. Let's investigate more. - - const clipboard_type: apprt.Clipboard = switch (kind) { - 'c' => .standard, - 's' => .selection, - 'p' => .primary, - else => .standard, - }; - - // Get clipboard contents - if (data.len == 1 and data[0] == '?') { - self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); - return; - } - - // Write clipboard contents - self.surfaceMessageWriter(.{ - .clipboard_write = .{ - .req = try apprt.surface.Message.WriteReq.init( - self.alloc, - data, - ), - .clipboard_type = clipboard_type, - }, - }); - } - - pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt); - self.terminal.flags.shell_redraws_prompt = redraw; - } - - pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt_continuation); - } - - pub fn promptEnd(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.input); - } - - pub fn endOfInput(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.command); - } - - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { - if (builtin.os.tag == .windows) { - log.warn("reportPwd unimplemented on windows", .{}); - return; - } - - const uri = std.Uri.parse(url) catch |e| { - log.warn("invalid url in OSC 7: {}", .{e}); - return; - }; - - if (!std.mem.eql(u8, "file", uri.scheme) and - !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) - { - log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); - return; - } - - // OSC 7 is a little sketchy because anyone can send any value from - // any host (such an SSH session). The best practice terminals follow - // is to valid the hostname to be local. - const host_valid = host_valid: { - const host_component = uri.host orelse break :host_valid false; - - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { - break :host_valid true; - } - - // Otherwise, it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const hostname = posix.gethostname(&buf) catch |err| { - log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); - break :host_valid false; - }; - - break :host_valid std.mem.eql(u8, host, hostname); - }; - if (!host_valid) { - log.warn("OSC 7 host must be local", .{}); - return; - } - - // We need to unescape the path. We first try to unescape onto - // the stack and fall back to heap allocation if we have to. - var pathBuf: [1024]u8 = undefined; - const path, const heap = path: { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const path = switch (uri.path) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // If the path doesn't have any escapes, we can use it directly. - if (std.mem.indexOfScalar(u8, path, '%') == null) - break :path .{ path, false }; - - // First try to stack-allocate - var fba = std.heap.FixedBufferAllocator.init(&pathBuf); - if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| - break :path .{ v, false } - else |_| {} - - // Fall back to heap - if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| - break :path .{ v, true } - else |_| {} - - // Fall back to using it directly... - log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); - break :path .{ path, false }; - }; - defer if (heap) self.alloc.free(path); - - log.debug("terminal pwd: {s}", .{path}); - try self.terminal.setPwd(path); - - // If we haven't seen a title, use our pwd as the title. - if (!self.ev.seen_title) { - try self.changeWindowTitle(path); - self.ev.seen_title = false; - } - } - - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - terminator: terminal.osc.Terminator, - ) !void { - if (self.osc_color_report_format == .none) return; - - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color, - .background => self.background_color, - .cursor => self.cursor_color orelse self.foreground_color, - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - }, - - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); - - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } - } - }, - .foreground => { - self.foreground_color = self.default_foreground_color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = self.default_background_color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = self.default_cursor_color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); - }, - } - } - - pub fn showDesktopNotification( - self: *StreamHandler, - title: []const u8, - body: []const u8, - ) !void { - var message = apprt.surface.Message{ .desktop_notification = undefined }; - - const title_len = @min(title.len, message.desktop_notification.title.len); - @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); - message.desktop_notification.title[title_len] = 0; - - const body_len = @min(body.len, message.desktop_notification.body.len); - @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); - message.desktop_notification.body[body_len] = 0; - - self.surfaceMessageWriter(message); - } -}; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig new file mode 100644 index 000000000..f88aeca97 --- /dev/null +++ b/src/termio/stream_handler.zig @@ -0,0 +1,1273 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const xev = @import("xev"); +const apprt = @import("../apprt.zig"); +const build_config = @import("../build_config.zig"); +const configpkg = @import("../config.zig"); +const renderer = @import("../renderer.zig"); +const termio = @import("../termio.zig"); +const terminal = @import("../terminal/main.zig"); +const terminfo = @import("../terminfo/main.zig"); +const posix = std.posix; + +const log = std.log.scoped(.io_handler); + +/// True if we should disable the kitty keyboard protocol. We have to +/// disable this on GLFW because GLFW input events don't support the +/// correct granularity of events. +const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; + +/// This is used as the handler for the terminal.Stream type. This is +/// stateful and is expected to live for the entire lifetime of the terminal. +/// It is NOT VALID to stop a stream handler, create a new one, and use that +/// unless all of the member fields are copied. +pub const StreamHandler = struct { + alloc: Allocator, + grid_size: *renderer.GridSize, + terminal: *terminal.Terminal, + + /// Mailbox for data to the writer thread. + writer_mailbox: *termio.Mailbox, + writer_wakeup: xev.Async, + + /// Mailbox for the surface. + surface_mailbox: apprt.surface.Mailbox, + + /// The shared render state + renderer_state: *renderer.State, + + /// The mailbox for notifying the renderer of things. + renderer_mailbox: *renderer.Thread.Mailbox, + + /// A handle to wake up the renderer. This hints to the renderer that that + /// a repaint should happen. + renderer_wakeup: xev.Async, + + /// The default cursor state. This is used with CSI q. This is + /// set to true when we're currently in the default cursor state. + default_cursor: bool = true, + default_cursor_style: terminal.CursorStyle, + default_cursor_blink: ?bool, + default_cursor_color: ?terminal.color.RGB, + + /// Actual cursor color. This can be changed with OSC 12. + cursor_color: ?terminal.color.RGB, + + /// The default foreground and background color are those set by the user's + /// config file. These can be overridden by terminal applications using OSC + /// 10 and OSC 11, respectively. + default_foreground_color: terminal.color.RGB, + default_background_color: terminal.color.RGB, + + /// The actual foreground and background color. Normally this will be the + /// same as the default foreground and background color, unless changed by a + /// terminal application. + foreground_color: terminal.color.RGB, + background_color: terminal.color.RGB, + + /// The response to use for ENQ requests. The memory is owned by + /// whoever owns StreamHandler. + enquiry_response: []const u8, + + /// The color reporting format for OSC requests. + osc_color_report_format: configpkg.Config.OSCColorReportFormat, + + //--------------------------------------------------------------- + // Internal state + + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + apc: terminal.apc.Handler = .{}, + + /// The DCS handler maintains DCS state. DCS is like CSI or OSC, + /// but requires more stateful parsing. This is used by functionality + /// such as XTGETTCAP. + dcs: terminal.dcs.Handler = .{}, + + /// This is set to true when a message was written to the writer + /// mailbox. This can be used by callers to determine if they need + /// to wake up the writer. + writer_messaged: bool = false, + + /// This is set to true when we've seen a title escape sequence. We use + /// this to determine if we need to default the window title. + seen_title: bool = false, + + pub fn deinit(self: *StreamHandler) void { + self.apc.deinit(); + self.dcs.deinit(); + } + + /// This queues a render operation with the renderer thread. The render + /// isn't guaranteed to happen immediately but it will happen as soon as + /// practical. + inline fn queueRender(self: *StreamHandler) !void { + try self.renderer_wakeup.notify(); + } + + /// Change the configuration for this handler. + pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { + self.osc_color_report_format = config.osc_color_report_format; + self.enquiry_response = config.enquiry_response; + self.default_foreground_color = config.foreground.toTerminalRGB(); + self.default_background_color = config.background.toTerminalRGB(); + self.default_cursor_style = config.cursor_style; + self.default_cursor_blink = config.cursor_blink; + self.default_cursor_color = if (config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + // If our cursor is the default, then we update it immediately. + if (self.default_cursor) self.setCursorStyle(.default) catch |err| { + log.warn("failed to set default cursor style: {}", .{err}); + }; + } + + inline fn surfaceMessageWriter( + self: *StreamHandler, + msg: apprt.surface.Message, + ) void { + // See messageWriter which has similar logic and explains why + // we may have to do this. + if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + _ = self.surface_mailbox.push(msg, .{ .forever = {} }); + } + } + + inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { + // Try to write to the mailbox with an instant timeout. This is the + // fast path because we can queue without a lock. + if (self.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { + // If we enter this conditional, the mailbox is full. We wake up + // the writer thread so that it can process messages to clear up + // space. However, the writer thread may require the renderer + // lock so we need to unlock. + self.writer_wakeup.notify() catch |err| { + log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + return; + }; + + // Unlock the renderer state so the writer thread can acquire it. + // Then try to queue our message before continuing. This is a very + // slow path because we are having a lot of contention for data. + // But this only gets triggered in certain pathological cases. + // + // Note that writes themselves don't require a lock, but there + // are other messages in the writer mailbox (resize, focus) that + // could acquire the lock. This is why we have to release our lock + // here. + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + _ = self.writer_mailbox.push(msg, .{ .forever = {} }); + } + + // Normally, we just flag this true to wake up the writer thread + // once per batch of data. + self.writer_messaged = true; + } + + pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { + self.dcs.hook(self.alloc, dcs); + } + + pub fn dcsPut(self: *StreamHandler, byte: u8) !void { + self.dcs.put(byte); + } + + pub fn dcsUnhook(self: *StreamHandler) !void { + var cmd = self.dcs.unhook() orelse return; + defer cmd.deinit(); + + // log.warn("DCS command: {}", .{cmd}); + switch (cmd) { + .xtgettcap => |*gettcap| { + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + const response = map.get(key) orelse continue; + self.messageWriter(.{ .write_stable = response }); + } + }, + .decrqss => |decrqss| { + var response: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&response); + const writer = stream.writer(); + + // Offset the stream position to just past the response prefix. + // We will write the "payload" (if any) below. If no payload is + // written then we send an invalid DECRPSS response. + const prefix_fmt = "\x1bP{d}$r"; + const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; + stream.pos = prefix_len; + + switch (decrqss) { + // Invalid or unhandled request + .none => {}, + + .sgr => { + const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); + + // printAttributes wrote into our buffer, so adjust the stream + // position + stream.pos += buf.len; + + try writer.writeByte('m'); + }, + + .decscusr => { + const blink = self.terminal.modes.get(.cursor_blinking); + const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { + .block => if (blink) 1 else 2, + .underline => if (blink) 3 else 4, + .bar => if (blink) 5 else 6, + }; + try writer.print("{d} q", .{style}); + }, + + .decstbm => { + try writer.print("{d};{d}r", .{ + self.terminal.scrolling_region.top + 1, + self.terminal.scrolling_region.bottom + 1, + }); + }, + + .decslrm => { + // We only send a valid response when left and right + // margin mode (DECLRMM) is enabled. + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try writer.print("{d};{d}s", .{ + self.terminal.scrolling_region.left + 1, + self.terminal.scrolling_region.right + 1, + }); + } + }, + } + + // Our response is valid if we have a response payload + const valid = stream.pos > prefix_len; + + // Write the terminator + try writer.writeAll("\x1b\\"); + + // Write the response prefix into the buffer + _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); + const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); + self.messageWriter(msg); + }, + } + } + + pub fn apcStart(self: *StreamHandler) !void { + self.apc.start(); + } + + pub fn apcPut(self: *StreamHandler, byte: u8) !void { + self.apc.feed(self.alloc, byte); + } + + pub fn apcEnd(self: *StreamHandler) !void { + var cmd = self.apc.end() orelse return; + defer cmd.deinit(self.alloc); + + // log.warn("APC command: {}", .{cmd}); + switch (cmd) { + .kitty => |*kitty_cmd| { + if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { + var buf: [1024]u8 = undefined; + var buf_stream = std.io.fixedBufferStream(&buf); + try resp.encode(buf_stream.writer()); + const final = buf_stream.getWritten(); + if (final.len > 2) { + // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); + self.messageWriter(try termio.Message.writeReq(self.alloc, final)); + } + } + }, + } + } + + pub fn print(self: *StreamHandler, ch: u21) !void { + try self.terminal.print(ch); + } + + pub fn printRepeat(self: *StreamHandler, count: usize) !void { + try self.terminal.printRepeat(count); + } + + pub fn bell(self: StreamHandler) !void { + _ = self; + log.info("BELL", .{}); + } + + pub fn backspace(self: *StreamHandler) !void { + self.terminal.backspace(); + } + + pub fn horizontalTab(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTab(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTabBack(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn linefeed(self: *StreamHandler) !void { + // Small optimization: call index instead of linefeed because they're + // identical and this avoids one layer of function call overhead. + try self.terminal.index(); + } + + pub fn carriageReturn(self: *StreamHandler) !void { + self.terminal.carriageReturn(); + } + + pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorLeft(amount); + } + + pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorRight(amount); + } + + pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorDown(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorUp(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorCol(self: *StreamHandler, col: u16) !void { + self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); + } + + pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1, + self.terminal.screen.cursor.x + 1 +| offset, + ); + } + + pub fn setCursorRow(self: *StreamHandler, row: u16) !void { + self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); + } + + pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1 +| offset, + self.terminal.screen.cursor.x + 1, + ); + } + + pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { + self.terminal.setCursorPos(row, col); + } + + pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { + if (mode == .complete) { + // Whenever we erase the full display, scroll to bottom. + try self.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); + } + + self.terminal.eraseDisplay(mode, protected); + } + + pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { + self.terminal.eraseLine(mode, protected); + } + + pub fn deleteChars(self: *StreamHandler, count: usize) !void { + self.terminal.deleteChars(count); + } + + pub fn eraseChars(self: *StreamHandler, count: usize) !void { + self.terminal.eraseChars(count); + } + + pub fn insertLines(self: *StreamHandler, count: usize) !void { + self.terminal.insertLines(count); + } + + pub fn insertBlanks(self: *StreamHandler, count: usize) !void { + self.terminal.insertBlanks(count); + } + + pub fn deleteLines(self: *StreamHandler, count: usize) !void { + self.terminal.deleteLines(count); + } + + pub fn reverseIndex(self: *StreamHandler) !void { + self.terminal.reverseIndex(); + } + + pub fn index(self: *StreamHandler) !void { + try self.terminal.index(); + } + + pub fn nextLine(self: *StreamHandler) !void { + try self.terminal.index(); + self.terminal.carriageReturn(); + } + + pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { + self.terminal.setTopAndBottomMargin(top, bot); + } + + pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try self.setLeftAndRightMargin(0, 0); + } else { + try self.saveCursor(); + } + } + + pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { + self.terminal.setLeftAndRightMargin(left, right); + } + + pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { + self.terminal.flags.modify_other_keys_2 = false; + switch (format) { + .other_keys => |v| switch (v) { + .numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + }, + else => {}, + } + } + + pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { + // Get the mode value and respond. + const code: u8 = code: { + const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; + if (self.terminal.modes.get(mode)) break :code 1; + break :code 2; + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[{s}{};{}$y", + .{ + if (ansi) "" else "?", + mode_raw, + code, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { + // log.debug("save mode={}", .{mode}); + self.terminal.modes.save(mode); + } + + pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + const v = self.terminal.modes.restore(mode); + // log.debug("restore mode={} v={}", .{ mode, v }); + try self.setMode(mode, v); + } + + pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { + // Note: this function doesn't need to grab the render state or + // terminal locks because it is only called from process() which + // grabs the lock. + + // If we are setting cursor blinking, we ignore it if we have + // a default cursor blink setting set. This is a really weird + // behavior so this comment will go deep into trying to explain it. + // + // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) + // and DEC mode 12. DECSCUSR is the modern approach and has a + // way to revert to the "default" (as defined by the terminal) + // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls + // blinking and is either on or off and has no way to set a + // default. DEC mode 12 is also the more antiquated approach. + // + // The problem is that if the user specifies a desired default + // cursor blink with `cursor-style-blink`, the moment a running + // program uses DEC mode 12, the cursor blink can never be reset + // to the default without an explicit DECSCUSR. But if a program + // is using mode 12, it is by definition not using DECSCUSR. + // This makes for somewhat annoying interactions where a poorly + // (or legacy) behaved program will stop blinking, and it simply + // never restarts. + // + // To get around this, we have a special case where if the user + // specifies some explicit default cursor blink desire, we ignore + // DEC mode 12. We allow DECSCUSR to still set the cursor blink + // because programs using DECSCUSR usually are well behaved and + // reset the cursor blink to the default when they exit. + // + // To be extra safe, users can also add a manual `CSI 0 q` to + // their shell config when they render prompts to ensure the + // cursor is exactly as they request. + if (mode == .cursor_blinking and + self.default_cursor_blink != null) + { + return; + } + + // We first always set the raw mode on our mode state. + self.terminal.modes.set(mode, enabled); + + // And then some modes require additional processing. + switch (mode) { + // Just noting here that autorepeat has no effect on + // the terminal. xterm ignores this mode and so do we. + // We know about just so that we don't log that it is + // an unknown mode. + .autorepeat => {}, + + // Schedule a render since we changed colors + .reverse_colors => { + self.terminal.flags.dirty.reverse_colors = true; + try self.queueRender(); + }, + + // Origin resets cursor pos. This is called whether or not + // we're enabling or disabling origin mode and whether or + // not the value changed. + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + // When we disable left/right margin mode we need to + // reset the left/right margins. + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = false, + .clear_on_enter = false, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + .alt_screen_save_cursor_clear_enter => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = true, + .clear_on_enter = true, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + // Force resize back to the window size + .enable_mode_3 => self.terminal.resize( + self.alloc, + self.grid_size.columns, + self.grid_size.rows, + ) catch |err| { + log.err("error updating terminal size: {}", .{err}); + }, + + .@"132_column" => try self.terminal.deccolm( + self.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + // We need to start a timer to prevent the emulator being hung + // forever. + .synchronized_output => { + if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); + try self.queueRender(); + }, + + .linefeed => { + self.messageWriter(.{ .linefeed_mode = enabled }); + }, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } + + pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { + self.terminal.flags.mouse_shift_capture = if (v) .true else .false; + } + + pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { + switch (attr) { + .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), + + else => self.terminal.setAttribute(attr) catch |err| + log.warn("error setting attribute {}: {}", .{ attr, err }), + } + } + + pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screen.startHyperlink(uri, id); + } + + pub fn endHyperlink(self: *StreamHandler) !void { + self.terminal.screen.endHyperlink(); + } + + pub fn deviceAttributes( + self: *StreamHandler, + req: terminal.DeviceAttributeReq, + params: []const u16, + ) !void { + _ = params; + + // For the below, we quack as a VT220. We don't quack as + // a 420 because we don't support DCS sequences. + switch (req) { + .primary => self.messageWriter(.{ + .write_stable = "\x1B[?62;22c", + }), + + .secondary => self.messageWriter(.{ + .write_stable = "\x1B[>1;10;0c", + }), + + else => log.warn("unimplemented device attributes req: {}", .{req}), + } + } + + pub fn deviceStatusReport( + self: *StreamHandler, + req: terminal.device_status.Request, + ) !void { + switch (req) { + .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), + + .cursor_position => { + const pos: struct { + x: usize, + y: usize, + } = if (self.terminal.modes.get(.origin)) .{ + .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, + } else .{ + .x = self.terminal.screen.cursor.x, + .y = self.terminal.screen.cursor.y, + }; + + // Response always is at least 4 chars, so this leaves the + // remainder for the row/column as base-10 numbers. This + // will support a very large terminal. + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ + pos.y + 1, + pos.x + 1, + }); + msg.write_small.len = @intCast(resp.len); + + self.messageWriter(msg); + }, + + .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), + } + } + + pub fn setCursorStyle( + self: *StreamHandler, + style: terminal.CursorStyleReq, + ) !void { + // Assume we're setting to a non-default. + self.default_cursor = false; + + switch (style) { + .default => { + self.default_cursor = true; + self.terminal.screen.cursor.cursor_style = self.default_cursor_style; + self.terminal.modes.set( + .cursor_blinking, + self.default_cursor_blink orelse true, + ); + }, + + .blinking_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, false); + }, + + else => log.warn("unimplemented cursor style: {}", .{style}), + } + } + + pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { + self.terminal.setProtectedMode(mode); + } + + pub fn decaln(self: *StreamHandler) !void { + try self.terminal.decaln(); + } + + pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { + self.terminal.tabClear(cmd); + } + + pub fn tabSet(self: *StreamHandler) !void { + self.terminal.tabSet(); + } + + pub fn tabReset(self: *StreamHandler) !void { + self.terminal.tabReset(); + } + + pub fn saveCursor(self: *StreamHandler) !void { + self.terminal.saveCursor(); + } + + pub fn restoreCursor(self: *StreamHandler) !void { + try self.terminal.restoreCursor(); + } + + pub fn enquiry(self: *StreamHandler) !void { + log.debug("sending enquiry response={s}", .{self.enquiry_response}); + self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); + } + + pub fn scrollDown(self: *StreamHandler, count: usize) !void { + self.terminal.scrollDown(count); + } + + pub fn scrollUp(self: *StreamHandler, count: usize) !void { + self.terminal.scrollUp(count); + } + + pub fn setActiveStatusDisplay( + self: *StreamHandler, + req: terminal.StatusDisplay, + ) !void { + self.terminal.status_display = req; + } + + pub fn configureCharset( + self: *StreamHandler, + slot: terminal.CharsetSlot, + set: terminal.Charset, + ) !void { + self.terminal.configureCharset(slot, set); + } + + pub fn invokeCharset( + self: *StreamHandler, + active: terminal.CharsetActiveSlot, + slot: terminal.CharsetSlot, + single: bool, + ) !void { + self.terminal.invokeCharset(active, slot, single); + } + + pub fn fullReset( + self: *StreamHandler, + ) !void { + self.terminal.fullReset(); + try self.setMouseShape(.text); + } + + pub fn queryKittyKeyboard(self: *StreamHandler) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("querying kitty keyboard mode", .{}); + var data: termio.Message.WriteReq.Small.Array = undefined; + const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ + self.terminal.screen.kitty_keyboard.current().int(), + }); + + self.messageWriter(.{ + .write_small = .{ + .data = data, + .len = @intCast(resp.len), + }, + }); + } + + pub fn pushKittyKeyboard( + self: *StreamHandler, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("pushing kitty keyboard mode: {}", .{flags}); + self.terminal.screen.kitty_keyboard.push(flags); + } + + pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("popping kitty keyboard mode n={}", .{n}); + self.terminal.screen.kitty_keyboard.pop(@intCast(n)); + } + + pub fn setKittyKeyboard( + self: *StreamHandler, + mode: terminal.kitty.KeySetMode, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); + self.terminal.screen.kitty_keyboard.set(mode, flags); + } + + pub fn reportXtversion( + self: *StreamHandler, + ) !void { + log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); + var buf: [288]u8 = undefined; + const resp = try std.fmt.bufPrint( + &buf, + "\x1BP>|{s} {s}\x1B\\", + .{ + "ghostty", + build_config.version_string, + }, + ); + const msg = try termio.Message.writeReq(self.alloc, resp); + self.messageWriter(msg); + } + + //------------------------------------------------------------------------- + // OSC + + pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { + var buf: [256]u8 = undefined; + if (title.len >= buf.len) { + log.warn("change title requested larger than our buffer size, ignoring", .{}); + return; + } + + @memcpy(buf[0..title.len], title); + buf[title.len] = 0; + + // Mark that we've seen a title + self.seen_title = true; + self.surfaceMessageWriter(.{ .set_title = buf }); + } + + pub fn setMouseShape( + self: *StreamHandler, + shape: terminal.MouseShape, + ) !void { + // Avoid changing the shape it it is already set to avoid excess + // cross-thread messaging. + if (self.terminal.mouse_shape == shape) return; + + self.terminal.mouse_shape = shape; + self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); + } + + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + // Note: we ignore the "kind" field and always use the standard clipboard. + // iTerm also appears to do this but other terminals seem to only allow + // certain. Let's investigate more. + + const clipboard_type: apprt.Clipboard = switch (kind) { + 'c' => .standard, + 's' => .selection, + 'p' => .primary, + else => .standard, + }; + + // Get clipboard contents + if (data.len == 1 and data[0] == '?') { + self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); + return; + } + + // Write clipboard contents + self.surfaceMessageWriter(.{ + .clipboard_write = .{ + .req = try apprt.surface.Message.WriteReq.init( + self.alloc, + data, + ), + .clipboard_type = clipboard_type, + }, + }); + } + + pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt); + self.terminal.flags.shell_redraws_prompt = redraw; + } + + pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt_continuation); + } + + pub fn promptEnd(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.input); + } + + pub fn endOfInput(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.command); + } + + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + if (builtin.os.tag == .windows) { + log.warn("reportPwd unimplemented on windows", .{}); + return; + } + + const uri = std.Uri.parse(url) catch |e| { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + }; + + if (!std.mem.eql(u8, "file", uri.scheme) and + !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) + { + log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + return; + } + + // OSC 7 is a little sketchy because anyone can send any value from + // any host (such an SSH session). The best practice terminals follow + // is to valid the hostname to be local. + const host_valid = host_valid: { + const host_component = uri.host orelse break :host_valid false; + + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const host = switch (host_component) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { + break :host_valid true; + } + + // Otherwise, it must match our hostname. + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const hostname = posix.gethostname(&buf) catch |err| { + log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); + break :host_valid false; + }; + + break :host_valid std.mem.eql(u8, host, hostname); + }; + if (!host_valid) { + log.warn("OSC 7 host must be local", .{}); + return; + } + + // We need to unescape the path. We first try to unescape onto + // the stack and fall back to heap allocation if we have to. + var pathBuf: [1024]u8 = undefined; + const path, const heap = path: { + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const path = switch (uri.path) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + + // If the path doesn't have any escapes, we can use it directly. + if (std.mem.indexOfScalar(u8, path, '%') == null) + break :path .{ path, false }; + + // First try to stack-allocate + var fba = std.heap.FixedBufferAllocator.init(&pathBuf); + if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| + break :path .{ v, false } + else |_| {} + + // Fall back to heap + if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| + break :path .{ v, true } + else |_| {} + + // Fall back to using it directly... + log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); + break :path .{ path, false }; + }; + defer if (heap) self.alloc.free(path); + + log.debug("terminal pwd: {s}", .{path}); + try self.terminal.setPwd(path); + + // If we haven't seen a title, use our pwd as the title. + if (!self.seen_title) { + try self.changeWindowTitle(path); + self.seen_title = false; + } + } + + /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, + /// default foreground color, and background color respectively. + pub fn reportColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + terminator: terminal.osc.Terminator, + ) !void { + if (self.osc_color_report_format == .none) return; + + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color, + .background => self.background_color, + .cursor => self.cursor_color orelse self.foreground_color, + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + }, + .none => unreachable, // early return above + }; + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn setColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + const color = try terminal.color.RGB.parse(value); + + switch (kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = color; + _ = self.renderer_mailbox.push(.{ + .background_color = color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = color, + }, .{ .forever = {} }); + }, + } + } + + pub fn resetColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + switch (kind) { + .palette => { + const mask = &self.terminal.color_palette.mask; + if (value.len == 0) { + // Find all bit positions in the mask which are set and + // reset those indices to the default palette + var it = mask.iterator(.{}); + while (it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } else { + var it = std.mem.tokenizeScalar(u8, value, ';'); + while (it.next()) |param| { + // Skip invalid parameters + const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; + if (mask.isSet(i)) { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } + } + }, + .foreground => { + self.foreground_color = self.default_foreground_color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = self.default_background_color; + _ = self.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = self.default_cursor_color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + }, + } + } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + var message = apprt.surface.Message{ .desktop_notification = undefined }; + + const title_len = @min(title.len, message.desktop_notification.title.len); + @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); + message.desktop_notification.title[title_len] = 0; + + const body_len = @min(body.len, message.desktop_notification.body.len); + @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); + message.desktop_notification.body[body_len] = 0; + + self.surfaceMessageWriter(message); + } +}; From bfbbe1485e5a082e5459d75ec46e742320038cbc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 14:44:44 -0700 Subject: [PATCH 06/32] termio: queueWrite no longer uses EventData --- src/termio/Termio.zig | 89 ++++++++++++++++++++++--------------------- src/termio/reader.zig | 41 +++++++++++++++++++- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 0481a3126..f2a501b64 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -290,7 +290,6 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .renderer_wakeup = self.renderer_wakeup, .renderer_mailbox = self.renderer_mailbox, .process = process, - .data_stream = stream, .loop = &thread.loop, .terminal_stream = .{ .handler = handler, @@ -326,10 +325,13 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo data.* = .{ .alloc = alloc, .ev = ev_data_ptr, + .loop = &thread.loop, + .surface_mailbox = self.surface_mailbox, .reader = .{ .exec = .{ .start = process_start, .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, .wait_after_command = self.config.wait_after_command, + .write_stream = stream, } }, .read_thread = read_thread, .read_thread_pipe = pipe[1], @@ -627,28 +629,39 @@ pub inline fn queueWrite( data: []const u8, linefeed: bool, ) !void { - const ev = td.ev; + switch (td.reader) { + .manual => {}, + .exec => try self.queueWriteExec( + td, + data, + linefeed, + ), + } +} + +fn queueWriteExec( + self: *Termio, + td: *ThreadData, + data: []const u8, + linefeed: bool, +) !void { + const exec = &td.reader.exec; // If our process is exited then we send our surface a message // about it but we don't queue any more writes. - switch (td.reader) { - .manual => {}, - .exec => |exec| { - if (exec.exited) { - _ = ev.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } - }, + if (exec.exited) { + _ = td.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + return; } // We go through and chunk the data if necessary to fit into // our cached buffers that we can queue to the stream. var i: usize = 0; while (i < data.len) { - const req = try ev.write_req_pool.getGrow(self.alloc); - const buf = try ev.write_buf_pool.getGrow(self.alloc); + const req = try exec.write_req_pool.getGrow(self.alloc); + const buf = try exec.write_buf_pool.getGrow(self.alloc); const slice = slice: { // The maximum end index is either the end of our data or // the end of our buffer, whichever is smaller. @@ -685,13 +698,13 @@ pub inline fn queueWrite( //for (slice) |b| log.warn("write: {x}", .{b}); - ev.data_stream.queueWrite( - ev.loop, - &ev.write_queue, + exec.write_stream.queueWrite( + td.loop, + &exec.write_queue, req, .{ .slice = slice }, - EventData, - ev, + termio.reader.ThreadData.Exec, + exec, ttyWrite, ); } @@ -764,6 +777,13 @@ pub const ThreadData = struct { /// The data that is attached to the callbacks. ev: *EventData, + /// The event loop associated with this thread. This is owned by + /// the Thread but we have a pointer so we can queue new work to it. + loop: *xev.Loop, + + /// Mailboxes for different threads + surface_mailbox: apprt.surface.Mailbox, + /// Data associated with the reader implementation (i.e. pty/exec state) reader: termio.reader.ThreadData, @@ -775,6 +795,7 @@ pub const ThreadData = struct { pub fn deinit(self: *ThreadData) void { posix.close(self.read_thread_pipe); self.ev.deinit(self.alloc); + self.reader.deinit(self.alloc); self.alloc.destroy(self.ev); self.* = undefined; } @@ -813,35 +834,15 @@ pub const EventData = struct { /// subsequently to wait for the data_stream to close. process_wait_c: xev.Completion = .{}, - /// The data stream is the main IO for the pty. - data_stream: xev.Stream, - /// The event loop, loop: *xev.Loop, - /// The write queue for the data stream. - write_queue: xev.Stream.WriteQueue = .{}, - - /// This is the pool of available (unused) write requests. If you grab - /// one from the pool, you must put it back when you're done! - write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, - - /// The pool of available buffers for writing to the pty. - write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, - /// Last time the cursor was reset. This is used to prevent message /// flooding with cursor resets. last_cursor_reset: i64 = 0, pub fn deinit(self: *EventData, alloc: Allocator) void { - // Clear our write pools. We know we aren't ever going to do - // any more IO since we stop our data stream below so we can just - // drop this. - self.write_req_pool.deinit(alloc); - self.write_buf_pool.deinit(alloc); - - // Stop our data stream - self.data_stream.deinit(); + _ = alloc; // Stop our process watcher self.process.deinit(); @@ -946,16 +947,16 @@ fn processExit( } fn ttyWrite( - ev_: ?*EventData, + td_: ?*termio.reader.ThreadData.Exec, _: *xev.Loop, _: *xev.Completion, _: xev.Stream, _: xev.WriteBuffer, r: xev.Stream.WriteError!usize, ) xev.CallbackAction { - const ev = ev_.?; - ev.write_req_pool.put(); - ev.write_buf_pool.put(); + const td = td_.?; + td.write_req_pool.put(); + td.write_buf_pool.put(); const d = r catch |err| { log.err("write error: {}", .{err}); diff --git a/src/termio/reader.zig b/src/termio/reader.zig index 99f78b561..10a6e9980 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -1,7 +1,14 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; +const xev = @import("xev"); const configpkg = @import("../config.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); +const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; + +// The preallocation size for the write request pool. This should be big +// enough to satisfy most write requests. It must be a power of 2. +const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); /// The kinds of readers. pub const Kind = std.meta.Tag(Config); @@ -29,8 +36,9 @@ pub const Config = union(enum) { /// Termio thread data. See termio.ThreadData for docs. pub const ThreadData = union(Kind) { manual: void, + exec: Exec, - exec: struct { + pub const Exec = struct { /// Process start time and boolean of whether its already exited. start: std.time.Instant, exited: bool = false, @@ -45,7 +53,36 @@ pub const ThreadData = union(Kind) { /// false we'll show a process exited message and wait for user input /// to close the surface. wait_after_command: bool, - }, + + /// The data stream is the main IO for the pty. + write_stream: xev.Stream, + + /// This is the pool of available (unused) write requests. If you grab + /// one from the pool, you must put it back when you're done! + write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, + + /// The pool of available buffers for writing to the pty. + write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, + + /// The write queue for the data stream. + write_queue: xev.Stream.WriteQueue = .{}, + }; + + pub fn deinit(self: *ThreadData, alloc: Allocator) void { + switch (self.*) { + .manual => {}, + .exec => |*exec| { + // Clear our write pools. We know we aren't ever going to do + // any more IO since we stop our data stream below so we can just + // drop this. + exec.write_req_pool.deinit(alloc); + exec.write_buf_pool.deinit(alloc); + + // Stop our write stream + exec.write_stream.deinit(); + }, + } + } pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void { switch (self.*) { From 2e62e3354b9021e74cca215a543dd448cc270d05 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 14:59:25 -0700 Subject: [PATCH 07/32] termio: cleanup more state --- src/termio/Termio.zig | 116 +++++++++++++--------------------- src/termio/reader.zig | 10 +++ src/termio/stream_handler.zig | 2 +- 3 files changed, 56 insertions(+), 72 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f2a501b64..fb6c53823 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -236,8 +236,8 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo errdefer posix.close(pipe[1]); // Setup our data that is used for callbacks - var ev_data_ptr = try alloc.create(EventData); - errdefer alloc.destroy(ev_data_ptr); + var read_data_ptr = try alloc.create(ReadData); + errdefer alloc.destroy(read_data_ptr); // Setup our stream so that we can write. var stream = xev.Stream.initFd(pty_fds.write); @@ -282,14 +282,10 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo }; // Setup our event data before we start - ev_data_ptr.* = .{ - .writer_mailbox = thread.mailbox, - .writer_wakeup = thread.wakeup, - .surface_mailbox = self.surface_mailbox, + read_data_ptr.* = .{ .renderer_state = self.renderer_state, .renderer_wakeup = self.renderer_wakeup, .renderer_mailbox = self.renderer_mailbox, - .process = process, .loop = &thread.loop, .terminal_stream = .{ .handler = handler, @@ -302,41 +298,45 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo }, }, }; - errdefer ev_data_ptr.deinit(self.alloc); - - // Start our process watcher - process.wait( - ev_data_ptr.loop, - &ev_data_ptr.process_wait_c, - ThreadData, - data, - processExit, - ); + errdefer read_data_ptr.deinit(); // Start our reader thread const read_thread = try std.Thread.spawn( .{}, if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, - .{ pty_fds.read, ev_data_ptr, pipe[0] }, + .{ pty_fds.read, read_data_ptr, pipe[0] }, ); read_thread.setName("io-reader") catch {}; // Return our thread data data.* = .{ .alloc = alloc, - .ev = ev_data_ptr, .loop = &thread.loop, + .renderer_state = self.renderer_state, .surface_mailbox = self.surface_mailbox, + .writer_mailbox = thread.mailbox, + .writer_wakeup = thread.wakeup, .reader = .{ .exec = .{ .start = process_start, .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, .wait_after_command = self.config.wait_after_command, .write_stream = stream, + .process = process, } }, .read_thread = read_thread, .read_thread_pipe = pipe[1], .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, + .read_thread_data = read_data_ptr, }; + + // Start our process watcher + process.wait( + &thread.loop, + &data.reader.exec.process_wait_c, + ThreadData, + data, + processExit, + ); } /// This outputs an error message when exec failed and we are the @@ -405,7 +405,7 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // Update our stream handler. The stream handler uses the same // renderer mutex so this is safe to do despite being executed // from another thread. - td.ev.terminal_stream.handler.changeConfig(&self.config); + td.read_thread_data.terminal_stream.handler.changeConfig(&self.config); td.reader.changeConfig(&self.config); // Update the configuration that we know about. @@ -711,7 +711,7 @@ fn queueWriteExec( } fn readInternal( - ev: *EventData, + ev: *ReadData, buf: []const u8, ) void { // log.info("DATA: {d}", .{n}); @@ -722,7 +722,7 @@ fn readInternal( defer ev.renderer_state.mutex.unlock(); // Schedule a render. We can call this first because we have the lock. - ev.queueRender() catch unreachable; + ev.terminal_stream.handler.queueRender() catch unreachable; // Whenever a character is typed, we ensure the cursor is in the // non-blink state so it is rendered if visible. If we're under @@ -758,9 +758,10 @@ fn readInternal( // thread, then we need to wake it up so that it processes them. if (ev.terminal_stream.handler.writer_messaged) { ev.terminal_stream.handler.writer_messaged = false; - ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer thread err={}", .{err}); - }; + // TODO + // ev.writer_wakeup.notify() catch |err| { + // log.warn("failed to wake up writer thread err={}", .{err}); + // }; } } @@ -774,15 +775,17 @@ pub const ThreadData = struct { /// Allocator used for the event data alloc: Allocator, - /// The data that is attached to the callbacks. - ev: *EventData, - /// The event loop associated with this thread. This is owned by /// the Thread but we have a pointer so we can queue new work to it. loop: *xev.Loop, + /// The shared render state + renderer_state: *renderer.State, + /// Mailboxes for different threads surface_mailbox: apprt.surface.Mailbox, + writer_mailbox: *termio.Mailbox, + writer_wakeup: xev.Async, /// Data associated with the reader implementation (i.e. pty/exec state) reader: termio.reader.ThreadData, @@ -791,28 +794,19 @@ pub const ThreadData = struct { read_thread: std.Thread, read_thread_pipe: posix.fd_t, read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, + read_thread_data: *ReadData, pub fn deinit(self: *ThreadData) void { posix.close(self.read_thread_pipe); - self.ev.deinit(self.alloc); + self.read_thread_data.deinit(); self.reader.deinit(self.alloc); - self.alloc.destroy(self.ev); + self.alloc.destroy(self.read_thread_data); self.* = undefined; } }; -pub const EventData = struct { - // The preallocation size for the write request pool. This should be big - // enough to satisfy most write requests. It must be a power of 2. - const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); - - /// Mailbox for data to the writer thread. - writer_mailbox: *termio.Mailbox, - writer_wakeup: xev.Async, - - /// Mailbox for the surface. - surface_mailbox: apprt.surface.Mailbox, - +/// Thread local data for the reader thread. +pub const ReadData = struct { /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. terminal_stream: terminal.Stream(StreamHandler), @@ -827,13 +821,6 @@ pub const EventData = struct { /// The mailbox for notifying the renderer of things. renderer_mailbox: *renderer.Thread.Mailbox, - /// The process watcher - process: xev.Process, - - /// This is used for both waiting for the process to exit and then - /// subsequently to wait for the data_stream to close. - process_wait_c: xev.Completion = .{}, - /// The event loop, loop: *xev.Loop, @@ -841,23 +828,11 @@ pub const EventData = struct { /// flooding with cursor resets. last_cursor_reset: i64 = 0, - pub fn deinit(self: *EventData, alloc: Allocator) void { - _ = alloc; - - // Stop our process watcher - self.process.deinit(); - + pub fn deinit(self: *ReadData) void { // Clear any StreamHandler state self.terminal_stream.handler.deinit(); self.terminal_stream.deinit(); } - - /// This queues a render operation with the renderer thread. The render - /// isn't guaranteed to happen immediately but it will happen as soon as - /// practical. - pub inline fn queueRender(self: *EventData) !void { - try self.renderer_wakeup.notify(); - } }; fn processExit( @@ -870,7 +845,6 @@ fn processExit( const td = td_.?; assert(td.reader == .exec); - const ev = td.ev; const execdata = &td.reader.exec; execdata.exited = true; @@ -907,13 +881,13 @@ fn processExit( // Notify our main writer thread which has access to more // information so it can show a better error message. - _ = ev.writer_mailbox.push(.{ + _ = td.writer_mailbox.push(.{ .child_exited_abnormally = .{ .exit_code = exit_code, .runtime_ms = runtime, }, }, .{ .forever = {} }); - ev.writer_wakeup.notify() catch break :runtime; + td.writer_wakeup.notify() catch break :runtime; return .disarm; } @@ -925,9 +899,9 @@ fn processExit( // We output a message so that the user knows whats going on and // doesn't think their terminal just froze. terminal: { - ev.renderer_state.mutex.lock(); - defer ev.renderer_state.mutex.unlock(); - const t = ev.renderer_state.terminal; + td.renderer_state.mutex.lock(); + defer td.renderer_state.mutex.unlock(); + const t = td.renderer_state.terminal; t.carriageReturn(); t.linefeed() catch break :terminal; t.printString("Process exited. Press any key to close the terminal.") catch @@ -939,7 +913,7 @@ fn processExit( } // Notify our surface we want to close - _ = ev.surface_mailbox.push(.{ + _ = td.surface_mailbox.push(.{ .child_exited = {}, }, .{ .forever = {} }); @@ -1614,7 +1588,7 @@ const Subprocess = struct { /// fds and this is still much faster and lower overhead than any async /// mechanism. const ReadThread = struct { - fn threadMainPosix(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { + fn threadMainPosix(fd: posix.fd_t, ev: *ReadData, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1695,7 +1669,7 @@ const ReadThread = struct { } } - fn threadMainWindows(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { + fn threadMainWindows(fd: posix.fd_t, ev: *ReadData, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); diff --git a/src/termio/reader.zig b/src/termio/reader.zig index 10a6e9980..31d51498f 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -57,6 +57,9 @@ pub const ThreadData = union(Kind) { /// The data stream is the main IO for the pty. write_stream: xev.Stream, + /// The process watcher + process: xev.Process, + /// This is the pool of available (unused) write requests. If you grab /// one from the pool, you must put it back when you're done! write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, @@ -66,6 +69,10 @@ pub const ThreadData = union(Kind) { /// The write queue for the data stream. write_queue: xev.Stream.WriteQueue = .{}, + + /// This is used for both waiting for the process to exit and then + /// subsequently to wait for the data_stream to close. + process_wait_c: xev.Completion = .{}, }; pub fn deinit(self: *ThreadData, alloc: Allocator) void { @@ -78,6 +85,9 @@ pub const ThreadData = union(Kind) { exec.write_req_pool.deinit(alloc); exec.write_buf_pool.deinit(alloc); + // Stop our process watcher + exec.process.deinit(); + // Stop our write stream exec.write_stream.deinit(); }, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index f88aeca97..b7774250f 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -104,7 +104,7 @@ pub const StreamHandler = struct { /// This queues a render operation with the renderer thread. The render /// isn't guaranteed to happen immediately but it will happen as soon as /// practical. - inline fn queueRender(self: *StreamHandler) !void { + pub inline fn queueRender(self: *StreamHandler) !void { try self.renderer_wakeup.notify(); } From b3c2479f8768564dd99202fbd4af0ac239351685 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 15:17:36 -0700 Subject: [PATCH 08/32] termio: move subprocess out to its own file --- src/termio.zig | 1 + src/termio/Exec.zig | 3497 +++++++---------------------------------- src/termio/Termio.zig | 636 +------- src/termio/reader.zig | 12 +- 4 files changed, 604 insertions(+), 3542 deletions(-) diff --git a/src/termio.zig b/src/termio.zig index 2d663addd..d868dfd6d 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -6,6 +6,7 @@ const stream_handler = @import("termio/stream_handler.zig"); pub usingnamespace @import("termio/message.zig"); pub const reader = @import("termio/reader.zig"); +pub const Exec = @import("termio/Exec.zig"); pub const Options = @import("termio/Options.zig"); pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index b0f973b1e..ba2a7034d 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1,2973 +1,656 @@ -//! Implementation of IO that uses child exec to talk to the child process. -pub const Exec = @This(); +//! Exec implements the logic for starting and stopping a subprocess with a +//! pty as well as spinning up the necessary read thread to read from the +//! pty and forward it to the Termio instance. +const Exec = @This(); const std = @import("std"); const builtin = @import("builtin"); -const build_config = @import("../build_config.zig"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const EnvMap = std.process.EnvMap; const posix = std.posix; +const xev = @import("xev"); +const build_config = @import("../build_config.zig"); +const configpkg = @import("../config.zig"); +const internal_os = @import("../os/main.zig"); +const renderer = @import("../renderer.zig"); +const shell_integration = @import("shell_integration.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); -const Pty = @import("../pty.zig").Pty; const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; -const terminal = @import("../terminal/main.zig"); -const terminfo = @import("../terminfo/main.zig"); -const xev = @import("xev"); -const renderer = @import("../renderer.zig"); -const apprt = @import("../apprt.zig"); -const fastmem = @import("../fastmem.zig"); -const internal_os = @import("../os/main.zig"); +const Pty = @import("../pty.zig").Pty; +const EnvMap = std.process.EnvMap; const windows = internal_os.windows; -const configpkg = @import("../config.zig"); -const shell_integration = @import("shell_integration.zig"); const log = std.log.scoped(.io_exec); +/// If we build with flatpak support then we have to keep track of +/// a potential execution on the host. +const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; + const c = @cImport({ @cInclude("errno.h"); @cInclude("signal.h"); @cInclude("unistd.h"); }); -/// True if we should disable the kitty keyboard protocol. We have to -/// disable this on GLFW because GLFW input events don't support the -/// correct granularity of events. -const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; - -/// Allocator -alloc: Allocator, - -/// This is the pty fd created for the subcommand. -subprocess: Subprocess, - -/// The derived configuration for this termio implementation. -config: DerivedConfig, - -/// The terminal emulator internal state. This is the abstract "terminal" -/// that manages input, grid updating, etc. and is renderer-agnostic. It -/// just stores internal state about a grid. -terminal: terminal.Terminal, - -/// The shared render state -renderer_state: *renderer.State, - -/// A handle to wake up the renderer. This hints to the renderer that that -/// a repaint should happen. -renderer_wakeup: xev.Async, - -/// The mailbox for notifying the renderer of things. -renderer_mailbox: *renderer.Thread.Mailbox, - -/// The mailbox for communicating with the surface. -surface_mailbox: apprt.surface.Mailbox, - -/// The cached grid size whenever a resize is called. +arena: std.heap.ArenaAllocator, +cwd: ?[]const u8, +env: EnvMap, +args: [][]const u8, grid_size: renderer.GridSize, +screen_size: renderer.ScreenSize, +pty: ?Pty = null, +command: ?Command = null, +flatpak_command: ?FlatpakHostCommand = null, +linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, -/// The data associated with the currently running thread. -data: ?*EventData, +/// Initialize the subprocess. This will NOT start it, this only sets +/// up the internal state necessary to start it later. +pub fn init(gpa: Allocator, opts: termio.Options) !Exec { + // We have a lot of maybe-allocations that all share the same lifetime + // so use an arena so we don't end up in an accounting nightmare. + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); -/// The configuration for this IO that is derived from the main -/// configuration. This must be exported so that we don't need to -/// pass around Config pointers which makes memory management a pain. -pub const DerivedConfig = struct { - arena: ArenaAllocator, + // Set our env vars. For Flatpak builds running in Flatpak we don't + // inherit our environment because the login shell on the host side + // will get it. + var env = env: { + if (comptime build_config.flatpak) { + if (internal_os.isFlatpak()) { + break :env std.process.EnvMap.init(alloc); + } + } - palette: terminal.color.Palette, - image_storage_limit: usize, - cursor_style: terminal.CursorStyle, - cursor_blink: ?bool, - cursor_color: ?configpkg.Config.Color, - foreground: configpkg.Config.Color, - background: configpkg.Config.Color, - osc_color_report_format: configpkg.Config.OSCColorReportFormat, - term: []const u8, - grapheme_width_method: configpkg.Config.GraphemeWidthMethod, - abnormal_runtime_threshold_ms: u32, - wait_after_command: bool, - enquiry_response: []const u8, + break :env try std.process.getEnvMap(alloc); + }; + errdefer env.deinit(); - pub fn init( - alloc_gpa: Allocator, - config: *const configpkg.Config, - ) !DerivedConfig { - var arena = ArenaAllocator.init(alloc_gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); + // If we have a resources dir then set our env var + if (opts.resources_dir) |dir| { + log.info("found Ghostty resources dir: {s}", .{dir}); + try env.put("GHOSTTY_RESOURCES_DIR", dir); + } - return .{ - .palette = config.palette.value, - .image_storage_limit = config.@"image-storage-limit", - .cursor_style = config.@"cursor-style", - .cursor_blink = config.@"cursor-style-blink", - .cursor_color = config.@"cursor-color", - .foreground = config.foreground, - .background = config.background, - .osc_color_report_format = config.@"osc-color-report-format", - .term = try alloc.dupe(u8, config.term), - .grapheme_width_method = config.@"grapheme-width-method", - .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", - .wait_after_command = config.@"wait-after-command", - .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), + // Set our TERM var. This is a bit complicated because we want to use + // the ghostty TERM value but we want to only do that if we have + // ghostty in the TERMINFO database. + // + // For now, we just look up a bundled dir but in the future we should + // also load the terminfo database and look for it. + if (opts.resources_dir) |base| { + try env.put("TERM", opts.config.term); + try env.put("COLORTERM", "truecolor"); - // This has to be last so that we copy AFTER the arena allocations - // above happen (Zig assigns in order). - .arena = arena, + // Assume that the resources directory is adjacent to the terminfo + // database + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ + std.fs.path.dirname(base) orelse unreachable, + }); + try env.put("TERMINFO", dir); + } else { + if (comptime builtin.target.isDarwin()) { + log.warn("ghostty terminfo not found, using xterm-256color", .{}); + log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); + log.warn("you're using a valid app bundle.", .{}); + } + + try env.put("TERM", "xterm-256color"); + try env.put("COLORTERM", "truecolor"); + } + + // Add our binary to the path if we can find it. + ghostty_path: { + var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { + log.warn("failed to get ghostty exe path err={}", .{err}); + break :ghostty_path; }; + const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; + log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); + + // We always set this so that if the shell overwrites the path + // scripts still have a way to find the Ghostty binary when + // running in Ghostty. + try env.put("GHOSTTY_BIN_DIR", exe_dir); + + // Append if we have a path. We want to append so that ghostty is + // the last priority in the path. If we don't have a path set + // then we just set it to the directory of the binary. + if (env.get("PATH")) |path| { + // Verify that our path doesn't already contain this entry + var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); + while (it.next()) |entry| { + if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; + } + + try env.put( + "PATH", + try internal_os.appendEnv(alloc, path, exe_dir), + ); + } else { + try env.put("PATH", exe_dir); + } } - pub fn deinit(self: *DerivedConfig) void { - self.arena.deinit(); - } -}; + // Add the man pages from our application bundle to MANPATH. + if (comptime builtin.target.isDarwin()) { + if (opts.resources_dir) |resources_dir| man: { + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { + log.warn("error building manpath, man pages may not be available err={}", .{err}); + break :man; + }; -/// Initialize the exec implementation. This will also start the child -/// process. -pub fn init(alloc: Allocator, opts: termio.Options) !Exec { - // Create our terminal - var term = try terminal.Terminal.init(alloc, .{ - .cols = opts.grid_size.columns, - .rows = opts.grid_size.rows, - .max_scrollback = opts.full_config.@"scrollback-limit", - }); - errdefer term.deinit(alloc); - term.default_palette = opts.config.palette; - term.color_palette.colors = opts.config.palette; - - // Setup our initial grapheme cluster support if enabled. We use a - // switch to ensure we get a compiler error if more cases are added. - switch (opts.config.grapheme_width_method) { - .unicode => term.modes.set(.grapheme_cluster, true), - .legacy => {}, - } - - // Set the image size limits - try term.screen.kitty_images.setLimit( - alloc, - &term.screen, - opts.config.image_storage_limit, - ); - try term.secondary_screen.kitty_images.setLimit( - alloc, - &term.secondary_screen, - opts.config.image_storage_limit, - ); - - // Set default cursor blink settings - term.modes.set( - .cursor_blinking, - opts.config.cursor_blink orelse true, - ); - - // Set our default cursor style - term.screen.cursor.cursor_style = opts.config.cursor_style; - - var subprocess = try Subprocess.init(alloc, opts); - errdefer subprocess.deinit(); - - // If we have an initial pwd requested by the subprocess, then we - // set that on the terminal now. This allows rapidly initializing - // new surfaces to use the proper pwd. - if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { - log.warn("error setting initial pwd err={}", .{err}); - }; - - // Initial width/height based on subprocess - term.width_px = subprocess.screen_size.width; - term.height_px = subprocess.screen_size.height; - - return .{ - .alloc = alloc, - .terminal = term, - .subprocess = subprocess, - .config = opts.config, - .renderer_state = opts.renderer_state, - .renderer_wakeup = opts.renderer_wakeup, - .renderer_mailbox = opts.renderer_mailbox, - .surface_mailbox = opts.surface_mailbox, - .grid_size = opts.grid_size, - .data = null, - }; -} - -pub fn deinit(self: *Exec) void { - self.subprocess.deinit(); - self.terminal.deinit(self.alloc); - self.config.deinit(); -} - -pub fn threadEnter(self: *Exec, thread: *termio.Thread) !ThreadData { - assert(self.data == null); - const alloc = self.alloc; - - // Start our subprocess - const pty_fds = self.subprocess.start(alloc) catch |err| { - // If we specifically got this error then we are in the forked - // process and our child failed to execute. In that case - if (err != error.ExecFailedInChild) return err; - - // Output an error message about the exec faililng and exit. - // This generally should NOT happen because we always wrap - // our command execution either in login (macOS) or /bin/sh - // (Linux) which are usually guaranteed to exist. Still, we - // want to handle this scenario. - self.execFailedInChild() catch {}; - posix.exit(1); - }; - errdefer self.subprocess.stop(); - const pid = pid: { - const command = self.subprocess.command orelse return error.ProcessNotStarted; - break :pid command.pid orelse return error.ProcessNoPid; - }; - - // Track our process start time so we know how long it was - // running for. - const process_start = try std.time.Instant.now(); - - // Create our pipe that we'll use to kill our read thread. - // pipe[0] is the read end, pipe[1] is the write end. - const pipe = try internal_os.pipe(); - errdefer posix.close(pipe[0]); - errdefer posix.close(pipe[1]); - - // Setup our data that is used for callbacks - var ev_data_ptr = try alloc.create(EventData); - errdefer alloc.destroy(ev_data_ptr); - - // Setup our stream so that we can write. - var stream = xev.Stream.initFd(pty_fds.write); - errdefer stream.deinit(); - - // Wakeup watcher for the writer thread. - var wakeup = try xev.Async.init(); - errdefer wakeup.deinit(); - - // Watcher to detect subprocess exit - var process = try xev.Process.init(pid); - errdefer process.deinit(); - - // Setup our event data before we start - ev_data_ptr.* = .{ - .writer_mailbox = thread.mailbox, - .writer_wakeup = thread.wakeup, - .surface_mailbox = self.surface_mailbox, - .renderer_state = self.renderer_state, - .renderer_wakeup = self.renderer_wakeup, - .renderer_mailbox = self.renderer_mailbox, - .process = process, - .process_start = process_start, - .data_stream = stream, - .loop = &thread.loop, - .terminal_stream = .{ - .handler = StreamHandler.init( - self.alloc, - ev_data_ptr, - &self.grid_size, - &self.terminal, - &self.config, - ), - .parser = .{ - .osc_parser = .{ - // Populate the OSC parser allocator (optional) because - // we want to support large OSC payloads such as OSC 52. - .alloc = self.alloc, - }, - }, - }, - .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, - .wait_after_command = self.config.wait_after_command, - }; - errdefer ev_data_ptr.deinit(self.alloc); - - // Store our data so our callbacks can access it - self.data = ev_data_ptr; - errdefer self.data = null; - - // Start our process watcher - process.wait( - ev_data_ptr.loop, - &ev_data_ptr.process_wait_c, - EventData, - ev_data_ptr, - processExit, - ); - - // Start our reader thread - const read_thread = try std.Thread.spawn( - .{}, - if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, - .{ pty_fds.read, ev_data_ptr, pipe[0] }, - ); - read_thread.setName("io-reader") catch {}; - - // Return our thread data - return ThreadData{ - .alloc = alloc, - .ev = ev_data_ptr, - .read_thread = read_thread, - .read_thread_pipe = pipe[1], - .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, - }; -} - -/// This outputs an error message when exec failed and we are the -/// child process. This returns so the caller should probably exit -/// after calling this. -/// -/// Note that this usually is only called under very very rare -/// circumstances because we wrap our command execution in login -/// (macOS) or /bin/sh (Linux). So this output can be pretty crude -/// because it should never happen. Notably, this is not the error -/// users see when `command` is invalid. -fn execFailedInChild(self: *Exec) !void { - _ = self; - const stderr = std.io.getStdErr().writer(); - try stderr.writeAll("exec failed\n"); - try stderr.writeAll("press any key to exit\n"); - - var buf: [1]u8 = undefined; - var reader = std.io.getStdIn().reader(); - _ = try reader.read(&buf); -} - -pub fn threadExit(self: *Exec, data: ThreadData) void { - // Clear out our data since we're not active anymore. - self.data = null; - - // Stop our subprocess - if (data.ev.process_exited) self.subprocess.externalExit(); - self.subprocess.stop(); - - // Quit our read thread after exiting the subprocess so that - // we don't get stuck waiting for data to stop flowing if it is - // a particularly noisy process. - _ = posix.write(data.read_thread_pipe, "x") catch |err| - log.warn("error writing to read thread quit pipe err={}", .{err}); - - if (comptime builtin.os.tag == .windows) { - // Interrupt the blocking read so the thread can see the quit message - if (windows.kernel32.CancelIoEx(data.read_thread_fd, null) == 0) { - switch (windows.kernel32.GetLastError()) { - .NOT_FOUND => {}, - else => |err| log.warn("error interrupting read thread err={}", .{err}), + if (env.get("MANPATH")) |manpath| { + // Append to the existing MANPATH. It's very unlikely that our bundle's + // resources directory already appears here so we don't spend the time + // searching for it. + try env.put( + "MANPATH", + try internal_os.appendEnv(alloc, manpath, dir), + ); + } else { + try env.put("MANPATH", dir); } } } - data.read_thread.join(); -} + // Set environment variables used by some programs (such as neovim) to detect + // which terminal emulator and version they're running under. + try env.put("TERM_PROGRAM", "ghostty"); + try env.put("TERM_PROGRAM_VERSION", build_config.version_string); -/// Update the configuration. -pub fn changeConfig(self: *Exec, config: *DerivedConfig) !void { - // The remainder of this function is modifying terminal state or - // the read thread data, all of which requires holding the renderer - // state lock. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + // When embedding in macOS and running via XCode, XCode injects + // a bunch of things that break our shell process. We remove those. + if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { + if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { + env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); + env.remove("__XPC_DYLD_LIBRARY_PATH"); + env.remove("DYLD_FRAMEWORK_PATH"); + env.remove("DYLD_INSERT_LIBRARIES"); + env.remove("DYLD_LIBRARY_PATH"); + env.remove("LD_LIBRARY_PATH"); + env.remove("SECURITYSESSIONID"); + env.remove("XPC_SERVICE_NAME"); + } - // Deinit our old config. We do this in the lock because the - // stream handler may be referencing the old config (i.e. enquiry resp) - self.config.deinit(); - self.config = config.*; - - // Update our stream handler. The stream handler uses the same - // renderer mutex so this is safe to do despite being executed - // from another thread. - if (self.data) |data| { - data.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; - data.wait_after_command = config.wait_after_command; - data.terminal_stream.handler.changeConfig(&self.config); + // Remove this so that running `ghostty` within Ghostty works. + env.remove("GHOSTTY_MAC_APP"); } - // Update the configuration that we know about. - // - // Specific things we don't update: - // - command, working-directory: we never restart the underlying - // process so we don't care or need to know about these. + // Don't leak these environment variables to child processes. + if (comptime build_config.app_runtime == .gtk) { + env.remove("GDK_DEBUG"); + env.remove("GSK_RENDERER"); + } - // Update the default palette. Note this will only apply to new colors drawn - // since we decode all palette colors to RGB on usage. - self.terminal.default_palette = config.palette; + // Setup our shell integration, if we can. + const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { + const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { + .windows => "cmd.exe", + else => "sh", + }; - // Update the active palette, except for any colors that were modified with - // OSC 4 - for (0..config.palette.len) |i| { - if (!self.terminal.color_palette.mask.isSet(i)) { - self.terminal.color_palette.colors[i] = config.palette[i]; - self.terminal.flags.dirty.palette = true; + const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + .none => break :shell .{ null, default_shell_command }, + .detect => null, + .bash => .bash, + .elvish => .elvish, + .fish => .fish, + .zsh => .zsh, + }; + + const dir = opts.resources_dir orelse break :shell .{ + null, + default_shell_command, + }; + + const integration = try shell_integration.setup( + alloc, + dir, + default_shell_command, + &env, + force, + opts.full_config.@"shell-integration-features", + ) orelse break :shell .{ null, default_shell_command }; + + break :shell .{ integration.shell, integration.command }; + }; + + if (integrated_shell) |shell| { + log.info( + "shell integration automatically injected shell={}", + .{shell}, + ); + } else if (opts.full_config.@"shell-integration" != .none) { + log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); + } + + // Build our args list + const args = args: { + const cap = 9; // the most we'll ever use + var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); + defer args.deinit(); + + // If we're on macOS, we have to use `login(1)` to get all of + // the proper environment variables set, a login shell, and proper + // hushlogin behavior. + if (comptime builtin.target.isDarwin()) darwin: { + const passwd = internal_os.passwd.get(alloc) catch |err| { + log.warn("failed to read passwd, not using a login shell err={}", .{err}); + break :darwin; + }; + + const username = passwd.name orelse { + log.warn("failed to get username, not using a login shell", .{}); + break :darwin; + }; + + const hush = if (passwd.home) |home| hush: { + var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { + log.warn( + "failed to open home dir, not checking for hushlogin err={}", + .{err}, + ); + break :hush false; + }; + defer dir.close(); + + break :hush if (dir.access(".hushlogin", .{})) true else |_| false; + } else false; + + const cmd = try std.fmt.allocPrint( + alloc, + "exec -l {s}", + .{shell_command}, + ); + + // The reason for executing login this way is unclear. This + // comment will attempt to explain but prepare for a truly + // unhinged reality. + // + // The first major issue is that on macOS, a lot of users + // put shell configurations in ~/.bash_profile instead of + // ~/.bashrc (or equivalent for another shell). This file is only + // loaded for a login shell so macOS users expect all their terminals + // to be login shells. No other platform behaves this way and its + // totally braindead but somehow the entire dev community on + // macOS has cargo culted their way to this reality so we have to + // do it... + // + // To get a login shell, you COULD just prepend argv0 with a `-` + // but that doesn't fully work because `getlogin()` C API will + // return the wrong value, SHELL won't be set, and various + // other login behaviors that macOS users expect. + // + // The proper way is to use `login(1)`. But login(1) forces + // the working directory to change to the home directory, + // which we may not want. If we specify "-l" then we can avoid + // this behavior but now the shell isn't a login shell. + // + // There is another issue: `login(1)` only checks for ".hushlogin" + // in the working directory. This means that if we specify "-l" + // then we won't get hushlogin honored if its in the home + // directory (which is standard). To get around this, we + // check for hushlogin ourselves and if present specify the + // "-q" flag to login(1). + // + // So to get all the behaviors we want, we specify "-l" but + // execute "bash" (which is built-in to macOS). We then use + // the bash builtin "exec" to replace the process with a login + // shell ("-l" on exec) with the command we really want. + // + // We use "bash" instead of other shells that ship with macOS + // because as of macOS Sonoma, we found with a microbenchmark + // that bash can `exec` into the desired command ~2x faster + // than zsh. + // + // To figure out a lot of this logic I read the login.c + // source code in the OSS distribution Apple provides for + // macOS. + // + // Awesome. + try args.append("/usr/bin/login"); + if (hush) try args.append("-q"); + try args.append("-flp"); + + // We execute bash with "--noprofile --norc" so that it doesn't + // load startup files so that (1) our shell integration doesn't + // break and (2) user configuration doesn't mess this process + // up. + try args.append(username); + try args.append("/bin/bash"); + try args.append("--noprofile"); + try args.append("--norc"); + try args.append("-c"); + try args.append(cmd); + break :args try args.toOwnedSlice(); + } + + if (comptime builtin.os.tag == .windows) { + // We run our shell wrapped in `cmd.exe` so that we don't have + // to parse the command line ourselves if it has arguments. + + // Note we don't free any of the memory below since it is + // allocated in the arena. + const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); + const cmd = try std.fs.path.join(alloc, &[_][]const u8{ + windir, + "System32", + "cmd.exe", + }); + + try args.append(cmd); + try args.append("/C"); + } else { + // We run our shell wrapped in `/bin/sh` so that we don't have + // to parse the command line ourselves if it has arguments. + // Additionally, some environments (NixOS, I found) use /bin/sh + // to setup some environment variables that are important to + // have set. + try args.append("/bin/sh"); + if (internal_os.isFlatpak()) try args.append("-l"); + try args.append("-c"); + } + + try args.append(shell_command); + break :args try args.toOwnedSlice(); + }; + + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| + try alloc.dupe(u8, cwd) + else + null; + + // If we have a cgroup, then we copy that into our arena so the + // memory remains valid when we start. + const linux_cgroup: Command.LinuxCgroup = cgroup: { + const default = Command.linux_cgroup_default; + if (comptime builtin.os.tag != .linux) break :cgroup default; + const path = opts.linux_cgroup orelse break :cgroup default; + break :cgroup try alloc.dupe(u8, path); + }; + + // Our screen size should be our padded size + const padded_size = opts.screen_size.subPadding(opts.padding); + + return .{ + .arena = arena, + .env = env, + .cwd = cwd, + .args = args, + .grid_size = opts.grid_size, + .screen_size = padded_size, + .linux_cgroup = linux_cgroup, + }; +} + +/// Clean up the subprocess. This will stop the subprocess if it is started. +pub fn deinit(self: *Exec) void { + self.stop(); + if (self.pty) |*pty| pty.deinit(); + self.arena.deinit(); + self.* = undefined; +} + +/// Start the subprocess. If the subprocess is already started this +/// will crash. +pub fn start(self: *Exec, alloc: Allocator) !struct { + read: Pty.Fd, + write: Pty.Fd, +} { + assert(self.pty == null and self.command == null); + + // Create our pty + var pty = try Pty.open(.{ + .ws_row = @intCast(self.grid_size.rows), + .ws_col = @intCast(self.grid_size.columns), + .ws_xpixel = @intCast(self.screen_size.width), + .ws_ypixel = @intCast(self.screen_size.height), + }); + self.pty = pty; + errdefer { + pty.deinit(); + self.pty = null; + } + + log.debug("starting command command={s}", .{self.args}); + + // In flatpak, we use the HostCommand to execute our shell. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but flatpak support not built-in", .{}); + break :flatpak; + } + + // Flatpak command must have a stable pointer. + self.flatpak_command = .{ + .argv = self.args, + .env = &self.env, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + var cmd = &self.flatpak_command.?; + const pid = try cmd.spawn(alloc); + errdefer killCommandFlatpak(cmd); + + log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ + self.args[0], + pid, + }); + + // Once started, we can close the pty child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = posix.close(pty.slave); + + return .{ + .read = pty.master, + .write = pty.master, + }; + } + + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { + if (std.fs.accessAbsolute(proposed, .{})) { + break :cwd proposed; + } else |err| { + log.warn("cannot access cwd, ignoring: {}", .{err}); + break :cwd null; + } + } else null; + + // Build our subcommand + var cmd: Command = .{ + .path = self.args[0], + .args = self.args, + .env = &self.env, + .cwd = cwd, + .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, + .pre_exec = if (builtin.os.tag == .windows) null else (struct { + fn callback(cmd: *Command) void { + const sp = cmd.getData(Exec) orelse unreachable; + sp.childPreExec() catch |err| log.err( + "error initializing child: {}", + .{err}, + ); + } + }).callback, + .data = self, + .linux_cgroup = self.linux_cgroup, + }; + try cmd.start(alloc); + errdefer killCommand(&cmd) catch |err| { + log.warn("error killing command during cleanup err={}", .{err}); + }; + log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); + if (comptime builtin.os.tag == .linux) { + log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); + } + + self.command = cmd; + return switch (builtin.os.tag) { + .windows => .{ + .read = pty.out_pipe, + .write = pty.in_pipe, + }, + + else => .{ + .read = pty.master, + .write = pty.master, + }, + }; +} + +/// This should be called after fork but before exec in the child process. +/// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before +/// exec is called; it does NOT run in the main Ghostty process. +fn childPreExec(self: *Exec) !void { + // Setup our pty + try self.pty.?.childPreExec(); +} + +/// Called to notify that we exited externally so we can unset our +/// running state. +pub fn externalExit(self: *Exec) void { + self.command = null; +} + +/// Stop the subprocess. This is safe to call anytime. This will wait +/// for the subprocess to register that it has been signalled, but not +/// for it to terminate, so it will not block. +/// This does not close the pty. +pub fn stop(self: *Exec) void { + // Kill our command + if (self.command) |*cmd| { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + killCommand(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + self.command = null; + } + + // Kill our Flatpak command + if (FlatpakHostCommand != void) { + if (self.flatpak_command) |*cmd| { + killCommandFlatpak(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + _ = cmd.wait() catch |err| + log.err("error waiting for command to exit: {}", .{err}); + self.flatpak_command = null; } } - - // Set the image size limits - try self.terminal.screen.kitty_images.setLimit( - self.alloc, - &self.terminal.screen, - config.image_storage_limit, - ); - try self.terminal.secondary_screen.kitty_images.setLimit( - self.alloc, - &self.terminal.secondary_screen, - config.image_storage_limit, - ); } -/// Resize the terminal. +/// Resize the pty subprocess. This is safe to call anytime. pub fn resize( self: *Exec, grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, - padding: renderer.Padding, ) !void { - // Update the size of our pty. - const padded_size = screen_size.subPadding(padding); - try self.subprocess.resize(grid_size, padded_size); - - // Update our cached grid size self.grid_size = grid_size; - - // Enter the critical area that we want to keep small - { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // Update the size of our terminal state - try self.terminal.resize( - self.alloc, - grid_size.columns, - grid_size.rows, - ); - - // Update our pixel sizes - self.terminal.width_px = padded_size.width; - self.terminal.height_px = padded_size.height; - - // Disable synchronized output mode so that we show changes - // immediately for a resize. This is allowed by the spec. - self.terminal.modes.set(.synchronized_output, false); - - // Wake up our renderer so any changes will be shown asap - self.renderer_wakeup.notify() catch {}; - } -} - -/// Reset the synchronized output mode. This is usually called by timer -/// expiration from the termio thread. -pub fn resetSynchronizedOutput(self: *Exec) void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.terminal.modes.set(.synchronized_output, false); - self.renderer_wakeup.notify() catch {}; -} - -/// Clear the screen. -pub fn clearScreen(self: *Exec, history: bool) !void { - { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // If we're on the alternate screen, we do not clear. Since this is an - // emulator-level screen clear, this messes up the running programs - // knowledge of where the cursor is and causes rendering issues. So, - // for alt screen, we do nothing. - if (self.terminal.active_screen == .alternate) return; - - // Clear our scrollback - if (history) self.terminal.eraseDisplay(.scrollback, false); - - // If we're not at a prompt, we just delete above the cursor. - if (!self.terminal.cursorIsAtPrompt()) { - if (self.terminal.screen.cursor.y > 0) { - self.terminal.screen.eraseRows( - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, - ); - } - - return; - } - - // At a prompt, we want to first fully clear the screen, and then after - // send a FF (0x0C) to the shell so that it can repaint the screen. - // Mark the current row as a not a prompt so we can properly - // clear the full screen in the next eraseDisplay call. - self.terminal.markSemanticPrompt(.command); - assert(!self.terminal.cursorIsAtPrompt()); - self.terminal.eraseDisplay(.complete, false); - } - - // If we reached here it means we're at a prompt, so we send a form-feed. - try self.queueWrite(&[_]u8{0x0C}, false); -} - -/// Scroll the viewport -pub fn scrollViewport(self: *Exec, scroll: terminal.Terminal.ScrollViewport) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - try self.terminal.scrollViewport(scroll); -} - -/// Jump the viewport to the prompt. -pub fn jumpToPrompt(self: *Exec, delta: isize) !void { - { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.terminal.screen.scroll(.{ .delta_prompt = delta }); - } - - try self.renderer_wakeup.notify(); -} - -/// Called when the child process exited abnormally but before -/// the surface is notified. -pub fn childExitedAbnormally(self: *Exec, exit_code: u32, runtime_ms: u64) !void { - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const alloc = arena.allocator(); - - // Build up our command for the error message - const command = try std.mem.join(alloc, " ", self.subprocess.args); - const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); - - // Modify the terminal to show our error message. This - // requires grabbing the renderer state lock. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = self.renderer_state.terminal; - - // No matter what move the cursor back to the column 0. - t.carriageReturn(); - - // Reset styles - try t.setAttribute(.{ .unset = {} }); - - // If there is data in the viewport, we want to scroll down - // a little bit and write a horizontal rule before writing - // our message. This lets the use see the error message the - // command may have output. - const viewport_str = try t.plainString(alloc); - if (viewport_str.len > 0) { - try t.linefeed(); - for (0..t.cols) |_| try t.print(0x2501); - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - } - - // Output our error message - try t.setAttribute(.{ .@"8_fg" = .bright_red }); - try t.setAttribute(.{ .bold = {} }); - try t.printString("Ghostty failed to launch the requested command:"); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString(command); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Runtime: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(runtime_str); - try t.setAttribute(.{ .unset = {} }); - - // We don't print this on macOS because the exit code is always 0 - // due to the way we launch the process. - if (comptime !builtin.target.isDarwin()) { - const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); - t.carriageReturn(); - try t.linefeed(); - try t.printString("Exit Code: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(exit_code_str); - try t.setAttribute(.{ .unset = {} }); - } - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Press any key to close the window."); - - // Hide the cursor - t.modes.set(.cursor_visible, false); -} - -pub inline fn queueWrite(self: *Exec, data: []const u8, linefeed: bool) !void { - const ev = self.data.?; - - // If our process is exited then we send our surface a message - // about it but we don't queue any more writes. - if (ev.process_exited) { - _ = ev.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } - - // We go through and chunk the data if necessary to fit into - // our cached buffers that we can queue to the stream. - var i: usize = 0; - while (i < data.len) { - const req = try ev.write_req_pool.getGrow(self.alloc); - const buf = try ev.write_buf_pool.getGrow(self.alloc); - const slice = slice: { - // The maximum end index is either the end of our data or - // the end of our buffer, whichever is smaller. - const max = @min(data.len, i + buf.len); - - // Fast - if (!linefeed) { - fastmem.copy(u8, buf, data[i..max]); - const len = max - i; - i = max; - break :slice buf[0..len]; - } - - // Slow, have to replace \r with \r\n - var buf_i: usize = 0; - while (i < data.len and buf_i < buf.len - 1) { - const ch = data[i]; - i += 1; - - if (ch != '\r') { - buf[buf_i] = ch; - buf_i += 1; - continue; - } - - // CRLF - buf[buf_i] = '\r'; - buf[buf_i + 1] = '\n'; - buf_i += 2; - } - - break :slice buf[0..buf_i]; - }; - - //for (slice) |b| log.warn("write: {x}", .{b}); - - ev.data_stream.queueWrite( - ev.loop, - &ev.write_queue, - req, - .{ .slice = slice }, - EventData, - ev, - ttyWrite, - ); - } -} - -const ThreadData = struct { - /// Allocator used for the event data - alloc: Allocator, - - /// The data that is attached to the callbacks. - ev: *EventData, - - /// Our read thread - read_thread: std.Thread, - read_thread_pipe: posix.fd_t, - read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, - - pub fn deinit(self: *ThreadData) void { - posix.close(self.read_thread_pipe); - self.ev.deinit(self.alloc); - self.alloc.destroy(self.ev); - self.* = undefined; - } -}; - -const EventData = struct { - // The preallocation size for the write request pool. This should be big - // enough to satisfy most write requests. It must be a power of 2. - const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); - - /// Mailbox for data to the writer thread. - writer_mailbox: *termio.Mailbox, - writer_wakeup: xev.Async, - - /// Mailbox for the surface. - surface_mailbox: apprt.surface.Mailbox, - - /// The stream parser. This parses the stream of escape codes and so on - /// from the child process and calls callbacks in the stream handler. - terminal_stream: terminal.Stream(StreamHandler), - - /// The shared render state - renderer_state: *renderer.State, - - /// A handle to wake up the renderer. This hints to the renderer that that - /// a repaint should happen. - renderer_wakeup: xev.Async, - - /// The mailbox for notifying the renderer of things. - renderer_mailbox: *renderer.Thread.Mailbox, - - /// The process watcher - process: xev.Process, - process_start: std.time.Instant, - process_exited: bool = false, - - /// This is used for both waiting for the process to exit and then - /// subsequently to wait for the data_stream to close. - process_wait_c: xev.Completion = .{}, - - /// The data stream is the main IO for the pty. - data_stream: xev.Stream, - - /// The event loop, - loop: *xev.Loop, - - /// The write queue for the data stream. - write_queue: xev.Stream.WriteQueue = .{}, - - /// This is the pool of available (unused) write requests. If you grab - /// one from the pool, you must put it back when you're done! - write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, - - /// The pool of available buffers for writing to the pty. - write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, - - /// Last time the cursor was reset. This is used to prevent message - /// flooding with cursor resets. - last_cursor_reset: i64 = 0, - - /// This is set to true when we've seen a title escape sequence. We use - /// this to determine if we need to default the window title. - seen_title: bool = false, - - /// The number of milliseconds below which we consider a process - /// exit to be abnormal. This is used to show an error message - /// when the process exits too quickly. - abnormal_runtime_threshold_ms: u32, - - /// If true, do not immediately send a child exited message to the - /// surface to close the surface when the command exits. - wait_after_command: bool, - - pub fn deinit(self: *EventData, alloc: Allocator) void { - // Clear our write pools. We know we aren't ever going to do - // any more IO since we stop our data stream below so we can just - // drop this. - self.write_req_pool.deinit(alloc); - self.write_buf_pool.deinit(alloc); - - // Stop our data stream - self.data_stream.deinit(); - - // Stop our process watcher - self.process.deinit(); - - // Clear any StreamHandler state - self.terminal_stream.handler.deinit(); - self.terminal_stream.deinit(); - } - - /// This queues a render operation with the renderer thread. The render - /// isn't guaranteed to happen immediately but it will happen as soon as - /// practical. - inline fn queueRender(self: *EventData) !void { - try self.renderer_wakeup.notify(); - } -}; - -fn processExit( - ev_: ?*EventData, - _: *xev.Loop, - _: *xev.Completion, - r: xev.Process.WaitError!u32, -) xev.CallbackAction { - const exit_code = r catch unreachable; - - const ev = ev_.?; - ev.process_exited = true; - - // Determine how long the process was running for. - const runtime_ms: ?u64 = runtime: { - const process_end = std.time.Instant.now() catch break :runtime null; - const runtime_ns = process_end.since(ev.process_start); - const runtime_ms = runtime_ns / std.time.ns_per_ms; - break :runtime runtime_ms; - }; - log.debug( - "child process exited status={} runtime={}ms", - .{ exit_code, runtime_ms orelse 0 }, - ); - - // If our runtime was below some threshold then we assume that this - // was an abnormal exit and we show an error message. - if (runtime_ms) |runtime| runtime: { - // On macOS, our exit code detection doesn't work, possibly - // because of our `login` wrapper. More investigation required. - if (comptime !builtin.target.isDarwin()) { - // If our exit code is zero, then the command was successful - // and we don't ever consider it abnormal. - if (exit_code == 0) break :runtime; - } - - // Our runtime always has to be under the threshold to be - // considered abnormal. This is because a user can always - // manually do something like `exit 1` in their shell to - // force the exit code to be non-zero. We only want to detect - // abnormal exits that happen so quickly the user can't react. - if (runtime > ev.abnormal_runtime_threshold_ms) break :runtime; - log.warn("abnormal process exit detected, showing error message", .{}); - - // Notify our main writer thread which has access to more - // information so it can show a better error message. - _ = ev.writer_mailbox.push(.{ - .child_exited_abnormally = .{ - .exit_code = exit_code, - .runtime_ms = runtime, - }, - }, .{ .forever = {} }); - ev.writer_wakeup.notify() catch break :runtime; - - return .disarm; - } - - // If we're purposely waiting then we just return since the process - // exited flag is set to true. This allows the terminal window to remain - // open. - if (ev.wait_after_command) { - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. - terminal: { - ev.renderer_state.mutex.lock(); - defer ev.renderer_state.mutex.unlock(); - const t = ev.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - return .disarm; - } - - // Notify our surface we want to close - _ = ev.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - - return .disarm; -} - -fn ttyWrite( - ev_: ?*EventData, - _: *xev.Loop, - _: *xev.Completion, - _: xev.Stream, - _: xev.WriteBuffer, - r: xev.Stream.WriteError!usize, -) xev.CallbackAction { - const ev = ev_.?; - ev.write_req_pool.put(); - ev.write_buf_pool.put(); - - const d = r catch |err| { - log.err("write error: {}", .{err}); - return .disarm; - }; - _ = d; - //log.info("WROTE: {d}", .{d}); - - return .disarm; -} - -/// Subprocess manages the lifecycle of the shell subprocess. -const Subprocess = struct { - /// If we build with flatpak support then we have to keep track of - /// a potential execution on the host. - const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; - - arena: std.heap.ArenaAllocator, - cwd: ?[]const u8, - env: EnvMap, - args: [][]const u8, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - pty: ?Pty = null, - command: ?Command = null, - flatpak_command: ?FlatpakHostCommand = null, - linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, - - /// Initialize the subprocess. This will NOT start it, this only sets - /// up the internal state necessary to start it later. - pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { - // We have a lot of maybe-allocations that all share the same lifetime - // so use an arena so we don't end up in an accounting nightmare. - var arena = std.heap.ArenaAllocator.init(gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Set our env vars. For Flatpak builds running in Flatpak we don't - // inherit our environment because the login shell on the host side - // will get it. - var env = env: { - if (comptime build_config.flatpak) { - if (internal_os.isFlatpak()) { - break :env std.process.EnvMap.init(alloc); - } - } - - break :env try std.process.getEnvMap(alloc); - }; - errdefer env.deinit(); - - // If we have a resources dir then set our env var - if (opts.resources_dir) |dir| { - log.info("found Ghostty resources dir: {s}", .{dir}); - try env.put("GHOSTTY_RESOURCES_DIR", dir); - } - - // Set our TERM var. This is a bit complicated because we want to use - // the ghostty TERM value but we want to only do that if we have - // ghostty in the TERMINFO database. - // - // For now, we just look up a bundled dir but in the future we should - // also load the terminfo database and look for it. - if (opts.resources_dir) |base| { - try env.put("TERM", opts.config.term); - try env.put("COLORTERM", "truecolor"); - - // Assume that the resources directory is adjacent to the terminfo - // database - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ - std.fs.path.dirname(base) orelse unreachable, - }); - try env.put("TERMINFO", dir); - } else { - if (comptime builtin.target.isDarwin()) { - log.warn("ghostty terminfo not found, using xterm-256color", .{}); - log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); - log.warn("you're using a valid app bundle.", .{}); - } - - try env.put("TERM", "xterm-256color"); - try env.put("COLORTERM", "truecolor"); - } - - // Add our binary to the path if we can find it. - ghostty_path: { - var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { - log.warn("failed to get ghostty exe path err={}", .{err}); - break :ghostty_path; - }; - const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; - log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); - - // We always set this so that if the shell overwrites the path - // scripts still have a way to find the Ghostty binary when - // running in Ghostty. - try env.put("GHOSTTY_BIN_DIR", exe_dir); - - // Append if we have a path. We want to append so that ghostty is - // the last priority in the path. If we don't have a path set - // then we just set it to the directory of the binary. - if (env.get("PATH")) |path| { - // Verify that our path doesn't already contain this entry - var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); - while (it.next()) |entry| { - if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; - } - - try env.put( - "PATH", - try internal_os.appendEnv(alloc, path, exe_dir), - ); - } else { - try env.put("PATH", exe_dir); - } - } - - // Add the man pages from our application bundle to MANPATH. - if (comptime builtin.target.isDarwin()) { - if (opts.resources_dir) |resources_dir| man: { - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { - log.warn("error building manpath, man pages may not be available err={}", .{err}); - break :man; - }; - - if (env.get("MANPATH")) |manpath| { - // Append to the existing MANPATH. It's very unlikely that our bundle's - // resources directory already appears here so we don't spend the time - // searching for it. - try env.put( - "MANPATH", - try internal_os.appendEnv(alloc, manpath, dir), - ); - } else { - try env.put("MANPATH", dir); - } - } - } - - // Set environment variables used by some programs (such as neovim) to detect - // which terminal emulator and version they're running under. - try env.put("TERM_PROGRAM", "ghostty"); - try env.put("TERM_PROGRAM_VERSION", build_config.version_string); - - // When embedding in macOS and running via XCode, XCode injects - // a bunch of things that break our shell process. We remove those. - if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { - if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { - env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); - env.remove("__XPC_DYLD_LIBRARY_PATH"); - env.remove("DYLD_FRAMEWORK_PATH"); - env.remove("DYLD_INSERT_LIBRARIES"); - env.remove("DYLD_LIBRARY_PATH"); - env.remove("LD_LIBRARY_PATH"); - env.remove("SECURITYSESSIONID"); - env.remove("XPC_SERVICE_NAME"); - } - - // Remove this so that running `ghostty` within Ghostty works. - env.remove("GHOSTTY_MAC_APP"); - } - - // Don't leak these environment variables to child processes. - if (comptime build_config.app_runtime == .gtk) { - env.remove("GDK_DEBUG"); - env.remove("GSK_RENDERER"); - } - - // Setup our shell integration, if we can. - const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { - const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { - .windows => "cmd.exe", - else => "sh", - }; - - const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { - .none => break :shell .{ null, default_shell_command }, - .detect => null, - .bash => .bash, - .elvish => .elvish, - .fish => .fish, - .zsh => .zsh, - }; - - const dir = opts.resources_dir orelse break :shell .{ - null, - default_shell_command, - }; - - const integration = try shell_integration.setup( - alloc, - dir, - default_shell_command, - &env, - force, - opts.full_config.@"shell-integration-features", - ) orelse break :shell .{ null, default_shell_command }; - - break :shell .{ integration.shell, integration.command }; - }; - - if (integrated_shell) |shell| { - log.info( - "shell integration automatically injected shell={}", - .{shell}, - ); - } else if (opts.full_config.@"shell-integration" != .none) { - log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); - } - - // Build our args list - const args = args: { - const cap = 9; // the most we'll ever use - var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); - defer args.deinit(); - - // If we're on macOS, we have to use `login(1)` to get all of - // the proper environment variables set, a login shell, and proper - // hushlogin behavior. - if (comptime builtin.target.isDarwin()) darwin: { - const passwd = internal_os.passwd.get(alloc) catch |err| { - log.warn("failed to read passwd, not using a login shell err={}", .{err}); - break :darwin; - }; - - const username = passwd.name orelse { - log.warn("failed to get username, not using a login shell", .{}); - break :darwin; - }; - - const hush = if (passwd.home) |home| hush: { - var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { - log.warn( - "failed to open home dir, not checking for hushlogin err={}", - .{err}, - ); - break :hush false; - }; - defer dir.close(); - - break :hush if (dir.access(".hushlogin", .{})) true else |_| false; - } else false; - - const cmd = try std.fmt.allocPrint( - alloc, - "exec -l {s}", - .{shell_command}, - ); - - // The reason for executing login this way is unclear. This - // comment will attempt to explain but prepare for a truly - // unhinged reality. - // - // The first major issue is that on macOS, a lot of users - // put shell configurations in ~/.bash_profile instead of - // ~/.bashrc (or equivalent for another shell). This file is only - // loaded for a login shell so macOS users expect all their terminals - // to be login shells. No other platform behaves this way and its - // totally braindead but somehow the entire dev community on - // macOS has cargo culted their way to this reality so we have to - // do it... - // - // To get a login shell, you COULD just prepend argv0 with a `-` - // but that doesn't fully work because `getlogin()` C API will - // return the wrong value, SHELL won't be set, and various - // other login behaviors that macOS users expect. - // - // The proper way is to use `login(1)`. But login(1) forces - // the working directory to change to the home directory, - // which we may not want. If we specify "-l" then we can avoid - // this behavior but now the shell isn't a login shell. - // - // There is another issue: `login(1)` only checks for ".hushlogin" - // in the working directory. This means that if we specify "-l" - // then we won't get hushlogin honored if its in the home - // directory (which is standard). To get around this, we - // check for hushlogin ourselves and if present specify the - // "-q" flag to login(1). - // - // So to get all the behaviors we want, we specify "-l" but - // execute "bash" (which is built-in to macOS). We then use - // the bash builtin "exec" to replace the process with a login - // shell ("-l" on exec) with the command we really want. - // - // We use "bash" instead of other shells that ship with macOS - // because as of macOS Sonoma, we found with a microbenchmark - // that bash can `exec` into the desired command ~2x faster - // than zsh. - // - // To figure out a lot of this logic I read the login.c - // source code in the OSS distribution Apple provides for - // macOS. - // - // Awesome. - try args.append("/usr/bin/login"); - if (hush) try args.append("-q"); - try args.append("-flp"); - - // We execute bash with "--noprofile --norc" so that it doesn't - // load startup files so that (1) our shell integration doesn't - // break and (2) user configuration doesn't mess this process - // up. - try args.append(username); - try args.append("/bin/bash"); - try args.append("--noprofile"); - try args.append("--norc"); - try args.append("-c"); - try args.append(cmd); - break :args try args.toOwnedSlice(); - } - - if (comptime builtin.os.tag == .windows) { - // We run our shell wrapped in `cmd.exe` so that we don't have - // to parse the command line ourselves if it has arguments. - - // Note we don't free any of the memory below since it is - // allocated in the arena. - const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); - const cmd = try std.fs.path.join(alloc, &[_][]const u8{ - windir, - "System32", - "cmd.exe", - }); - - try args.append(cmd); - try args.append("/C"); - } else { - // We run our shell wrapped in `/bin/sh` so that we don't have - // to parse the command line ourselves if it has arguments. - // Additionally, some environments (NixOS, I found) use /bin/sh - // to setup some environment variables that are important to - // have set. - try args.append("/bin/sh"); - if (internal_os.isFlatpak()) try args.append("-l"); - try args.append("-c"); - } - - try args.append(shell_command); - break :args try args.toOwnedSlice(); - }; - - // We have to copy the cwd because there is no guarantee that - // pointers in full_config remain valid. - const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| - try alloc.dupe(u8, cwd) - else - null; - - // If we have a cgroup, then we copy that into our arena so the - // memory remains valid when we start. - const linux_cgroup: Command.LinuxCgroup = cgroup: { - const default = Command.linux_cgroup_default; - if (comptime builtin.os.tag != .linux) break :cgroup default; - const path = opts.linux_cgroup orelse break :cgroup default; - break :cgroup try alloc.dupe(u8, path); - }; - - // Our screen size should be our padded size - const padded_size = opts.screen_size.subPadding(opts.padding); - - return .{ - .arena = arena, - .env = env, - .cwd = cwd, - .args = args, - .grid_size = opts.grid_size, - .screen_size = padded_size, - .linux_cgroup = linux_cgroup, - }; - } - - /// Clean up the subprocess. This will stop the subprocess if it is started. - pub fn deinit(self: *Subprocess) void { - self.stop(); - if (self.pty) |*pty| pty.deinit(); - self.arena.deinit(); - self.* = undefined; - } - - /// Start the subprocess. If the subprocess is already started this - /// will crash. - pub fn start(self: *Subprocess, alloc: Allocator) !struct { - read: Pty.Fd, - write: Pty.Fd, - } { - assert(self.pty == null and self.command == null); - - // Create our pty - var pty = try Pty.open(.{ - .ws_row = @intCast(self.grid_size.rows), - .ws_col = @intCast(self.grid_size.columns), - .ws_xpixel = @intCast(self.screen_size.width), - .ws_ypixel = @intCast(self.screen_size.height), - }); - self.pty = pty; - errdefer { - pty.deinit(); - self.pty = null; - } - - log.debug("starting command command={s}", .{self.args}); - - // In flatpak, we use the HostCommand to execute our shell. - if (internal_os.isFlatpak()) flatpak: { - if (comptime !build_config.flatpak) { - log.warn("flatpak detected, but flatpak support not built-in", .{}); - break :flatpak; - } - - // Flatpak command must have a stable pointer. - self.flatpak_command = .{ - .argv = self.args, - .env = &self.env, - .stdin = pty.slave, - .stdout = pty.slave, - .stderr = pty.slave, - }; - var cmd = &self.flatpak_command.?; - const pid = try cmd.spawn(alloc); - errdefer killCommandFlatpak(cmd); - - log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ - self.args[0], - pid, - }); - - // Once started, we can close the pty child side. We do this after - // wait right now but that is fine too. This lets us read the - // parent and detect EOF. - _ = posix.close(pty.slave); - - return .{ - .read = pty.master, - .write = pty.master, - }; - } - - // If we can't access the cwd, then don't set any cwd and inherit. - // This is important because our cwd can be set by the shell (OSC 7) - // and we don't want to break new windows. - const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { - if (std.fs.accessAbsolute(proposed, .{})) { - break :cwd proposed; - } else |err| { - log.warn("cannot access cwd, ignoring: {}", .{err}); - break :cwd null; - } - } else null; - - // Build our subcommand - var cmd: Command = .{ - .path = self.args[0], - .args = self.args, - .env = &self.env, - .cwd = cwd, - .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, - .pre_exec = if (builtin.os.tag == .windows) null else (struct { - fn callback(cmd: *Command) void { - const sp = cmd.getData(Subprocess) orelse unreachable; - sp.childPreExec() catch |err| log.err( - "error initializing child: {}", - .{err}, - ); - } - }).callback, - .data = self, - .linux_cgroup = self.linux_cgroup, - }; - try cmd.start(alloc); - errdefer killCommand(&cmd) catch |err| { - log.warn("error killing command during cleanup err={}", .{err}); - }; - log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); - if (comptime builtin.os.tag == .linux) { - log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); - } - - self.command = cmd; - return switch (builtin.os.tag) { - .windows => .{ - .read = pty.out_pipe, - .write = pty.in_pipe, - }, - - else => .{ - .read = pty.master, - .write = pty.master, - }, - }; - } - - /// This should be called after fork but before exec in the child process. - /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before - /// exec is called; it does NOT run in the main Ghostty process. - fn childPreExec(self: *Subprocess) !void { - // Setup our pty - try self.pty.?.childPreExec(); - } - - /// Called to notify that we exited externally so we can unset our - /// running state. - pub fn externalExit(self: *Subprocess) void { - self.command = null; - } - - /// Stop the subprocess. This is safe to call anytime. This will wait - /// for the subprocess to register that it has been signalled, but not - /// for it to terminate, so it will not block. - /// This does not close the pty. - pub fn stop(self: *Subprocess) void { - // Kill our command - if (self.command) |*cmd| { - // Note: this will also wait for the command to exit, so - // DO NOT call cmd.wait - killCommand(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - self.command = null; - } - - // Kill our Flatpak command - if (FlatpakHostCommand != void) { - if (self.flatpak_command) |*cmd| { - killCommandFlatpak(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - _ = cmd.wait() catch |err| - log.err("error waiting for command to exit: {}", .{err}); - self.flatpak_command = null; - } - } - } - - /// Resize the pty subprocess. This is safe to call anytime. - pub fn resize( - self: *Subprocess, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - ) !void { - self.grid_size = grid_size; - self.screen_size = screen_size; - - if (self.pty) |*pty| { - try pty.setSize(.{ - .ws_row = @intCast(grid_size.rows), - .ws_col = @intCast(grid_size.columns), - .ws_xpixel = @intCast(screen_size.width), - .ws_ypixel = @intCast(screen_size.height), - }); - } - } - - /// Kill the underlying subprocess. This sends a SIGHUP to the child - /// process. This also waits for the command to exit and will return the - /// exit code. - fn killCommand(command: *Command) !void { - if (command.pid) |pid| { - switch (builtin.os.tag) { - .windows => { - if (windows.kernel32.TerminateProcess(pid, 0) == 0) { - return windows.unexpectedError(windows.kernel32.GetLastError()); - } - - _ = try command.wait(false); - }, - - else => if (getpgid(pid)) |pgid| { - // It is possible to send a killpg between the time that - // our child process calls setsid but before or simultaneous - // to calling execve. In this case, the direct child dies - // but grandchildren survive. To work around this, we loop - // and repeatedly kill the process group until all - // descendents are well and truly dead. We will not rest - // until the entire family tree is obliterated. - while (true) { - if (c.killpg(pgid, c.SIGHUP) < 0) { - log.warn("error killing process group pgid={}", .{pgid}); - return error.KillFailed; - } - - // See Command.zig wait for why we specify WNOHANG. - // The gist is that it lets us detect when children - // are still alive without blocking so that we can - // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - if (res.pid != 0) break; - std.time.sleep(10 * std.time.ns_per_ms); - } - }, - } - } - } - - fn getpgid(pid: c.pid_t) ?c.pid_t { - // Get our process group ID. Before the child pid calls setsid - // the pgid will be ours because we forked it. Its possible that - // we may be calling this before setsid if we are killing a surface - // VERY quickly after starting it. - const my_pgid = c.getpgid(0); - - // We loop while pgid == my_pgid. The expectation if we have a valid - // pid is that setsid will eventually be called because it is the - // FIRST thing the child process does and as far as I can tell, - // setsid cannot fail. I'm sure that's not true, but I'd rather - // have a bug reported than defensively program against it now. - while (true) { - const pgid = c.getpgid(pid); - if (pgid == my_pgid) { - log.warn("pgid is our own, retrying", .{}); - std.time.sleep(10 * std.time.ns_per_ms); - continue; - } - - // Don't know why it would be zero but its not a valid pid - if (pgid == 0) return null; - - // If the pid doesn't exist then... we're done! - if (pgid == c.ESRCH) return null; - - // If we have an error we're done. - if (pgid < 0) { - log.warn("error getting pgid for kill", .{}); - return null; - } - - return pgid; - } - } - - /// Kill the underlying process started via Flatpak host command. - /// This sends a signal via the Flatpak API. - fn killCommandFlatpak(command: *FlatpakHostCommand) !void { - try command.signal(c.SIGHUP, true); - } -}; - -/// The read thread sits in a loop doing the following pseudo code: -/// -/// while (true) { blocking_read(); exit_if_eof(); process(); } -/// -/// Almost all terminal-modifying activity is from the pty read, so -/// putting this on a dedicated thread keeps performance very predictable -/// while also almost optimal. "Locking is fast, lock contention is slow." -/// and since we rarely have contention, this is fast. -/// -/// This is also empirically fast compared to putting the read into -/// an async mechanism like io_uring/epoll because the reads are generally -/// small. -/// -/// We use a basic poll syscall here because we are only monitoring two -/// fds and this is still much faster and lower overhead than any async -/// mechanism. -const ReadThread = struct { - fn threadMainPosix(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { - // Always close our end of the pipe when we exit. - defer posix.close(quit); - - // First thing, we want to set the fd to non-blocking. We do this - // so that we can try to read from the fd in a tight loop and only - // check the quit fd occasionally. - if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { - _ = posix.fcntl( - fd, - posix.F.SETFL, - flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), - ) catch |err| { - log.warn("read thread failed to set flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - }; - } else |err| { - log.warn("read thread failed to get flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - } - - // Build up the list of fds we're going to poll. We are looking - // for data on the pty and our quit notification. - var pollfds: [2]posix.pollfd = .{ - .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, - .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, - }; - - var buf: [1024]u8 = undefined; - while (true) { - // We try to read from the file descriptor as long as possible - // to maximize performance. We only check the quit fd if the - // main fd blocks. This optimizes for the realistic scenario that - // the data will eventually stop while we're trying to quit. This - // is always true because we kill the process. - while (true) { - const n = posix.read(fd, &buf) catch |err| { - switch (err) { - // This means our pty is closed. We're probably - // gracefully shutting down. - error.NotOpenForReading, - error.InputOutput, - => { - log.info("io reader exiting", .{}); - return; - }, - - // No more data, fall back to poll and check for - // exit conditions. - error.WouldBlock => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - }; - - // This happens on macOS instead of WouldBlock when the - // child process dies. To be safe, we just break the loop - // and let our poll happen. - if (n == 0) break; - - // log.info("DATA: {d}", .{n}); - @call(.always_inline, process, .{ ev, buf[0..n] }); - } - - // Wait for data. - _ = posix.poll(&pollfds, -1) catch |err| { - log.warn("poll failed on read thread, exiting early err={}", .{err}); - return; - }; - - // If our quit fd is set, we're done. - if (pollfds[1].revents & posix.POLL.IN != 0) { - log.info("read thread got quit signal", .{}); - return; - } - } - } - - fn threadMainWindows(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { - // Always close our end of the pipe when we exit. - defer posix.close(quit); - - var buf: [1024]u8 = undefined; - while (true) { - while (true) { - var n: windows.DWORD = 0; - if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { - const err = windows.kernel32.GetLastError(); - switch (err) { - // Check for a quit signal - .OPERATION_ABORTED => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - } - - @call(.always_inline, process, .{ ev, buf[0..n] }); - } - - var quit_bytes: windows.DWORD = 0; - if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { - const err = windows.kernel32.GetLastError(); - log.err("quit pipe reader error err={}", .{err}); - unreachable; - } - - if (quit_bytes > 0) { - log.info("read thread got quit signal", .{}); - return; - } - } - } - - fn process( - ev: *EventData, - buf: []const u8, - ) void { - // log.info("DATA: {d}", .{n}); - // log.info("DATA: {any}", .{buf[0..@intCast(usize, n)]}); - - // Whenever a character is typed, we ensure the cursor is in the - // non-blink state so it is rendered if visible. If we're under - // HEAVY read load, we don't want to send a ton of these so we - // use a timer under the covers - const now = ev.loop.now(); - if (now - ev.last_cursor_reset > 500) { - ev.last_cursor_reset = now; - _ = ev.renderer_mailbox.push(.{ - .reset_cursor_blink = {}, - }, .{ .forever = {} }); - } - - // We are modifying terminal state from here on out - ev.renderer_state.mutex.lock(); - defer ev.renderer_state.mutex.unlock(); - - // Schedule a render - ev.queueRender() catch unreachable; - - // If we have an inspector, we enter SLOW MODE because we need to - // process a byte at a time alternating between the inspector handler - // and the termio handler. This is very slow compared to our optimizations - // below but at least users only pay for it if they're using the inspector. - if (ev.renderer_state.inspector) |insp| { - for (buf, 0..) |byte, i| { - insp.recordPtyRead(buf[i .. i + 1]) catch |err| { - log.err("error recording pty read in inspector err={}", .{err}); - }; - - ev.terminal_stream.next(byte) catch |err| - log.err("error processing terminal data: {}", .{err}); - } - } else { - ev.terminal_stream.nextSlice(buf) catch |err| - log.err("error processing terminal data: {}", .{err}); - } - - // If our stream handling caused messages to be sent to the writer - // thread, then we need to wake it up so that it processes them. - if (ev.terminal_stream.handler.writer_messaged) { - ev.terminal_stream.handler.writer_messaged = false; - ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer thread err={}", .{err}); - }; - } - } -}; - -/// This is used as the handler for the terminal.Stream type. This is -/// stateful and is expected to live for the entire lifetime of the terminal. -/// It is NOT VALID to stop a stream handler, create a new one, and use that -/// unless all of the member fields are copied. -const StreamHandler = struct { - ev: *EventData, - alloc: Allocator, - grid_size: *renderer.GridSize, - terminal: *terminal.Terminal, - - /// The APC command handler maintains the APC state. APC is like - /// CSI or OSC, but it is a private escape sequence that is used - /// to send commands to the terminal emulator. This is used by - /// the kitty graphics protocol. - apc: terminal.apc.Handler = .{}, - - /// The DCS handler maintains DCS state. DCS is like CSI or OSC, - /// but requires more stateful parsing. This is used by functionality - /// such as XTGETTCAP. - dcs: terminal.dcs.Handler = .{}, - - /// This is set to true when a message was written to the writer - /// mailbox. This can be used by callers to determine if they need - /// to wake up the writer. - writer_messaged: bool = false, - - /// The default cursor state. This is used with CSI q. This is - /// set to true when we're currently in the default cursor state. - default_cursor: bool = true, - default_cursor_style: terminal.CursorStyle, - default_cursor_blink: ?bool, - default_cursor_color: ?terminal.color.RGB, - - /// Actual cursor color. This can be changed with OSC 12. - cursor_color: ?terminal.color.RGB, - - /// The default foreground and background color are those set by the user's - /// config file. These can be overridden by terminal applications using OSC - /// 10 and OSC 11, respectively. - default_foreground_color: terminal.color.RGB, - default_background_color: terminal.color.RGB, - - /// The actual foreground and background color. Normally this will be the - /// same as the default foreground and background color, unless changed by a - /// terminal application. - foreground_color: terminal.color.RGB, - background_color: terminal.color.RGB, - - /// The response to use for ENQ requests. The memory is owned by - /// whoever owns StreamHandler. - enquiry_response: []const u8, - - osc_color_report_format: configpkg.Config.OSCColorReportFormat, - - pub fn init( - alloc: Allocator, - ev: *EventData, - grid_size: *renderer.GridSize, - t: *terminal.Terminal, - config: *const DerivedConfig, - ) StreamHandler { - const default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - return .{ - .alloc = alloc, - .ev = ev, - .grid_size = grid_size, - .terminal = t, - .osc_color_report_format = config.osc_color_report_format, - .enquiry_response = config.enquiry_response, - .default_foreground_color = config.foreground.toTerminalRGB(), - .default_background_color = config.background.toTerminalRGB(), - .default_cursor_style = config.cursor_style, - .default_cursor_blink = config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = default_cursor_color, - .foreground_color = config.foreground.toTerminalRGB(), - .background_color = config.background.toTerminalRGB(), - }; - } - - pub fn deinit(self: *StreamHandler) void { - self.apc.deinit(); - self.dcs.deinit(); - } - - /// Change the configuration for this handler. - pub fn changeConfig(self: *StreamHandler, config: *DerivedConfig) void { - self.osc_color_report_format = config.osc_color_report_format; - self.enquiry_response = config.enquiry_response; - self.default_foreground_color = config.foreground.toTerminalRGB(); - self.default_background_color = config.background.toTerminalRGB(); - self.default_cursor_style = config.cursor_style; - self.default_cursor_blink = config.cursor_blink; - self.default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - // If our cursor is the default, then we update it immediately. - if (self.default_cursor) self.setCursorStyle(.default) catch |err| { - log.warn("failed to set default cursor style: {}", .{err}); - }; - } - - inline fn queueRender(self: *StreamHandler) !void { - try self.ev.queueRender(); - } - - inline fn surfaceMessageWriter( - self: *StreamHandler, - msg: apprt.surface.Message, - ) void { - // See messageWriter which has similar logic and explains why - // we may have to do this. - if (self.ev.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.surface_mailbox.push(msg, .{ .forever = {} }); - } - } - - inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { - // Try to write to the mailbox with an instant timeout. This is the - // fast path because we can queue without a lock. - if (self.ev.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { - // If we enter this conditional, the mailbox is full. We wake up - // the writer thread so that it can process messages to clear up - // space. However, the writer thread may require the renderer - // lock so we need to unlock. - self.ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer, data will be dropped err={}", .{err}); - return; - }; - - // Unlock the renderer state so the writer thread can acquire it. - // Then try to queue our message before continuing. This is a very - // slow path because we are having a lot of contention for data. - // But this only gets triggered in certain pathological cases. - // - // Note that writes themselves don't require a lock, but there - // are other messages in the writer mailbox (resize, focus) that - // could acquire the lock. This is why we have to release our lock - // here. - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.writer_mailbox.push(msg, .{ .forever = {} }); - } - - // Normally, we just flag this true to wake up the writer thread - // once per batch of data. - self.writer_messaged = true; - } - - pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - var cmd = self.dcs.hook(self.alloc, dcs) orelse return; - defer cmd.deinit(); - try self.dcsCommand(&cmd); - } - - pub fn dcsPut(self: *StreamHandler, byte: u8) !void { - var cmd = self.dcs.put(byte) orelse return; - defer cmd.deinit(); - try self.dcsCommand(&cmd); - } - - pub fn dcsUnhook(self: *StreamHandler) !void { - var cmd = self.dcs.unhook() orelse return; - defer cmd.deinit(); - try self.dcsCommand(&cmd); - } - - fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { - // log.warn("DCS command: {}", .{cmd}); - switch (cmd.*) { - .tmux => |tmux| { - // TODO: process it - log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); - }, - - .xtgettcap => |*gettcap| { - const map = comptime terminfo.ghostty.xtgettcapMap(); - while (gettcap.next()) |key| { - const response = map.get(key) orelse continue; - self.messageWriter(.{ .write_stable = response }); - } - }, - - .decrqss => |decrqss| { - var response: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&response); - const writer = stream.writer(); - - // Offset the stream position to just past the response prefix. - // We will write the "payload" (if any) below. If no payload is - // written then we send an invalid DECRPSS response. - const prefix_fmt = "\x1bP{d}$r"; - const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; - stream.pos = prefix_len; - - switch (decrqss) { - // Invalid or unhandled request - .none => {}, - - .sgr => { - const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); - - // printAttributes wrote into our buffer, so adjust the stream - // position - stream.pos += buf.len; - - try writer.writeByte('m'); - }, - - .decscusr => { - const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { - .block => if (blink) 1 else 2, - .underline => if (blink) 3 else 4, - .bar => if (blink) 5 else 6, - }; - try writer.print("{d} q", .{style}); - }, - - .decstbm => { - try writer.print("{d};{d}r", .{ - self.terminal.scrolling_region.top + 1, - self.terminal.scrolling_region.bottom + 1, - }); - }, - - .decslrm => { - // We only send a valid response when left and right - // margin mode (DECLRMM) is enabled. - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try writer.print("{d};{d}s", .{ - self.terminal.scrolling_region.left + 1, - self.terminal.scrolling_region.right + 1, - }); - } - }, - } - - // Our response is valid if we have a response payload - const valid = stream.pos > prefix_len; - - // Write the terminator - try writer.writeAll("\x1b\\"); - - // Write the response prefix into the buffer - _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); - const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); - self.messageWriter(msg); - }, - } - } - - pub fn apcStart(self: *StreamHandler) !void { - self.apc.start(); - } - - pub fn apcPut(self: *StreamHandler, byte: u8) !void { - self.apc.feed(self.alloc, byte); - } - - pub fn apcEnd(self: *StreamHandler) !void { - var cmd = self.apc.end() orelse return; - defer cmd.deinit(self.alloc); - - // log.warn("APC command: {}", .{cmd}); - switch (cmd) { - .kitty => |*kitty_cmd| { - if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { - var buf: [1024]u8 = undefined; - var buf_stream = std.io.fixedBufferStream(&buf); - try resp.encode(buf_stream.writer()); - const final = buf_stream.getWritten(); - if (final.len > 2) { - // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); - self.messageWriter(try termio.Message.writeReq(self.alloc, final)); - } - } - }, - } - } - - pub fn print(self: *StreamHandler, ch: u21) !void { - try self.terminal.print(ch); - } - - pub fn printRepeat(self: *StreamHandler, count: usize) !void { - try self.terminal.printRepeat(count); - } - - pub fn bell(self: StreamHandler) !void { - _ = self; - log.info("BELL", .{}); - } - - pub fn backspace(self: *StreamHandler) !void { - self.terminal.backspace(); - } - - pub fn horizontalTab(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn linefeed(self: *StreamHandler) !void { - // Small optimization: call index instead of linefeed because they're - // identical and this avoids one layer of function call overhead. - try self.terminal.index(); - } - - pub fn carriageReturn(self: *StreamHandler) !void { - self.terminal.carriageReturn(); - } - - pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorLeft(amount); - } - - pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorRight(amount); - } - - pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorDown(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorUp(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorCol(self: *StreamHandler, col: u16) !void { - self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); - } - - pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| offset, - ); - } - - pub fn setCursorRow(self: *StreamHandler, row: u16) !void { - self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); - } - - pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| offset, - self.terminal.screen.cursor.x + 1, - ); - } - - pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { - self.terminal.setCursorPos(row, col); - } - - pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { - if (mode == .complete) { - // Whenever we erase the full display, scroll to bottom. - try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - self.terminal.eraseDisplay(mode, protected); - } - - pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { - self.terminal.eraseLine(mode, protected); - } - - pub fn deleteChars(self: *StreamHandler, count: usize) !void { - self.terminal.deleteChars(count); - } - - pub fn eraseChars(self: *StreamHandler, count: usize) !void { - self.terminal.eraseChars(count); - } - - pub fn insertLines(self: *StreamHandler, count: usize) !void { - self.terminal.insertLines(count); - } - - pub fn insertBlanks(self: *StreamHandler, count: usize) !void { - self.terminal.insertBlanks(count); - } - - pub fn deleteLines(self: *StreamHandler, count: usize) !void { - self.terminal.deleteLines(count); - } - - pub fn reverseIndex(self: *StreamHandler) !void { - self.terminal.reverseIndex(); - } - - pub fn index(self: *StreamHandler) !void { - try self.terminal.index(); - } - - pub fn nextLine(self: *StreamHandler) !void { - try self.terminal.index(); - self.terminal.carriageReturn(); - } - - pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { - self.terminal.setTopAndBottomMargin(top, bot); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try self.setLeftAndRightMargin(0, 0); - } else { - try self.saveCursor(); - } - } - - pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { - self.terminal.setLeftAndRightMargin(left, right); - } - - pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { - self.terminal.flags.modify_other_keys_2 = false; - switch (format) { - .other_keys => |v| switch (v) { - .numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - }, - else => {}, - } - } - - pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - // Get the mode value and respond. - const code: u8 = code: { - const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; - if (self.terminal.modes.get(mode)) break :code 1; - break :code 2; - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B[{s}{};{}$y", - .{ - if (ansi) "" else "?", - mode_raw, - code, - }, - ); - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { - // log.debug("save mode={}", .{mode}); - self.terminal.modes.save(mode); - } - - pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { - // For restore mode we have to restore but if we set it, we - // always have to call setMode because setting some modes have - // side effects and we want to make sure we process those. - const v = self.terminal.modes.restore(mode); - // log.debug("restore mode={} v={}", .{ mode, v }); - try self.setMode(mode, v); - } - - pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { - // Note: this function doesn't need to grab the render state or - // terminal locks because it is only called from process() which - // grabs the lock. - - // If we are setting cursor blinking, we ignore it if we have - // a default cursor blink setting set. This is a really weird - // behavior so this comment will go deep into trying to explain it. - // - // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) - // and DEC mode 12. DECSCUSR is the modern approach and has a - // way to revert to the "default" (as defined by the terminal) - // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls - // blinking and is either on or off and has no way to set a - // default. DEC mode 12 is also the more antiquated approach. - // - // The problem is that if the user specifies a desired default - // cursor blink with `cursor-style-blink`, the moment a running - // program uses DEC mode 12, the cursor blink can never be reset - // to the default without an explicit DECSCUSR. But if a program - // is using mode 12, it is by definition not using DECSCUSR. - // This makes for somewhat annoying interactions where a poorly - // (or legacy) behaved program will stop blinking, and it simply - // never restarts. - // - // To get around this, we have a special case where if the user - // specifies some explicit default cursor blink desire, we ignore - // DEC mode 12. We allow DECSCUSR to still set the cursor blink - // because programs using DECSCUSR usually are well behaved and - // reset the cursor blink to the default when they exit. - // - // To be extra safe, users can also add a manual `CSI 0 q` to - // their shell config when they render prompts to ensure the - // cursor is exactly as they request. - if (mode == .cursor_blinking and - self.default_cursor_blink != null) - { - return; - } - - // We first always set the raw mode on our mode state. - self.terminal.modes.set(mode, enabled); - - // And then some modes require additional processing. - switch (mode) { - // Just noting here that autorepeat has no effect on - // the terminal. xterm ignores this mode and so do we. - // We know about just so that we don't log that it is - // an unknown mode. - .autorepeat => {}, - - // Schedule a render since we changed colors - .reverse_colors => { - self.terminal.flags.dirty.reverse_colors = true; - try self.queueRender(); - }, - - // Origin resets cursor pos. This is called whether or not - // we're enabling or disabling origin mode and whether or - // not the value changed. - .origin => self.terminal.setCursorPos(1, 1), - - .enable_left_and_right_margin => if (!enabled) { - // When we disable left/right margin mode we need to - // reset the left/right margins. - self.terminal.scrolling_region.left = 0; - self.terminal.scrolling_region.right = self.terminal.cols - 1; - }, - - .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - // Force resize back to the window size - .enable_mode_3 => self.terminal.resize( - self.alloc, - self.grid_size.columns, - self.grid_size.rows, - ) catch |err| { - log.err("error updating terminal size: {}", .{err}); - }, - - .@"132_column" => try self.terminal.deccolm( - self.alloc, - if (enabled) .@"132_cols" else .@"80_cols", - ), - - // We need to start a timer to prevent the emulator being hung - // forever. - .synchronized_output => { - if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); - try self.queueRender(); - }, - - .linefeed => { - self.messageWriter(.{ .linefeed_mode = enabled }); - }, - - .mouse_event_x10 => { - if (enabled) { - self.terminal.flags.mouse_event = .x10; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_normal => { - if (enabled) { - self.terminal.flags.mouse_event = .normal; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_button => { - if (enabled) { - self.terminal.flags.mouse_event = .button; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_any => { - if (enabled) { - self.terminal.flags.mouse_event = .any; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - - .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, - .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, - .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, - .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, - - else => {}, - } - } - - pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { - self.terminal.flags.mouse_shift_capture = if (v) .true else .false; - } - - pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { - switch (attr) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - - else => self.terminal.setAttribute(attr) catch |err| - log.warn("error setting attribute {}: {}", .{ attr, err }), - } - } - - pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { - try self.terminal.screen.startHyperlink(uri, id); - } - - pub fn endHyperlink(self: *StreamHandler) !void { - self.terminal.screen.endHyperlink(); - } - - pub fn deviceAttributes( - self: *StreamHandler, - req: terminal.DeviceAttributeReq, - params: []const u16, - ) !void { - _ = params; - - // For the below, we quack as a VT220. We don't quack as - // a 420 because we don't support DCS sequences. - switch (req) { - .primary => self.messageWriter(.{ - .write_stable = "\x1B[?62;22c", - }), - - .secondary => self.messageWriter(.{ - .write_stable = "\x1B[>1;10;0c", - }), - - else => log.warn("unimplemented device attributes req: {}", .{req}), - } - } - - pub fn deviceStatusReport( - self: *StreamHandler, - req: terminal.device_status.Request, - ) !void { - switch (req) { - .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), - - .cursor_position => { - const pos: struct { - x: usize, - y: usize, - } = if (self.terminal.modes.get(.origin)) .{ - .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, - .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, - } else .{ - .x = self.terminal.screen.cursor.x, - .y = self.terminal.screen.cursor.y, - }; - - // Response always is at least 4 chars, so this leaves the - // remainder for the row/column as base-10 numbers. This - // will support a very large terminal. - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ - pos.y + 1, - pos.x + 1, - }); - msg.write_small.len = @intCast(resp.len); - - self.messageWriter(msg); - }, - - .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), - } - } - - pub fn setCursorStyle( - self: *StreamHandler, - style: terminal.CursorStyleReq, - ) !void { - // Assume we're setting to a non-default. - self.default_cursor = false; - - switch (style) { - .default => { - self.default_cursor = true; - self.terminal.screen.cursor.cursor_style = self.default_cursor_style; - self.terminal.modes.set( - .cursor_blinking, - self.default_cursor_blink orelse true, - ); - }, - - .blinking_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, false); - }, - - else => log.warn("unimplemented cursor style: {}", .{style}), - } - } - - pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { - self.terminal.setProtectedMode(mode); - } - - pub fn decaln(self: *StreamHandler) !void { - try self.terminal.decaln(); - } - - pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { - self.terminal.tabClear(cmd); - } - - pub fn tabSet(self: *StreamHandler) !void { - self.terminal.tabSet(); - } - - pub fn tabReset(self: *StreamHandler) !void { - self.terminal.tabReset(); - } - - pub fn saveCursor(self: *StreamHandler) !void { - self.terminal.saveCursor(); - } - - pub fn restoreCursor(self: *StreamHandler) !void { - try self.terminal.restoreCursor(); - } - - pub fn enquiry(self: *StreamHandler) !void { - log.debug("sending enquiry response={s}", .{self.enquiry_response}); - self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); - } - - pub fn scrollDown(self: *StreamHandler, count: usize) !void { - self.terminal.scrollDown(count); - } - - pub fn scrollUp(self: *StreamHandler, count: usize) !void { - self.terminal.scrollUp(count); - } - - pub fn setActiveStatusDisplay( - self: *StreamHandler, - req: terminal.StatusDisplay, - ) !void { - self.terminal.status_display = req; - } - - pub fn configureCharset( - self: *StreamHandler, - slot: terminal.CharsetSlot, - set: terminal.Charset, - ) !void { - self.terminal.configureCharset(slot, set); - } - - pub fn invokeCharset( - self: *StreamHandler, - active: terminal.CharsetActiveSlot, - slot: terminal.CharsetSlot, - single: bool, - ) !void { - self.terminal.invokeCharset(active, slot, single); - } - - pub fn fullReset( - self: *StreamHandler, - ) !void { - self.terminal.fullReset(); - try self.setMouseShape(.text); - } - - pub fn queryKittyKeyboard(self: *StreamHandler) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("querying kitty keyboard mode", .{}); - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ - self.terminal.screen.kitty_keyboard.current().int(), - }); - - self.messageWriter(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, + self.screen_size = screen_size; + + if (self.pty) |*pty| { + try pty.setSize(.{ + .ws_row = @intCast(grid_size.rows), + .ws_col = @intCast(grid_size.columns), + .ws_xpixel = @intCast(screen_size.width), + .ws_ypixel = @intCast(screen_size.height), }); } +} - pub fn pushKittyKeyboard( - self: *StreamHandler, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; +/// Kill the underlying subprocess. This sends a SIGHUP to the child +/// process. This also waits for the command to exit and will return the +/// exit code. +fn killCommand(command: *Command) !void { + if (command.pid) |pid| { + switch (builtin.os.tag) { + .windows => { + if (windows.kernel32.TerminateProcess(pid, 0) == 0) { + return windows.unexpectedError(windows.kernel32.GetLastError()); + } - log.debug("pushing kitty keyboard mode: {}", .{flags}); - self.terminal.screen.kitty_keyboard.push(flags); - } - - pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("popping kitty keyboard mode n={}", .{n}); - self.terminal.screen.kitty_keyboard.pop(@intCast(n)); - } - - pub fn setKittyKeyboard( - self: *StreamHandler, - mode: terminal.kitty.KeySetMode, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); - self.terminal.screen.kitty_keyboard.set(mode, flags); - } - - pub fn reportXtversion( - self: *StreamHandler, - ) !void { - log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); - var buf: [288]u8 = undefined; - const resp = try std.fmt.bufPrint( - &buf, - "\x1BP>|{s} {s}\x1B\\", - .{ - "ghostty", - build_config.version_string, - }, - ); - const msg = try termio.Message.writeReq(self.alloc, resp); - self.messageWriter(msg); - } - - //------------------------------------------------------------------------- - // OSC - - pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { - var buf: [256]u8 = undefined; - if (title.len >= buf.len) { - log.warn("change title requested larger than our buffer size, ignoring", .{}); - return; - } - - @memcpy(buf[0..title.len], title); - buf[title.len] = 0; - - // Mark that we've seen a title - self.ev.seen_title = true; - self.surfaceMessageWriter(.{ .set_title = buf }); - } - - pub fn setMouseShape( - self: *StreamHandler, - shape: terminal.MouseShape, - ) !void { - // Avoid changing the shape it it is already set to avoid excess - // cross-thread messaging. - if (self.terminal.mouse_shape == shape) return; - - self.terminal.mouse_shape = shape; - self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); - } - - pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { - // Note: we ignore the "kind" field and always use the standard clipboard. - // iTerm also appears to do this but other terminals seem to only allow - // certain. Let's investigate more. - - const clipboard_type: apprt.Clipboard = switch (kind) { - 'c' => .standard, - 's' => .selection, - 'p' => .primary, - else => .standard, - }; - - // Get clipboard contents - if (data.len == 1 and data[0] == '?') { - self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); - return; - } - - // Write clipboard contents - self.surfaceMessageWriter(.{ - .clipboard_write = .{ - .req = try apprt.surface.Message.WriteReq.init( - self.alloc, - data, - ), - .clipboard_type = clipboard_type, - }, - }); - } - - pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt); - self.terminal.flags.shell_redraws_prompt = redraw; - } - - pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt_continuation); - } - - pub fn promptEnd(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.input); - } - - pub fn endOfInput(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.command); - } - - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { - if (builtin.os.tag == .windows) { - log.warn("reportPwd unimplemented on windows", .{}); - return; - } - - const uri = std.Uri.parse(url) catch |e| { - log.warn("invalid url in OSC 7: {}", .{e}); - return; - }; - - if (!std.mem.eql(u8, "file", uri.scheme) and - !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) - { - log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); - return; - } - - // OSC 7 is a little sketchy because anyone can send any value from - // any host (such an SSH session). The best practice terminals follow - // is to valid the hostname to be local. - const host_valid = host_valid: { - const host_component = uri.host orelse break :host_valid false; - - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { - break :host_valid true; - } - - // Otherwise, it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const hostname = posix.gethostname(&buf) catch |err| { - log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); - break :host_valid false; - }; - - break :host_valid std.mem.eql(u8, host, hostname); - }; - if (!host_valid) { - log.warn("OSC 7 host must be local", .{}); - return; - } - - // We need to unescape the path. We first try to unescape onto - // the stack and fall back to heap allocation if we have to. - var pathBuf: [1024]u8 = undefined; - const path, const heap = path: { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const path = switch (uri.path) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // If the path doesn't have any escapes, we can use it directly. - if (std.mem.indexOfScalar(u8, path, '%') == null) - break :path .{ path, false }; - - // First try to stack-allocate - var fba = std.heap.FixedBufferAllocator.init(&pathBuf); - if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| - break :path .{ v, false } - else |_| {} - - // Fall back to heap - if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| - break :path .{ v, true } - else |_| {} - - // Fall back to using it directly... - log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); - break :path .{ path, false }; - }; - defer if (heap) self.alloc.free(path); - - log.debug("terminal pwd: {s}", .{path}); - try self.terminal.setPwd(path); - - // If we haven't seen a title, use our pwd as the title. - if (!self.ev.seen_title) { - try self.changeWindowTitle(path); - self.ev.seen_title = false; - } - } - - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - terminator: terminal.osc.Terminator, - ) !void { - if (self.osc_color_report_format == .none) return; - - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color, - .background => self.background_color, - .cursor => self.cursor_color orelse self.foreground_color, - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), + _ = try command.wait(false); }, - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); - - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } + else => if (getpgid(pid)) |pgid| { + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + while (true) { + if (c.killpg(pgid, c.SIGHUP) < 0) { + log.warn("error killing process group pgid={}", .{pgid}); + return error.KillFailed; } + + // See Command.zig wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + const res = posix.waitpid(pid, std.c.W.NOHANG); + if (res.pid != 0) break; + std.time.sleep(10 * std.time.ns_per_ms); } }, - .foreground => { - self.foreground_color = self.default_foreground_color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = self.default_background_color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = self.default_cursor_color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); - }, } } +} - pub fn showDesktopNotification( - self: *StreamHandler, - title: []const u8, - body: []const u8, - ) !void { - var message = apprt.surface.Message{ .desktop_notification = undefined }; +fn getpgid(pid: c.pid_t) ?c.pid_t { + // Get our process group ID. Before the child pid calls setsid + // the pgid will be ours because we forked it. Its possible that + // we may be calling this before setsid if we are killing a surface + // VERY quickly after starting it. + const my_pgid = c.getpgid(0); - const title_len = @min(title.len, message.desktop_notification.title.len); - @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); - message.desktop_notification.title[title_len] = 0; + // We loop while pgid == my_pgid. The expectation if we have a valid + // pid is that setsid will eventually be called because it is the + // FIRST thing the child process does and as far as I can tell, + // setsid cannot fail. I'm sure that's not true, but I'd rather + // have a bug reported than defensively program against it now. + while (true) { + const pgid = c.getpgid(pid); + if (pgid == my_pgid) { + log.warn("pgid is our own, retrying", .{}); + std.time.sleep(10 * std.time.ns_per_ms); + continue; + } - const body_len = @min(body.len, message.desktop_notification.body.len); - @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); - message.desktop_notification.body[body_len] = 0; + // Don't know why it would be zero but its not a valid pid + if (pgid == 0) return null; - self.surfaceMessageWriter(message); + // If the pid doesn't exist then... we're done! + if (pgid == c.ESRCH) return null; + + // If we have an error we're done. + if (pgid < 0) { + log.warn("error getting pgid for kill", .{}); + return null; + } + + return pgid; } -}; +} + +/// Kill the underlying process started via Flatpak host command. +/// This sends a signal via the Flatpak API. +fn killCommandFlatpak(command: *FlatpakHostCommand) !void { + try command.signal(c.SIGHUP, true); +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index fb6c53823..a8920527a 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -16,6 +16,7 @@ const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); const Pty = @import("../pty.zig").Pty; const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; +const StreamHandler = @import("stream_handler.zig").StreamHandler; const terminal = @import("../terminal/main.zig"); const terminfo = @import("../terminfo/main.zig"); const xev = @import("xev"); @@ -27,8 +28,6 @@ const windows = internal_os.windows; const configpkg = @import("../config.zig"); const shell_integration = @import("shell_integration.zig"); -const StreamHandler = @import("stream_handler.zig").StreamHandler; - const log = std.log.scoped(.io_exec); const c = @cImport({ @@ -46,7 +45,7 @@ const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; alloc: Allocator, /// This is the pty fd created for the subcommand. -subprocess: Subprocess, +subprocess: termio.Exec, /// The derived configuration for this termio implementation. config: DerivedConfig, @@ -169,7 +168,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio { // Set our default cursor style term.screen.cursor.cursor_style = opts.config.cursor_style; - var subprocess = try Subprocess.init(alloc, opts); + var subprocess = try termio.Exec.init(alloc, opts); errdefer subprocess.deinit(); // If we have an initial pwd requested by the subprocess, then we @@ -942,635 +941,6 @@ fn ttyWrite( return .disarm; } -/// Subprocess manages the lifecycle of the shell subprocess. -const Subprocess = struct { - /// If we build with flatpak support then we have to keep track of - /// a potential execution on the host. - const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; - - arena: std.heap.ArenaAllocator, - cwd: ?[]const u8, - env: EnvMap, - args: [][]const u8, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - pty: ?Pty = null, - command: ?Command = null, - flatpak_command: ?FlatpakHostCommand = null, - linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, - - /// Initialize the subprocess. This will NOT start it, this only sets - /// up the internal state necessary to start it later. - pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { - // We have a lot of maybe-allocations that all share the same lifetime - // so use an arena so we don't end up in an accounting nightmare. - var arena = std.heap.ArenaAllocator.init(gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Set our env vars. For Flatpak builds running in Flatpak we don't - // inherit our environment because the login shell on the host side - // will get it. - var env = env: { - if (comptime build_config.flatpak) { - if (internal_os.isFlatpak()) { - break :env std.process.EnvMap.init(alloc); - } - } - - break :env try std.process.getEnvMap(alloc); - }; - errdefer env.deinit(); - - // If we have a resources dir then set our env var - if (opts.resources_dir) |dir| { - log.info("found Ghostty resources dir: {s}", .{dir}); - try env.put("GHOSTTY_RESOURCES_DIR", dir); - } - - // Set our TERM var. This is a bit complicated because we want to use - // the ghostty TERM value but we want to only do that if we have - // ghostty in the TERMINFO database. - // - // For now, we just look up a bundled dir but in the future we should - // also load the terminfo database and look for it. - if (opts.resources_dir) |base| { - try env.put("TERM", opts.config.term); - try env.put("COLORTERM", "truecolor"); - - // Assume that the resources directory is adjacent to the terminfo - // database - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ - std.fs.path.dirname(base) orelse unreachable, - }); - try env.put("TERMINFO", dir); - } else { - if (comptime builtin.target.isDarwin()) { - log.warn("ghostty terminfo not found, using xterm-256color", .{}); - log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); - log.warn("you're using a valid app bundle.", .{}); - } - - try env.put("TERM", "xterm-256color"); - try env.put("COLORTERM", "truecolor"); - } - - // Add our binary to the path if we can find it. - ghostty_path: { - var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { - log.warn("failed to get ghostty exe path err={}", .{err}); - break :ghostty_path; - }; - const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; - log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); - - // We always set this so that if the shell overwrites the path - // scripts still have a way to find the Ghostty binary when - // running in Ghostty. - try env.put("GHOSTTY_BIN_DIR", exe_dir); - - // Append if we have a path. We want to append so that ghostty is - // the last priority in the path. If we don't have a path set - // then we just set it to the directory of the binary. - if (env.get("PATH")) |path| { - // Verify that our path doesn't already contain this entry - var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); - while (it.next()) |entry| { - if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; - } - - try env.put( - "PATH", - try internal_os.appendEnv(alloc, path, exe_dir), - ); - } else { - try env.put("PATH", exe_dir); - } - } - - // Add the man pages from our application bundle to MANPATH. - if (comptime builtin.target.isDarwin()) { - if (opts.resources_dir) |resources_dir| man: { - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { - log.warn("error building manpath, man pages may not be available err={}", .{err}); - break :man; - }; - - if (env.get("MANPATH")) |manpath| { - // Append to the existing MANPATH. It's very unlikely that our bundle's - // resources directory already appears here so we don't spend the time - // searching for it. - try env.put( - "MANPATH", - try internal_os.appendEnv(alloc, manpath, dir), - ); - } else { - try env.put("MANPATH", dir); - } - } - } - - // Set environment variables used by some programs (such as neovim) to detect - // which terminal emulator and version they're running under. - try env.put("TERM_PROGRAM", "ghostty"); - try env.put("TERM_PROGRAM_VERSION", build_config.version_string); - - // When embedding in macOS and running via XCode, XCode injects - // a bunch of things that break our shell process. We remove those. - if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { - if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { - env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); - env.remove("__XPC_DYLD_LIBRARY_PATH"); - env.remove("DYLD_FRAMEWORK_PATH"); - env.remove("DYLD_INSERT_LIBRARIES"); - env.remove("DYLD_LIBRARY_PATH"); - env.remove("LD_LIBRARY_PATH"); - env.remove("SECURITYSESSIONID"); - env.remove("XPC_SERVICE_NAME"); - } - - // Remove this so that running `ghostty` within Ghostty works. - env.remove("GHOSTTY_MAC_APP"); - } - - // Don't leak these environment variables to child processes. - if (comptime build_config.app_runtime == .gtk) { - env.remove("GDK_DEBUG"); - env.remove("GSK_RENDERER"); - } - - // Setup our shell integration, if we can. - const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { - const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { - .windows => "cmd.exe", - else => "sh", - }; - - const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { - .none => break :shell .{ null, default_shell_command }, - .detect => null, - .bash => .bash, - .elvish => .elvish, - .fish => .fish, - .zsh => .zsh, - }; - - const dir = opts.resources_dir orelse break :shell .{ - null, - default_shell_command, - }; - - const integration = try shell_integration.setup( - alloc, - dir, - default_shell_command, - &env, - force, - opts.full_config.@"shell-integration-features", - ) orelse break :shell .{ null, default_shell_command }; - - break :shell .{ integration.shell, integration.command }; - }; - - if (integrated_shell) |shell| { - log.info( - "shell integration automatically injected shell={}", - .{shell}, - ); - } else if (opts.full_config.@"shell-integration" != .none) { - log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); - } - - // Build our args list - const args = args: { - const cap = 9; // the most we'll ever use - var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); - defer args.deinit(); - - // If we're on macOS, we have to use `login(1)` to get all of - // the proper environment variables set, a login shell, and proper - // hushlogin behavior. - if (comptime builtin.target.isDarwin()) darwin: { - const passwd = internal_os.passwd.get(alloc) catch |err| { - log.warn("failed to read passwd, not using a login shell err={}", .{err}); - break :darwin; - }; - - const username = passwd.name orelse { - log.warn("failed to get username, not using a login shell", .{}); - break :darwin; - }; - - const hush = if (passwd.home) |home| hush: { - var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { - log.warn( - "failed to open home dir, not checking for hushlogin err={}", - .{err}, - ); - break :hush false; - }; - defer dir.close(); - - break :hush if (dir.access(".hushlogin", .{})) true else |_| false; - } else false; - - const cmd = try std.fmt.allocPrint( - alloc, - "exec -l {s}", - .{shell_command}, - ); - - // The reason for executing login this way is unclear. This - // comment will attempt to explain but prepare for a truly - // unhinged reality. - // - // The first major issue is that on macOS, a lot of users - // put shell configurations in ~/.bash_profile instead of - // ~/.bashrc (or equivalent for another shell). This file is only - // loaded for a login shell so macOS users expect all their terminals - // to be login shells. No other platform behaves this way and its - // totally braindead but somehow the entire dev community on - // macOS has cargo culted their way to this reality so we have to - // do it... - // - // To get a login shell, you COULD just prepend argv0 with a `-` - // but that doesn't fully work because `getlogin()` C API will - // return the wrong value, SHELL won't be set, and various - // other login behaviors that macOS users expect. - // - // The proper way is to use `login(1)`. But login(1) forces - // the working directory to change to the home directory, - // which we may not want. If we specify "-l" then we can avoid - // this behavior but now the shell isn't a login shell. - // - // There is another issue: `login(1)` only checks for ".hushlogin" - // in the working directory. This means that if we specify "-l" - // then we won't get hushlogin honored if its in the home - // directory (which is standard). To get around this, we - // check for hushlogin ourselves and if present specify the - // "-q" flag to login(1). - // - // So to get all the behaviors we want, we specify "-l" but - // execute "bash" (which is built-in to macOS). We then use - // the bash builtin "exec" to replace the process with a login - // shell ("-l" on exec) with the command we really want. - // - // We use "bash" instead of other shells that ship with macOS - // because as of macOS Sonoma, we found with a microbenchmark - // that bash can `exec` into the desired command ~2x faster - // than zsh. - // - // To figure out a lot of this logic I read the login.c - // source code in the OSS distribution Apple provides for - // macOS. - // - // Awesome. - try args.append("/usr/bin/login"); - if (hush) try args.append("-q"); - try args.append("-flp"); - - // We execute bash with "--noprofile --norc" so that it doesn't - // load startup files so that (1) our shell integration doesn't - // break and (2) user configuration doesn't mess this process - // up. - try args.append(username); - try args.append("/bin/bash"); - try args.append("--noprofile"); - try args.append("--norc"); - try args.append("-c"); - try args.append(cmd); - break :args try args.toOwnedSlice(); - } - - if (comptime builtin.os.tag == .windows) { - // We run our shell wrapped in `cmd.exe` so that we don't have - // to parse the command line ourselves if it has arguments. - - // Note we don't free any of the memory below since it is - // allocated in the arena. - const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); - const cmd = try std.fs.path.join(alloc, &[_][]const u8{ - windir, - "System32", - "cmd.exe", - }); - - try args.append(cmd); - try args.append("/C"); - } else { - // We run our shell wrapped in `/bin/sh` so that we don't have - // to parse the command line ourselves if it has arguments. - // Additionally, some environments (NixOS, I found) use /bin/sh - // to setup some environment variables that are important to - // have set. - try args.append("/bin/sh"); - if (internal_os.isFlatpak()) try args.append("-l"); - try args.append("-c"); - } - - try args.append(shell_command); - break :args try args.toOwnedSlice(); - }; - - // We have to copy the cwd because there is no guarantee that - // pointers in full_config remain valid. - const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| - try alloc.dupe(u8, cwd) - else - null; - - // If we have a cgroup, then we copy that into our arena so the - // memory remains valid when we start. - const linux_cgroup: Command.LinuxCgroup = cgroup: { - const default = Command.linux_cgroup_default; - if (comptime builtin.os.tag != .linux) break :cgroup default; - const path = opts.linux_cgroup orelse break :cgroup default; - break :cgroup try alloc.dupe(u8, path); - }; - - // Our screen size should be our padded size - const padded_size = opts.screen_size.subPadding(opts.padding); - - return .{ - .arena = arena, - .env = env, - .cwd = cwd, - .args = args, - .grid_size = opts.grid_size, - .screen_size = padded_size, - .linux_cgroup = linux_cgroup, - }; - } - - /// Clean up the subprocess. This will stop the subprocess if it is started. - pub fn deinit(self: *Subprocess) void { - self.stop(); - if (self.pty) |*pty| pty.deinit(); - self.arena.deinit(); - self.* = undefined; - } - - /// Start the subprocess. If the subprocess is already started this - /// will crash. - pub fn start(self: *Subprocess, alloc: Allocator) !struct { - read: Pty.Fd, - write: Pty.Fd, - } { - assert(self.pty == null and self.command == null); - - // Create our pty - var pty = try Pty.open(.{ - .ws_row = @intCast(self.grid_size.rows), - .ws_col = @intCast(self.grid_size.columns), - .ws_xpixel = @intCast(self.screen_size.width), - .ws_ypixel = @intCast(self.screen_size.height), - }); - self.pty = pty; - errdefer { - pty.deinit(); - self.pty = null; - } - - log.debug("starting command command={s}", .{self.args}); - - // In flatpak, we use the HostCommand to execute our shell. - if (internal_os.isFlatpak()) flatpak: { - if (comptime !build_config.flatpak) { - log.warn("flatpak detected, but flatpak support not built-in", .{}); - break :flatpak; - } - - // Flatpak command must have a stable pointer. - self.flatpak_command = .{ - .argv = self.args, - .env = &self.env, - .stdin = pty.slave, - .stdout = pty.slave, - .stderr = pty.slave, - }; - var cmd = &self.flatpak_command.?; - const pid = try cmd.spawn(alloc); - errdefer killCommandFlatpak(cmd); - - log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ - self.args[0], - pid, - }); - - // Once started, we can close the pty child side. We do this after - // wait right now but that is fine too. This lets us read the - // parent and detect EOF. - _ = posix.close(pty.slave); - - return .{ - .read = pty.master, - .write = pty.master, - }; - } - - // If we can't access the cwd, then don't set any cwd and inherit. - // This is important because our cwd can be set by the shell (OSC 7) - // and we don't want to break new windows. - const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { - if (std.fs.accessAbsolute(proposed, .{})) { - break :cwd proposed; - } else |err| { - log.warn("cannot access cwd, ignoring: {}", .{err}); - break :cwd null; - } - } else null; - - // Build our subcommand - var cmd: Command = .{ - .path = self.args[0], - .args = self.args, - .env = &self.env, - .cwd = cwd, - .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, - .pre_exec = if (builtin.os.tag == .windows) null else (struct { - fn callback(cmd: *Command) void { - const sp = cmd.getData(Subprocess) orelse unreachable; - sp.childPreExec() catch |err| log.err( - "error initializing child: {}", - .{err}, - ); - } - }).callback, - .data = self, - .linux_cgroup = self.linux_cgroup, - }; - try cmd.start(alloc); - errdefer killCommand(&cmd) catch |err| { - log.warn("error killing command during cleanup err={}", .{err}); - }; - log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); - if (comptime builtin.os.tag == .linux) { - log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); - } - - self.command = cmd; - return switch (builtin.os.tag) { - .windows => .{ - .read = pty.out_pipe, - .write = pty.in_pipe, - }, - - else => .{ - .read = pty.master, - .write = pty.master, - }, - }; - } - - /// This should be called after fork but before exec in the child process. - /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before - /// exec is called; it does NOT run in the main Ghostty process. - fn childPreExec(self: *Subprocess) !void { - // Setup our pty - try self.pty.?.childPreExec(); - } - - /// Called to notify that we exited externally so we can unset our - /// running state. - pub fn externalExit(self: *Subprocess) void { - self.command = null; - } - - /// Stop the subprocess. This is safe to call anytime. This will wait - /// for the subprocess to register that it has been signalled, but not - /// for it to terminate, so it will not block. - /// This does not close the pty. - pub fn stop(self: *Subprocess) void { - // Kill our command - if (self.command) |*cmd| { - // Note: this will also wait for the command to exit, so - // DO NOT call cmd.wait - killCommand(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - self.command = null; - } - - // Kill our Flatpak command - if (FlatpakHostCommand != void) { - if (self.flatpak_command) |*cmd| { - killCommandFlatpak(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - _ = cmd.wait() catch |err| - log.err("error waiting for command to exit: {}", .{err}); - self.flatpak_command = null; - } - } - } - - /// Resize the pty subprocess. This is safe to call anytime. - pub fn resize( - self: *Subprocess, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - ) !void { - self.grid_size = grid_size; - self.screen_size = screen_size; - - if (self.pty) |*pty| { - try pty.setSize(.{ - .ws_row = @intCast(grid_size.rows), - .ws_col = @intCast(grid_size.columns), - .ws_xpixel = @intCast(screen_size.width), - .ws_ypixel = @intCast(screen_size.height), - }); - } - } - - /// Kill the underlying subprocess. This sends a SIGHUP to the child - /// process. This also waits for the command to exit and will return the - /// exit code. - fn killCommand(command: *Command) !void { - if (command.pid) |pid| { - switch (builtin.os.tag) { - .windows => { - if (windows.kernel32.TerminateProcess(pid, 0) == 0) { - return windows.unexpectedError(windows.kernel32.GetLastError()); - } - - _ = try command.wait(false); - }, - - else => if (getpgid(pid)) |pgid| { - // It is possible to send a killpg between the time that - // our child process calls setsid but before or simultaneous - // to calling execve. In this case, the direct child dies - // but grandchildren survive. To work around this, we loop - // and repeatedly kill the process group until all - // descendents are well and truly dead. We will not rest - // until the entire family tree is obliterated. - while (true) { - if (c.killpg(pgid, c.SIGHUP) < 0) { - log.warn("error killing process group pgid={}", .{pgid}); - return error.KillFailed; - } - - // See Command.zig wait for why we specify WNOHANG. - // The gist is that it lets us detect when children - // are still alive without blocking so that we can - // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - if (res.pid != 0) break; - std.time.sleep(10 * std.time.ns_per_ms); - } - }, - } - } - } - - fn getpgid(pid: c.pid_t) ?c.pid_t { - // Get our process group ID. Before the child pid calls setsid - // the pgid will be ours because we forked it. Its possible that - // we may be calling this before setsid if we are killing a surface - // VERY quickly after starting it. - const my_pgid = c.getpgid(0); - - // We loop while pgid == my_pgid. The expectation if we have a valid - // pid is that setsid will eventually be called because it is the - // FIRST thing the child process does and as far as I can tell, - // setsid cannot fail. I'm sure that's not true, but I'd rather - // have a bug reported than defensively program against it now. - while (true) { - const pgid = c.getpgid(pid); - if (pgid == my_pgid) { - log.warn("pgid is our own, retrying", .{}); - std.time.sleep(10 * std.time.ns_per_ms); - continue; - } - - // Don't know why it would be zero but its not a valid pid - if (pgid == 0) return null; - - // If the pid doesn't exist then... we're done! - if (pgid == c.ESRCH) return null; - - // If we have an error we're done. - if (pgid < 0) { - log.warn("error getting pgid for kill", .{}); - return null; - } - - return pgid; - } - } - - /// Kill the underlying process started via Flatpak host command. - /// This sends a signal via the Flatpak API. - fn killCommandFlatpak(command: *FlatpakHostCommand) !void { - try command.signal(c.SIGHUP, true); - } -}; - /// The read thread sits in a loop doing the following pseudo code: /// /// while (true) { blocking_read(); exit_if_eof(); process(); } diff --git a/src/termio/reader.zig b/src/termio/reader.zig index 31d51498f..11af2f70b 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -1,10 +1,18 @@ const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const posix = std.posix; const xev = @import("xev"); +const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); +const internal_os = @import("../os/main.zig"); +const renderer = @import("../renderer.zig"); +const shell_integration = @import("shell_integration.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; +const Pty = @import("../pty.zig").Pty; // The preallocation size for the write request pool. This should be big // enough to satisfy most write requests. It must be a power of 2. @@ -22,7 +30,7 @@ pub const Config = union(enum) { manual: void, /// Exec uses posix exec to run a command with a pty. - exec: Exec, + exec: Config.Exec, pub const Exec = struct { command: ?[]const u8 = null, @@ -36,7 +44,7 @@ pub const Config = union(enum) { /// Termio thread data. See termio.ThreadData for docs. pub const ThreadData = union(Kind) { manual: void, - exec: Exec, + exec: ThreadData.Exec, pub const Exec = struct { /// Process start time and boolean of whether its already exited. From 16f589072427a90425cf9b274be297508ed9d620 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 15:18:15 -0700 Subject: [PATCH 09/32] termio: delete old impl --- src/termio/Old.zig | 2965 -------------------------------------------- 1 file changed, 2965 deletions(-) delete mode 100644 src/termio/Old.zig diff --git a/src/termio/Old.zig b/src/termio/Old.zig deleted file mode 100644 index a93f858e2..000000000 --- a/src/termio/Old.zig +++ /dev/null @@ -1,2965 +0,0 @@ -//! Primary terminal IO ("termio") state. This maintains the terminal state, -//! pty, subprocess, etc. This is flexible enough to be used in environments -//! that don't have a pty and simply provides the input/output using raw -//! bytes. -pub const Termio = @This(); - -const std = @import("std"); -const builtin = @import("builtin"); -const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const EnvMap = std.process.EnvMap; -const posix = std.posix; -const termio = @import("../termio.zig"); -const Command = @import("../Command.zig"); -const Pty = @import("../pty.zig").Pty; -const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; -const terminal = @import("../terminal/main.zig"); -const terminfo = @import("../terminfo/main.zig"); -const xev = @import("xev"); -const renderer = @import("../renderer.zig"); -const apprt = @import("../apprt.zig"); -const fastmem = @import("../fastmem.zig"); -const internal_os = @import("../os/main.zig"); -const windows = internal_os.windows; -const configpkg = @import("../config.zig"); -const shell_integration = @import("shell_integration.zig"); - -const log = std.log.scoped(.io_exec); - -const c = @cImport({ - @cInclude("errno.h"); - @cInclude("signal.h"); - @cInclude("unistd.h"); -}); - -/// True if we should disable the kitty keyboard protocol. We have to -/// disable this on GLFW because GLFW input events don't support the -/// correct granularity of events. -const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; - -/// Allocator -alloc: Allocator, - -/// This is the pty fd created for the subcommand. -subprocess: Subprocess, - -/// The derived configuration for this termio implementation. -config: DerivedConfig, - -/// The terminal emulator internal state. This is the abstract "terminal" -/// that manages input, grid updating, etc. and is renderer-agnostic. It -/// just stores internal state about a grid. -terminal: terminal.Terminal, - -/// The shared render state -renderer_state: *renderer.State, - -/// A handle to wake up the renderer. This hints to the renderer that that -/// a repaint should happen. -renderer_wakeup: xev.Async, - -/// The mailbox for notifying the renderer of things. -renderer_mailbox: *renderer.Thread.Mailbox, - -/// The mailbox for communicating with the surface. -surface_mailbox: apprt.surface.Mailbox, - -/// The cached grid size whenever a resize is called. -grid_size: renderer.GridSize, - -/// The data associated with the currently running thread. -data: ?*EventData, - -/// The configuration for this IO that is derived from the main -/// configuration. This must be exported so that we don't need to -/// pass around Config pointers which makes memory management a pain. -pub const DerivedConfig = struct { - arena: ArenaAllocator, - - palette: terminal.color.Palette, - image_storage_limit: usize, - cursor_style: terminal.CursorStyle, - cursor_blink: ?bool, - cursor_color: ?configpkg.Config.Color, - foreground: configpkg.Config.Color, - background: configpkg.Config.Color, - osc_color_report_format: configpkg.Config.OSCColorReportFormat, - term: []const u8, - grapheme_width_method: configpkg.Config.GraphemeWidthMethod, - abnormal_runtime_threshold_ms: u32, - wait_after_command: bool, - enquiry_response: []const u8, - - pub fn init( - alloc_gpa: Allocator, - config: *const configpkg.Config, - ) !DerivedConfig { - var arena = ArenaAllocator.init(alloc_gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - return .{ - .palette = config.palette.value, - .image_storage_limit = config.@"image-storage-limit", - .cursor_style = config.@"cursor-style", - .cursor_blink = config.@"cursor-style-blink", - .cursor_color = config.@"cursor-color", - .foreground = config.foreground, - .background = config.background, - .osc_color_report_format = config.@"osc-color-report-format", - .term = try alloc.dupe(u8, config.term), - .grapheme_width_method = config.@"grapheme-width-method", - .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", - .wait_after_command = config.@"wait-after-command", - .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), - - // This has to be last so that we copy AFTER the arena allocations - // above happen (Zig assigns in order). - .arena = arena, - }; - } - - pub fn deinit(self: *DerivedConfig) void { - self.arena.deinit(); - } -}; - -/// Initialize the termio state. -/// -/// This will also start the child process if the termio is configured -/// to run a child process. -pub fn init(alloc: Allocator, opts: termio.Options) !Termio { - // Create our terminal - var term = try terminal.Terminal.init(alloc, .{ - .cols = opts.grid_size.columns, - .rows = opts.grid_size.rows, - .max_scrollback = opts.full_config.@"scrollback-limit", - }); - errdefer term.deinit(alloc); - term.default_palette = opts.config.palette; - term.color_palette.colors = opts.config.palette; - - // Setup our initial grapheme cluster support if enabled. We use a - // switch to ensure we get a compiler error if more cases are added. - switch (opts.config.grapheme_width_method) { - .unicode => term.modes.set(.grapheme_cluster, true), - .legacy => {}, - } - - // Set the image size limits - try term.screen.kitty_images.setLimit( - alloc, - &term.screen, - opts.config.image_storage_limit, - ); - try term.secondary_screen.kitty_images.setLimit( - alloc, - &term.secondary_screen, - opts.config.image_storage_limit, - ); - - // Set default cursor blink settings - term.modes.set( - .cursor_blinking, - opts.config.cursor_blink orelse true, - ); - - // Set our default cursor style - term.screen.cursor.cursor_style = opts.config.cursor_style; - - var subprocess = try Subprocess.init(alloc, opts); - errdefer subprocess.deinit(); - - // If we have an initial pwd requested by the subprocess, then we - // set that on the terminal now. This allows rapidly initializing - // new surfaces to use the proper pwd. - if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { - log.warn("error setting initial pwd err={}", .{err}); - }; - - // Initial width/height based on subprocess - term.width_px = subprocess.screen_size.width; - term.height_px = subprocess.screen_size.height; - - return .{ - .alloc = alloc, - .terminal = term, - .subprocess = subprocess, - .config = opts.config, - .renderer_state = opts.renderer_state, - .renderer_wakeup = opts.renderer_wakeup, - .renderer_mailbox = opts.renderer_mailbox, - .surface_mailbox = opts.surface_mailbox, - .grid_size = opts.grid_size, - .data = null, - }; -} - -pub fn deinit(self: *Termio) void { - self.subprocess.deinit(); - self.terminal.deinit(self.alloc); - self.config.deinit(); -} - -pub fn threadEnter(self: *Termio, thread: *termio.Thread) !ThreadData { - assert(self.data == null); - const alloc = self.alloc; - - // Start our subprocess - const pty_fds = self.subprocess.start(alloc) catch |err| { - // If we specifically got this error then we are in the forked - // process and our child failed to execute. In that case - if (err != error.Termio) return err; - - // Output an error message about the exec faililng and exit. - // This generally should NOT happen because we always wrap - // our command execution either in login (macOS) or /bin/sh - // (Linux) which are usually guaranteed to exist. Still, we - // want to handle this scenario. - self.execFailedInChild() catch {}; - posix.exit(1); - }; - errdefer self.subprocess.stop(); - const pid = pid: { - const command = self.subprocess.command orelse return error.ProcessNotStarted; - break :pid command.pid orelse return error.ProcessNoPid; - }; - - // Track our process start time so we know how long it was - // running for. - const process_start = try std.time.Instant.now(); - - // Create our pipe that we'll use to kill our read thread. - // pipe[0] is the read end, pipe[1] is the write end. - const pipe = try internal_os.pipe(); - errdefer posix.close(pipe[0]); - errdefer posix.close(pipe[1]); - - // Setup our data that is used for callbacks - var ev_data_ptr = try alloc.create(EventData); - errdefer alloc.destroy(ev_data_ptr); - - // Setup our stream so that we can write. - var stream = xev.Stream.initFd(pty_fds.write); - errdefer stream.deinit(); - - // Wakeup watcher for the writer thread. - var wakeup = try xev.Async.init(); - errdefer wakeup.deinit(); - - // Watcher to detect subprocess exit - var process = try xev.Process.init(pid); - errdefer process.deinit(); - - // Setup our event data before we start - ev_data_ptr.* = .{ - .writer_mailbox = thread.mailbox, - .writer_wakeup = thread.wakeup, - .surface_mailbox = self.surface_mailbox, - .renderer_state = self.renderer_state, - .renderer_wakeup = self.renderer_wakeup, - .renderer_mailbox = self.renderer_mailbox, - .process = process, - .process_start = process_start, - .data_stream = stream, - .loop = &thread.loop, - .terminal_stream = .{ - .handler = StreamHandler.init( - self.alloc, - ev_data_ptr, - &self.grid_size, - &self.terminal, - &self.config, - ), - .parser = .{ - .osc_parser = .{ - // Populate the OSC parser allocator (optional) because - // we want to support large OSC payloads such as OSC 52. - .alloc = self.alloc, - }, - }, - }, - .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, - .wait_after_command = self.config.wait_after_command, - }; - errdefer ev_data_ptr.deinit(self.alloc); - - // Store our data so our callbacks can access it - self.data = ev_data_ptr; - errdefer self.data = null; - - // Start our process watcher - process.wait( - ev_data_ptr.loop, - &ev_data_ptr.process_wait_c, - EventData, - ev_data_ptr, - processExit, - ); - - // Start our reader thread - const read_thread = try std.Thread.spawn( - .{}, - if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, - .{ pty_fds.read, ev_data_ptr, pipe[0] }, - ); - read_thread.setName("io-reader") catch {}; - - // Return our thread data - return ThreadData{ - .alloc = alloc, - .ev = ev_data_ptr, - .read_thread = read_thread, - .read_thread_pipe = pipe[1], - .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, - }; -} - -/// This outputs an error message when exec failed and we are the -/// child process. This returns so the caller should probably exit -/// after calling this. -/// -/// Note that this usually is only called under very very rare -/// circumstances because we wrap our command execution in login -/// (macOS) or /bin/sh (Linux). So this output can be pretty crude -/// because it should never happen. Notably, this is not the error -/// users see when `command` is invalid. -fn execFailedInChild(self: *Termio) !void { - _ = self; - const stderr = std.io.getStdErr().writer(); - try stderr.writeAll("exec failed\n"); - try stderr.writeAll("press any key to exit\n"); - - var buf: [1]u8 = undefined; - var reader = std.io.getStdIn().reader(); - _ = try reader.read(&buf); -} - -pub fn threadExit(self: *Termio, data: ThreadData) void { - // Clear out our data since we're not active anymore. - self.data = null; - - // Stop our subprocess - if (data.ev.process_exited) self.subprocess.externalExit(); - self.subprocess.stop(); - - // Quit our read thread after exiting the subprocess so that - // we don't get stuck waiting for data to stop flowing if it is - // a particularly noisy process. - _ = posix.write(data.read_thread_pipe, "x") catch |err| - log.warn("error writing to read thread quit pipe err={}", .{err}); - - if (comptime builtin.os.tag == .windows) { - // Interrupt the blocking read so the thread can see the quit message - if (windows.kernel32.CancelIoEx(data.read_thread_fd, null) == 0) { - switch (windows.kernel32.GetLastError()) { - .NOT_FOUND => {}, - else => |err| log.warn("error interrupting read thread err={}", .{err}), - } - } - } - - data.read_thread.join(); -} - -/// Update the configuration. -pub fn changeConfig(self: *Termio, config: *DerivedConfig) !void { - // The remainder of this function is modifying terminal state or - // the read thread data, all of which requires holding the renderer - // state lock. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // Deinit our old config. We do this in the lock because the - // stream handler may be referencing the old config (i.e. enquiry resp) - self.config.deinit(); - self.config = config.*; - - // Update our stream handler. The stream handler uses the same - // renderer mutex so this is safe to do despite being executed - // from another thread. - if (self.data) |data| { - data.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; - data.wait_after_command = config.wait_after_command; - data.terminal_stream.handler.changeConfig(&self.config); - } - - // Update the configuration that we know about. - // - // Specific things we don't update: - // - command, working-directory: we never restart the underlying - // process so we don't care or need to know about these. - - // Update the default palette. Note this will only apply to new colors drawn - // since we decode all palette colors to RGB on usage. - self.terminal.default_palette = config.palette; - - // Update the active palette, except for any colors that were modified with - // OSC 4 - for (0..config.palette.len) |i| { - if (!self.terminal.color_palette.mask.isSet(i)) { - self.terminal.color_palette.colors[i] = config.palette[i]; - self.terminal.flags.dirty.palette = true; - } - } - - // Set the image size limits - try self.terminal.screen.kitty_images.setLimit( - self.alloc, - &self.terminal.screen, - config.image_storage_limit, - ); - try self.terminal.secondary_screen.kitty_images.setLimit( - self.alloc, - &self.terminal.secondary_screen, - config.image_storage_limit, - ); -} - -/// Resize the terminal. -pub fn resize( - self: *Termio, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - padding: renderer.Padding, -) !void { - // Update the size of our pty. - const padded_size = screen_size.subPadding(padding); - try self.subprocess.resize(grid_size, padded_size); - - // Update our cached grid size - self.grid_size = grid_size; - - // Enter the critical area that we want to keep small - { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // Update the size of our terminal state - try self.terminal.resize( - self.alloc, - grid_size.columns, - grid_size.rows, - ); - - // Update our pixel sizes - self.terminal.width_px = padded_size.width; - self.terminal.height_px = padded_size.height; - - // Disable synchronized output mode so that we show changes - // immediately for a resize. This is allowed by the spec. - self.terminal.modes.set(.synchronized_output, false); - - // Wake up our renderer so any changes will be shown asap - self.renderer_wakeup.notify() catch {}; - } -} - -/// Reset the synchronized output mode. This is usually called by timer -/// expiration from the termio thread. -pub fn resetSynchronizedOutput(self: *Termio) void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.terminal.modes.set(.synchronized_output, false); - self.renderer_wakeup.notify() catch {}; -} - -/// Clear the screen. -pub fn clearScreen(self: *Termio, history: bool) !void { - { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // If we're on the alternate screen, we do not clear. Since this is an - // emulator-level screen clear, this messes up the running programs - // knowledge of where the cursor is and causes rendering issues. So, - // for alt screen, we do nothing. - if (self.terminal.active_screen == .alternate) return; - - // Clear our scrollback - if (history) self.terminal.eraseDisplay(.scrollback, false); - - // If we're not at a prompt, we just delete above the cursor. - if (!self.terminal.cursorIsAtPrompt()) { - if (self.terminal.screen.cursor.y > 0) { - self.terminal.screen.eraseRows( - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, - ); - } - - return; - } - - // At a prompt, we want to first fully clear the screen, and then after - // send a FF (0x0C) to the shell so that it can repaint the screen. - // Mark the current row as a not a prompt so we can properly - // clear the full screen in the next eraseDisplay call. - self.terminal.markSemanticPrompt(.command); - assert(!self.terminal.cursorIsAtPrompt()); - self.terminal.eraseDisplay(.complete, false); - } - - // If we reached here it means we're at a prompt, so we send a form-feed. - try self.queueWrite(&[_]u8{0x0C}, false); -} - -/// Scroll the viewport -pub fn scrollViewport(self: *Termio, scroll: terminal.Terminal.ScrollViewport) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - try self.terminal.scrollViewport(scroll); -} - -/// Jump the viewport to the prompt. -pub fn jumpToPrompt(self: *Termio, delta: isize) !void { - { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.terminal.screen.scroll(.{ .delta_prompt = delta }); - } - - try self.renderer_wakeup.notify(); -} - -/// Called when the child process exited abnormally but before -/// the surface is notified. -pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const alloc = arena.allocator(); - - // Build up our command for the error message - const command = try std.mem.join(alloc, " ", self.subprocess.args); - const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); - - // Modify the terminal to show our error message. This - // requires grabbing the renderer state lock. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = self.renderer_state.terminal; - - // No matter what move the cursor back to the column 0. - t.carriageReturn(); - - // Reset styles - try t.setAttribute(.{ .unset = {} }); - - // If there is data in the viewport, we want to scroll down - // a little bit and write a horizontal rule before writing - // our message. This lets the use see the error message the - // command may have output. - const viewport_str = try t.plainString(alloc); - if (viewport_str.len > 0) { - try t.linefeed(); - for (0..t.cols) |_| try t.print(0x2501); - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - } - - // Output our error message - try t.setAttribute(.{ .@"8_fg" = .bright_red }); - try t.setAttribute(.{ .bold = {} }); - try t.printString("Ghostty failed to launch the requested command:"); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString(command); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Runtime: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(runtime_str); - try t.setAttribute(.{ .unset = {} }); - - // We don't print this on macOS because the exit code is always 0 - // due to the way we launch the process. - if (comptime !builtin.target.isDarwin()) { - const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); - t.carriageReturn(); - try t.linefeed(); - try t.printString("Exit Code: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(exit_code_str); - try t.setAttribute(.{ .unset = {} }); - } - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Press any key to close the window."); - - // Hide the cursor - t.modes.set(.cursor_visible, false); -} - -pub inline fn queueWrite(self: *Termio, data: []const u8, linefeed: bool) !void { - const ev = self.data.?; - - // If our process is exited then we send our surface a message - // about it but we don't queue any more writes. - if (ev.process_exited) { - _ = ev.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } - - // We go through and chunk the data if necessary to fit into - // our cached buffers that we can queue to the stream. - var i: usize = 0; - while (i < data.len) { - const req = try ev.write_req_pool.getGrow(self.alloc); - const buf = try ev.write_buf_pool.getGrow(self.alloc); - const slice = slice: { - // The maximum end index is either the end of our data or - // the end of our buffer, whichever is smaller. - const max = @min(data.len, i + buf.len); - - // Fast - if (!linefeed) { - fastmem.copy(u8, buf, data[i..max]); - const len = max - i; - i = max; - break :slice buf[0..len]; - } - - // Slow, have to replace \r with \r\n - var buf_i: usize = 0; - while (i < data.len and buf_i < buf.len - 1) { - const ch = data[i]; - i += 1; - - if (ch != '\r') { - buf[buf_i] = ch; - buf_i += 1; - continue; - } - - // CRLF - buf[buf_i] = '\r'; - buf[buf_i + 1] = '\n'; - buf_i += 2; - } - - break :slice buf[0..buf_i]; - }; - - //for (slice) |b| log.warn("write: {x}", .{b}); - - ev.data_stream.queueWrite( - ev.loop, - &ev.write_queue, - req, - .{ .slice = slice }, - EventData, - ev, - ttyWrite, - ); - } -} - -const ThreadData = struct { - /// Allocator used for the event data - alloc: Allocator, - - /// The data that is attached to the callbacks. - ev: *EventData, - - /// Our read thread - read_thread: std.Thread, - read_thread_pipe: posix.fd_t, - read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, - - pub fn deinit(self: *ThreadData) void { - posix.close(self.read_thread_pipe); - self.ev.deinit(self.alloc); - self.alloc.destroy(self.ev); - self.* = undefined; - } -}; - -const EventData = struct { - // The preallocation size for the write request pool. This should be big - // enough to satisfy most write requests. It must be a power of 2. - const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); - - /// Mailbox for data to the writer thread. - writer_mailbox: *termio.Mailbox, - writer_wakeup: xev.Async, - - /// Mailbox for the surface. - surface_mailbox: apprt.surface.Mailbox, - - /// The stream parser. This parses the stream of escape codes and so on - /// from the child process and calls callbacks in the stream handler. - terminal_stream: terminal.Stream(StreamHandler), - - /// The shared render state - renderer_state: *renderer.State, - - /// A handle to wake up the renderer. This hints to the renderer that that - /// a repaint should happen. - renderer_wakeup: xev.Async, - - /// The mailbox for notifying the renderer of things. - renderer_mailbox: *renderer.Thread.Mailbox, - - /// The process watcher - process: xev.Process, - process_start: std.time.Instant, - process_exited: bool = false, - - /// This is used for both waiting for the process to exit and then - /// subsequently to wait for the data_stream to close. - process_wait_c: xev.Completion = .{}, - - /// The data stream is the main IO for the pty. - data_stream: xev.Stream, - - /// The event loop, - loop: *xev.Loop, - - /// The write queue for the data stream. - write_queue: xev.Stream.WriteQueue = .{}, - - /// This is the pool of available (unused) write requests. If you grab - /// one from the pool, you must put it back when you're done! - write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, - - /// The pool of available buffers for writing to the pty. - write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, - - /// Last time the cursor was reset. This is used to prevent message - /// flooding with cursor resets. - last_cursor_reset: i64 = 0, - - /// This is set to true when we've seen a title escape sequence. We use - /// this to determine if we need to default the window title. - seen_title: bool = false, - - /// The number of milliseconds below which we consider a process - /// exit to be abnormal. This is used to show an error message - /// when the process exits too quickly. - abnormal_runtime_threshold_ms: u32, - - /// If true, do not immediately send a child exited message to the - /// surface to close the surface when the command exits. - wait_after_command: bool, - - pub fn deinit(self: *EventData, alloc: Allocator) void { - // Clear our write pools. We know we aren't ever going to do - // any more IO since we stop our data stream below so we can just - // drop this. - self.write_req_pool.deinit(alloc); - self.write_buf_pool.deinit(alloc); - - // Stop our data stream - self.data_stream.deinit(); - - // Stop our process watcher - self.process.deinit(); - - // Clear any StreamHandler state - self.terminal_stream.handler.deinit(); - self.terminal_stream.deinit(); - } - - /// This queues a render operation with the renderer thread. The render - /// isn't guaranteed to happen immediately but it will happen as soon as - /// practical. - inline fn queueRender(self: *EventData) !void { - try self.renderer_wakeup.notify(); - } -}; - -fn processExit( - ev_: ?*EventData, - _: *xev.Loop, - _: *xev.Completion, - r: xev.Process.WaitError!u32, -) xev.CallbackAction { - const exit_code = r catch unreachable; - - const ev = ev_.?; - ev.process_exited = true; - - // Determine how long the process was running for. - const runtime_ms: ?u64 = runtime: { - const process_end = std.time.Instant.now() catch break :runtime null; - const runtime_ns = process_end.since(ev.process_start); - const runtime_ms = runtime_ns / std.time.ns_per_ms; - break :runtime runtime_ms; - }; - log.debug( - "child process exited status={} runtime={}ms", - .{ exit_code, runtime_ms orelse 0 }, - ); - - // If our runtime was below some threshold then we assume that this - // was an abnormal exit and we show an error message. - if (runtime_ms) |runtime| runtime: { - // On macOS, our exit code detection doesn't work, possibly - // because of our `login` wrapper. More investigation required. - if (comptime !builtin.target.isDarwin()) { - // If our exit code is zero, then the command was successful - // and we don't ever consider it abnormal. - if (exit_code == 0) break :runtime; - } - - // Our runtime always has to be under the threshold to be - // considered abnormal. This is because a user can always - // manually do something like `exit 1` in their shell to - // force the exit code to be non-zero. We only want to detect - // abnormal exits that happen so quickly the user can't react. - if (runtime > ev.abnormal_runtime_threshold_ms) break :runtime; - log.warn("abnormal process exit detected, showing error message", .{}); - - // Notify our main writer thread which has access to more - // information so it can show a better error message. - _ = ev.writer_mailbox.push(.{ - .child_exited_abnormally = .{ - .exit_code = exit_code, - .runtime_ms = runtime, - }, - }, .{ .forever = {} }); - ev.writer_wakeup.notify() catch break :runtime; - - return .disarm; - } - - // If we're purposely waiting then we just return since the process - // exited flag is set to true. This allows the terminal window to remain - // open. - if (ev.wait_after_command) { - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. - terminal: { - ev.renderer_state.mutex.lock(); - defer ev.renderer_state.mutex.unlock(); - const t = ev.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - return .disarm; - } - - // Notify our surface we want to close - _ = ev.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - - return .disarm; -} - -fn ttyWrite( - ev_: ?*EventData, - _: *xev.Loop, - _: *xev.Completion, - _: xev.Stream, - _: xev.WriteBuffer, - r: xev.Stream.WriteError!usize, -) xev.CallbackAction { - const ev = ev_.?; - ev.write_req_pool.put(); - ev.write_buf_pool.put(); - - const d = r catch |err| { - log.err("write error: {}", .{err}); - return .disarm; - }; - _ = d; - //log.info("WROTE: {d}", .{d}); - - return .disarm; -} - -/// Subprocess manages the lifecycle of the shell subprocess. -const Subprocess = struct { - /// If we build with flatpak support then we have to keep track of - /// a potential execution on the host. - const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; - - arena: std.heap.ArenaAllocator, - cwd: ?[]const u8, - env: EnvMap, - args: [][]const u8, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - pty: ?Pty = null, - command: ?Command = null, - flatpak_command: ?FlatpakHostCommand = null, - linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, - - /// Initialize the subprocess. This will NOT start it, this only sets - /// up the internal state necessary to start it later. - pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { - // We have a lot of maybe-allocations that all share the same lifetime - // so use an arena so we don't end up in an accounting nightmare. - var arena = std.heap.ArenaAllocator.init(gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Set our env vars. For Flatpak builds running in Flatpak we don't - // inherit our environment because the login shell on the host side - // will get it. - var env = env: { - if (comptime build_config.flatpak) { - if (internal_os.isFlatpak()) { - break :env std.process.EnvMap.init(alloc); - } - } - - break :env try std.process.getEnvMap(alloc); - }; - errdefer env.deinit(); - - // If we have a resources dir then set our env var - if (opts.resources_dir) |dir| { - log.info("found Ghostty resources dir: {s}", .{dir}); - try env.put("GHOSTTY_RESOURCES_DIR", dir); - } - - // Set our TERM var. This is a bit complicated because we want to use - // the ghostty TERM value but we want to only do that if we have - // ghostty in the TERMINFO database. - // - // For now, we just look up a bundled dir but in the future we should - // also load the terminfo database and look for it. - if (opts.resources_dir) |base| { - try env.put("TERM", opts.config.term); - try env.put("COLORTERM", "truecolor"); - - // Assume that the resources directory is adjacent to the terminfo - // database - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ - std.fs.path.dirname(base) orelse unreachable, - }); - try env.put("TERMINFO", dir); - } else { - if (comptime builtin.target.isDarwin()) { - log.warn("ghostty terminfo not found, using xterm-256color", .{}); - log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); - log.warn("you're using a valid app bundle.", .{}); - } - - try env.put("TERM", "xterm-256color"); - try env.put("COLORTERM", "truecolor"); - } - - // Add our binary to the path if we can find it. - ghostty_path: { - var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { - log.warn("failed to get ghostty exe path err={}", .{err}); - break :ghostty_path; - }; - const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; - log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); - - // We always set this so that if the shell overwrites the path - // scripts still have a way to find the Ghostty binary when - // running in Ghostty. - try env.put("GHOSTTY_BIN_DIR", exe_dir); - - // Append if we have a path. We want to append so that ghostty is - // the last priority in the path. If we don't have a path set - // then we just set it to the directory of the binary. - if (env.get("PATH")) |path| { - // Verify that our path doesn't already contain this entry - var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); - while (it.next()) |entry| { - if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; - } - - try env.put( - "PATH", - try internal_os.appendEnv(alloc, path, exe_dir), - ); - } else { - try env.put("PATH", exe_dir); - } - } - - // Add the man pages from our application bundle to MANPATH. - if (comptime builtin.target.isDarwin()) { - if (opts.resources_dir) |resources_dir| man: { - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { - log.warn("error building manpath, man pages may not be available err={}", .{err}); - break :man; - }; - - if (env.get("MANPATH")) |manpath| { - // Append to the existing MANPATH. It's very unlikely that our bundle's - // resources directory already appears here so we don't spend the time - // searching for it. - try env.put( - "MANPATH", - try internal_os.appendEnv(alloc, manpath, dir), - ); - } else { - try env.put("MANPATH", dir); - } - } - } - - // Set environment variables used by some programs (such as neovim) to detect - // which terminal emulator and version they're running under. - try env.put("TERM_PROGRAM", "ghostty"); - try env.put("TERM_PROGRAM_VERSION", build_config.version_string); - - // When embedding in macOS and running via XCode, XCode injects - // a bunch of things that break our shell process. We remove those. - if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { - if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { - env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); - env.remove("__XPC_DYLD_LIBRARY_PATH"); - env.remove("DYLD_FRAMEWORK_PATH"); - env.remove("DYLD_INSERT_LIBRARIES"); - env.remove("DYLD_LIBRARY_PATH"); - env.remove("LD_LIBRARY_PATH"); - env.remove("SECURITYSESSIONID"); - env.remove("XPC_SERVICE_NAME"); - } - - // Remove this so that running `ghostty` within Ghostty works. - env.remove("GHOSTTY_MAC_APP"); - } - - // Don't leak these environment variables to child processes. - if (comptime build_config.app_runtime == .gtk) { - env.remove("GDK_DEBUG"); - env.remove("GSK_RENDERER"); - } - - // Setup our shell integration, if we can. - const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { - const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { - .windows => "cmd.exe", - else => "sh", - }; - - const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { - .none => break :shell .{ null, default_shell_command }, - .detect => null, - .bash => .bash, - .elvish => .elvish, - .fish => .fish, - .zsh => .zsh, - }; - - const dir = opts.resources_dir orelse break :shell .{ - null, - default_shell_command, - }; - - const integration = try shell_integration.setup( - alloc, - dir, - default_shell_command, - &env, - force, - opts.full_config.@"shell-integration-features", - ) orelse break :shell .{ null, default_shell_command }; - - break :shell .{ integration.shell, integration.command }; - }; - - if (integrated_shell) |shell| { - log.info( - "shell integration automatically injected shell={}", - .{shell}, - ); - } else if (opts.full_config.@"shell-integration" != .none) { - log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); - } - - // Build our args list - const args = args: { - const cap = 9; // the most we'll ever use - var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); - defer args.deinit(); - - // If we're on macOS, we have to use `login(1)` to get all of - // the proper environment variables set, a login shell, and proper - // hushlogin behavior. - if (comptime builtin.target.isDarwin()) darwin: { - const passwd = internal_os.passwd.get(alloc) catch |err| { - log.warn("failed to read passwd, not using a login shell err={}", .{err}); - break :darwin; - }; - - const username = passwd.name orelse { - log.warn("failed to get username, not using a login shell", .{}); - break :darwin; - }; - - const hush = if (passwd.home) |home| hush: { - var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { - log.warn( - "failed to open home dir, not checking for hushlogin err={}", - .{err}, - ); - break :hush false; - }; - defer dir.close(); - - break :hush if (dir.access(".hushlogin", .{})) true else |_| false; - } else false; - - const cmd = try std.fmt.allocPrint( - alloc, - "exec -l {s}", - .{shell_command}, - ); - - // The reason for executing login this way is unclear. This - // comment will attempt to explain but prepare for a truly - // unhinged reality. - // - // The first major issue is that on macOS, a lot of users - // put shell configurations in ~/.bash_profile instead of - // ~/.bashrc (or equivalent for another shell). This file is only - // loaded for a login shell so macOS users expect all their terminals - // to be login shells. No other platform behaves this way and its - // totally braindead but somehow the entire dev community on - // macOS has cargo culted their way to this reality so we have to - // do it... - // - // To get a login shell, you COULD just prepend argv0 with a `-` - // but that doesn't fully work because `getlogin()` C API will - // return the wrong value, SHELL won't be set, and various - // other login behaviors that macOS users expect. - // - // The proper way is to use `login(1)`. But login(1) forces - // the working directory to change to the home directory, - // which we may not want. If we specify "-l" then we can avoid - // this behavior but now the shell isn't a login shell. - // - // There is another issue: `login(1)` only checks for ".hushlogin" - // in the working directory. This means that if we specify "-l" - // then we won't get hushlogin honored if its in the home - // directory (which is standard). To get around this, we - // check for hushlogin ourselves and if present specify the - // "-q" flag to login(1). - // - // So to get all the behaviors we want, we specify "-l" but - // execute "bash" (which is built-in to macOS). We then use - // the bash builtin "exec" to replace the process with a login - // shell ("-l" on exec) with the command we really want. - // - // We use "bash" instead of other shells that ship with macOS - // because as of macOS Sonoma, we found with a microbenchmark - // that bash can `exec` into the desired command ~2x faster - // than zsh. - // - // To figure out a lot of this logic I read the login.c - // source code in the OSS distribution Apple provides for - // macOS. - // - // Awesome. - try args.append("/usr/bin/login"); - if (hush) try args.append("-q"); - try args.append("-flp"); - - // We execute bash with "--noprofile --norc" so that it doesn't - // load startup files so that (1) our shell integration doesn't - // break and (2) user configuration doesn't mess this process - // up. - try args.append(username); - try args.append("/bin/bash"); - try args.append("--noprofile"); - try args.append("--norc"); - try args.append("-c"); - try args.append(cmd); - break :args try args.toOwnedSlice(); - } - - if (comptime builtin.os.tag == .windows) { - // We run our shell wrapped in `cmd.exe` so that we don't have - // to parse the command line ourselves if it has arguments. - - // Note we don't free any of the memory below since it is - // allocated in the arena. - const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); - const cmd = try std.fs.path.join(alloc, &[_][]const u8{ - windir, - "System32", - "cmd.exe", - }); - - try args.append(cmd); - try args.append("/C"); - } else { - // We run our shell wrapped in `/bin/sh` so that we don't have - // to parse the command line ourselves if it has arguments. - // Additionally, some environments (NixOS, I found) use /bin/sh - // to setup some environment variables that are important to - // have set. - try args.append("/bin/sh"); - if (internal_os.isFlatpak()) try args.append("-l"); - try args.append("-c"); - } - - try args.append(shell_command); - break :args try args.toOwnedSlice(); - }; - - // We have to copy the cwd because there is no guarantee that - // pointers in full_config remain valid. - const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| - try alloc.dupe(u8, cwd) - else - null; - - // If we have a cgroup, then we copy that into our arena so the - // memory remains valid when we start. - const linux_cgroup: Command.LinuxCgroup = cgroup: { - const default = Command.linux_cgroup_default; - if (comptime builtin.os.tag != .linux) break :cgroup default; - const path = opts.linux_cgroup orelse break :cgroup default; - break :cgroup try alloc.dupe(u8, path); - }; - - // Our screen size should be our padded size - const padded_size = opts.screen_size.subPadding(opts.padding); - - return .{ - .arena = arena, - .env = env, - .cwd = cwd, - .args = args, - .grid_size = opts.grid_size, - .screen_size = padded_size, - .linux_cgroup = linux_cgroup, - }; - } - - /// Clean up the subprocess. This will stop the subprocess if it is started. - pub fn deinit(self: *Subprocess) void { - self.stop(); - if (self.pty) |*pty| pty.deinit(); - self.arena.deinit(); - self.* = undefined; - } - - /// Start the subprocess. If the subprocess is already started this - /// will crash. - pub fn start(self: *Subprocess, alloc: Allocator) !struct { - read: Pty.Fd, - write: Pty.Fd, - } { - assert(self.pty == null and self.command == null); - - // Create our pty - var pty = try Pty.open(.{ - .ws_row = @intCast(self.grid_size.rows), - .ws_col = @intCast(self.grid_size.columns), - .ws_xpixel = @intCast(self.screen_size.width), - .ws_ypixel = @intCast(self.screen_size.height), - }); - self.pty = pty; - errdefer { - pty.deinit(); - self.pty = null; - } - - log.debug("starting command command={s}", .{self.args}); - - // In flatpak, we use the HostCommand to execute our shell. - if (internal_os.isFlatpak()) flatpak: { - if (comptime !build_config.flatpak) { - log.warn("flatpak detected, but flatpak support not built-in", .{}); - break :flatpak; - } - - // Flatpak command must have a stable pointer. - self.flatpak_command = .{ - .argv = self.args, - .env = &self.env, - .stdin = pty.slave, - .stdout = pty.slave, - .stderr = pty.slave, - }; - var cmd = &self.flatpak_command.?; - const pid = try cmd.spawn(alloc); - errdefer killCommandFlatpak(cmd); - - log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ - self.args[0], - pid, - }); - - // Once started, we can close the pty child side. We do this after - // wait right now but that is fine too. This lets us read the - // parent and detect EOF. - _ = posix.close(pty.slave); - - return .{ - .read = pty.master, - .write = pty.master, - }; - } - - // If we can't access the cwd, then don't set any cwd and inherit. - // This is important because our cwd can be set by the shell (OSC 7) - // and we don't want to break new windows. - const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { - if (std.fs.accessAbsolute(proposed, .{})) { - break :cwd proposed; - } else |err| { - log.warn("cannot access cwd, ignoring: {}", .{err}); - break :cwd null; - } - } else null; - - // Build our subcommand - var cmd: Command = .{ - .path = self.args[0], - .args = self.args, - .env = &self.env, - .cwd = cwd, - .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, - .pre_exec = if (builtin.os.tag == .windows) null else (struct { - fn callback(cmd: *Command) void { - const sp = cmd.getData(Subprocess) orelse unreachable; - sp.childPreExec() catch |err| log.err( - "error initializing child: {}", - .{err}, - ); - } - }).callback, - .data = self, - .linux_cgroup = self.linux_cgroup, - }; - try cmd.start(alloc); - errdefer killCommand(&cmd) catch |err| { - log.warn("error killing command during cleanup err={}", .{err}); - }; - log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); - if (comptime builtin.os.tag == .linux) { - log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); - } - - self.command = cmd; - return switch (builtin.os.tag) { - .windows => .{ - .read = pty.out_pipe, - .write = pty.in_pipe, - }, - - else => .{ - .read = pty.master, - .write = pty.master, - }, - }; - } - - /// This should be called after fork but before exec in the child process. - /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before - /// exec is called; it does NOT run in the main Ghostty process. - fn childPreExec(self: *Subprocess) !void { - // Setup our pty - try self.pty.?.childPreExec(); - } - - /// Called to notify that we exited externally so we can unset our - /// running state. - pub fn externalExit(self: *Subprocess) void { - self.command = null; - } - - /// Stop the subprocess. This is safe to call anytime. This will wait - /// for the subprocess to register that it has been signalled, but not - /// for it to terminate, so it will not block. - /// This does not close the pty. - pub fn stop(self: *Subprocess) void { - // Kill our command - if (self.command) |*cmd| { - // Note: this will also wait for the command to exit, so - // DO NOT call cmd.wait - killCommand(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - self.command = null; - } - - // Kill our Flatpak command - if (FlatpakHostCommand != void) { - if (self.flatpak_command) |*cmd| { - killCommandFlatpak(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - _ = cmd.wait() catch |err| - log.err("error waiting for command to exit: {}", .{err}); - self.flatpak_command = null; - } - } - } - - /// Resize the pty subprocess. This is safe to call anytime. - pub fn resize( - self: *Subprocess, - grid_size: renderer.GridSize, - screen_size: renderer.ScreenSize, - ) !void { - self.grid_size = grid_size; - self.screen_size = screen_size; - - if (self.pty) |*pty| { - try pty.setSize(.{ - .ws_row = @intCast(grid_size.rows), - .ws_col = @intCast(grid_size.columns), - .ws_xpixel = @intCast(screen_size.width), - .ws_ypixel = @intCast(screen_size.height), - }); - } - } - - /// Kill the underlying subprocess. This sends a SIGHUP to the child - /// process. This also waits for the command to exit and will return the - /// exit code. - fn killCommand(command: *Command) !void { - if (command.pid) |pid| { - switch (builtin.os.tag) { - .windows => { - if (windows.kernel32.TerminateProcess(pid, 0) == 0) { - return windows.unexpectedError(windows.kernel32.GetLastError()); - } - - _ = try command.wait(false); - }, - - else => if (getpgid(pid)) |pgid| { - // It is possible to send a killpg between the time that - // our child process calls setsid but before or simultaneous - // to calling execve. In this case, the direct child dies - // but grandchildren survive. To work around this, we loop - // and repeatedly kill the process group until all - // descendents are well and truly dead. We will not rest - // until the entire family tree is obliterated. - while (true) { - if (c.killpg(pgid, c.SIGHUP) < 0) { - log.warn("error killing process group pgid={}", .{pgid}); - return error.KillFailed; - } - - // See Command.zig wait for why we specify WNOHANG. - // The gist is that it lets us detect when children - // are still alive without blocking so that we can - // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - if (res.pid != 0) break; - std.time.sleep(10 * std.time.ns_per_ms); - } - }, - } - } - } - - fn getpgid(pid: c.pid_t) ?c.pid_t { - // Get our process group ID. Before the child pid calls setsid - // the pgid will be ours because we forked it. Its possible that - // we may be calling this before setsid if we are killing a surface - // VERY quickly after starting it. - const my_pgid = c.getpgid(0); - - // We loop while pgid == my_pgid. The expectation if we have a valid - // pid is that setsid will eventually be called because it is the - // FIRST thing the child process does and as far as I can tell, - // setsid cannot fail. I'm sure that's not true, but I'd rather - // have a bug reported than defensively program against it now. - while (true) { - const pgid = c.getpgid(pid); - if (pgid == my_pgid) { - log.warn("pgid is our own, retrying", .{}); - std.time.sleep(10 * std.time.ns_per_ms); - continue; - } - - // Don't know why it would be zero but its not a valid pid - if (pgid == 0) return null; - - // If the pid doesn't exist then... we're done! - if (pgid == c.ESRCH) return null; - - // If we have an error we're done. - if (pgid < 0) { - log.warn("error getting pgid for kill", .{}); - return null; - } - - return pgid; - } - } - - /// Kill the underlying process started via Flatpak host command. - /// This sends a signal via the Flatpak API. - fn killCommandFlatpak(command: *FlatpakHostCommand) !void { - try command.signal(c.SIGHUP, true); - } -}; - -/// The read thread sits in a loop doing the following pseudo code: -/// -/// while (true) { blocking_read(); exit_if_eof(); process(); } -/// -/// Almost all terminal-modifying activity is from the pty read, so -/// putting this on a dedicated thread keeps performance very predictable -/// while also almost optimal. "Locking is fast, lock contention is slow." -/// and since we rarely have contention, this is fast. -/// -/// This is also empirically fast compared to putting the read into -/// an async mechanism like io_uring/epoll because the reads are generally -/// small. -/// -/// We use a basic poll syscall here because we are only monitoring two -/// fds and this is still much faster and lower overhead than any async -/// mechanism. -const ReadThread = struct { - fn threadMainPosix(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { - // Always close our end of the pipe when we exit. - defer posix.close(quit); - - // First thing, we want to set the fd to non-blocking. We do this - // so that we can try to read from the fd in a tight loop and only - // check the quit fd occasionally. - if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { - _ = posix.fcntl( - fd, - posix.F.SETFL, - flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), - ) catch |err| { - log.warn("read thread failed to set flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - }; - } else |err| { - log.warn("read thread failed to get flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - } - - // Build up the list of fds we're going to poll. We are looking - // for data on the pty and our quit notification. - var pollfds: [2]posix.pollfd = .{ - .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, - .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, - }; - - var buf: [1024]u8 = undefined; - while (true) { - // We try to read from the file descriptor as long as possible - // to maximize performance. We only check the quit fd if the - // main fd blocks. This optimizes for the realistic scenario that - // the data will eventually stop while we're trying to quit. This - // is always true because we kill the process. - while (true) { - const n = posix.read(fd, &buf) catch |err| { - switch (err) { - // This means our pty is closed. We're probably - // gracefully shutting down. - error.NotOpenForReading, - error.InputOutput, - => { - log.info("io reader exiting", .{}); - return; - }, - - // No more data, fall back to poll and check for - // exit conditions. - error.WouldBlock => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - }; - - // This happens on macOS instead of WouldBlock when the - // child process dies. To be safe, we just break the loop - // and let our poll happen. - if (n == 0) break; - - // log.info("DATA: {d}", .{n}); - @call(.always_inline, process, .{ ev, buf[0..n] }); - } - - // Wait for data. - _ = posix.poll(&pollfds, -1) catch |err| { - log.warn("poll failed on read thread, exiting early err={}", .{err}); - return; - }; - - // If our quit fd is set, we're done. - if (pollfds[1].revents & posix.POLL.IN != 0) { - log.info("read thread got quit signal", .{}); - return; - } - } - } - - fn threadMainWindows(fd: posix.fd_t, ev: *EventData, quit: posix.fd_t) void { - // Always close our end of the pipe when we exit. - defer posix.close(quit); - - var buf: [1024]u8 = undefined; - while (true) { - while (true) { - var n: windows.DWORD = 0; - if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { - const err = windows.kernel32.GetLastError(); - switch (err) { - // Check for a quit signal - .OPERATION_ABORTED => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - } - - @call(.always_inline, process, .{ ev, buf[0..n] }); - } - - var quit_bytes: windows.DWORD = 0; - if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { - const err = windows.kernel32.GetLastError(); - log.err("quit pipe reader error err={}", .{err}); - unreachable; - } - - if (quit_bytes > 0) { - log.info("read thread got quit signal", .{}); - return; - } - } - } - - fn process( - ev: *EventData, - buf: []const u8, - ) void { - // log.info("DATA: {d}", .{n}); - // log.info("DATA: {any}", .{buf[0..@intCast(usize, n)]}); - - // Whenever a character is typed, we ensure the cursor is in the - // non-blink state so it is rendered if visible. If we're under - // HEAVY read load, we don't want to send a ton of these so we - // use a timer under the covers - const now = ev.loop.now(); - if (now - ev.last_cursor_reset > 500) { - ev.last_cursor_reset = now; - _ = ev.renderer_mailbox.push(.{ - .reset_cursor_blink = {}, - }, .{ .forever = {} }); - } - - // We are modifying terminal state from here on out - ev.renderer_state.mutex.lock(); - defer ev.renderer_state.mutex.unlock(); - - // Schedule a render - ev.queueRender() catch unreachable; - - // If we have an inspector, we enter SLOW MODE because we need to - // process a byte at a time alternating between the inspector handler - // and the termio handler. This is very slow compared to our optimizations - // below but at least users only pay for it if they're using the inspector. - if (ev.renderer_state.inspector) |insp| { - for (buf, 0..) |byte, i| { - insp.recordPtyRead(buf[i .. i + 1]) catch |err| { - log.err("error recording pty read in inspector err={}", .{err}); - }; - - ev.terminal_stream.next(byte) catch |err| - log.err("error processing terminal data: {}", .{err}); - } - } else { - ev.terminal_stream.nextSlice(buf) catch |err| - log.err("error processing terminal data: {}", .{err}); - } - - // If our stream handling caused messages to be sent to the writer - // thread, then we need to wake it up so that it processes them. - if (ev.terminal_stream.handler.writer_messaged) { - ev.terminal_stream.handler.writer_messaged = false; - ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer thread err={}", .{err}); - }; - } - } -}; - -/// This is used as the handler for the terminal.Stream type. This is -/// stateful and is expected to live for the entire lifetime of the terminal. -/// It is NOT VALID to stop a stream handler, create a new one, and use that -/// unless all of the member fields are copied. -const StreamHandler = struct { - ev: *EventData, - alloc: Allocator, - grid_size: *renderer.GridSize, - terminal: *terminal.Terminal, - - /// The APC command handler maintains the APC state. APC is like - /// CSI or OSC, but it is a private escape sequence that is used - /// to send commands to the terminal emulator. This is used by - /// the kitty graphics protocol. - apc: terminal.apc.Handler = .{}, - - /// The DCS handler maintains DCS state. DCS is like CSI or OSC, - /// but requires more stateful parsing. This is used by functionality - /// such as XTGETTCAP. - dcs: terminal.dcs.Handler = .{}, - - /// This is set to true when a message was written to the writer - /// mailbox. This can be used by callers to determine if they need - /// to wake up the writer. - writer_messaged: bool = false, - - /// The default cursor state. This is used with CSI q. This is - /// set to true when we're currently in the default cursor state. - default_cursor: bool = true, - default_cursor_style: terminal.CursorStyle, - default_cursor_blink: ?bool, - default_cursor_color: ?terminal.color.RGB, - - /// Actual cursor color. This can be changed with OSC 12. - cursor_color: ?terminal.color.RGB, - - /// The default foreground and background color are those set by the user's - /// config file. These can be overridden by terminal applications using OSC - /// 10 and OSC 11, respectively. - default_foreground_color: terminal.color.RGB, - default_background_color: terminal.color.RGB, - - /// The actual foreground and background color. Normally this will be the - /// same as the default foreground and background color, unless changed by a - /// terminal application. - foreground_color: terminal.color.RGB, - background_color: terminal.color.RGB, - - /// The response to use for ENQ requests. The memory is owned by - /// whoever owns StreamHandler. - enquiry_response: []const u8, - - osc_color_report_format: configpkg.Config.OSCColorReportFormat, - - pub fn init( - alloc: Allocator, - ev: *EventData, - grid_size: *renderer.GridSize, - t: *terminal.Terminal, - config: *const DerivedConfig, - ) StreamHandler { - const default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - return .{ - .alloc = alloc, - .ev = ev, - .grid_size = grid_size, - .terminal = t, - .osc_color_report_format = config.osc_color_report_format, - .enquiry_response = config.enquiry_response, - .default_foreground_color = config.foreground.toTerminalRGB(), - .default_background_color = config.background.toTerminalRGB(), - .default_cursor_style = config.cursor_style, - .default_cursor_blink = config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = default_cursor_color, - .foreground_color = config.foreground.toTerminalRGB(), - .background_color = config.background.toTerminalRGB(), - }; - } - - pub fn deinit(self: *StreamHandler) void { - self.apc.deinit(); - self.dcs.deinit(); - } - - /// Change the configuration for this handler. - pub fn changeConfig(self: *StreamHandler, config: *DerivedConfig) void { - self.osc_color_report_format = config.osc_color_report_format; - self.enquiry_response = config.enquiry_response; - self.default_foreground_color = config.foreground.toTerminalRGB(); - self.default_background_color = config.background.toTerminalRGB(); - self.default_cursor_style = config.cursor_style; - self.default_cursor_blink = config.cursor_blink; - self.default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - // If our cursor is the default, then we update it immediately. - if (self.default_cursor) self.setCursorStyle(.default) catch |err| { - log.warn("failed to set default cursor style: {}", .{err}); - }; - } - - inline fn queueRender(self: *StreamHandler) !void { - try self.ev.queueRender(); - } - - inline fn surfaceMessageWriter( - self: *StreamHandler, - msg: apprt.surface.Message, - ) void { - // See messageWriter which has similar logic and explains why - // we may have to do this. - if (self.ev.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.surface_mailbox.push(msg, .{ .forever = {} }); - } - } - - inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { - // Try to write to the mailbox with an instant timeout. This is the - // fast path because we can queue without a lock. - if (self.ev.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { - // If we enter this conditional, the mailbox is full. We wake up - // the writer thread so that it can process messages to clear up - // space. However, the writer thread may require the renderer - // lock so we need to unlock. - self.ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer, data will be dropped err={}", .{err}); - return; - }; - - // Unlock the renderer state so the writer thread can acquire it. - // Then try to queue our message before continuing. This is a very - // slow path because we are having a lot of contention for data. - // But this only gets triggered in certain pathological cases. - // - // Note that writes themselves don't require a lock, but there - // are other messages in the writer mailbox (resize, focus) that - // could acquire the lock. This is why we have to release our lock - // here. - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.writer_mailbox.push(msg, .{ .forever = {} }); - } - - // Normally, we just flag this true to wake up the writer thread - // once per batch of data. - self.writer_messaged = true; - } - - pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - self.dcs.hook(self.alloc, dcs); - } - - pub fn dcsPut(self: *StreamHandler, byte: u8) !void { - self.dcs.put(byte); - } - - pub fn dcsUnhook(self: *StreamHandler) !void { - var cmd = self.dcs.unhook() orelse return; - defer cmd.deinit(); - - // log.warn("DCS command: {}", .{cmd}); - switch (cmd) { - .xtgettcap => |*gettcap| { - const map = comptime terminfo.ghostty.xtgettcapMap(); - while (gettcap.next()) |key| { - const response = map.get(key) orelse continue; - self.messageWriter(.{ .write_stable = response }); - } - }, - .decrqss => |decrqss| { - var response: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&response); - const writer = stream.writer(); - - // Offset the stream position to just past the response prefix. - // We will write the "payload" (if any) below. If no payload is - // written then we send an invalid DECRPSS response. - const prefix_fmt = "\x1bP{d}$r"; - const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; - stream.pos = prefix_len; - - switch (decrqss) { - // Invalid or unhandled request - .none => {}, - - .sgr => { - const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); - - // printAttributes wrote into our buffer, so adjust the stream - // position - stream.pos += buf.len; - - try writer.writeByte('m'); - }, - - .decscusr => { - const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { - .block => if (blink) 1 else 2, - .underline => if (blink) 3 else 4, - .bar => if (blink) 5 else 6, - }; - try writer.print("{d} q", .{style}); - }, - - .decstbm => { - try writer.print("{d};{d}r", .{ - self.terminal.scrolling_region.top + 1, - self.terminal.scrolling_region.bottom + 1, - }); - }, - - .decslrm => { - // We only send a valid response when left and right - // margin mode (DECLRMM) is enabled. - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try writer.print("{d};{d}s", .{ - self.terminal.scrolling_region.left + 1, - self.terminal.scrolling_region.right + 1, - }); - } - }, - } - - // Our response is valid if we have a response payload - const valid = stream.pos > prefix_len; - - // Write the terminator - try writer.writeAll("\x1b\\"); - - // Write the response prefix into the buffer - _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); - const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); - self.messageWriter(msg); - }, - } - } - - pub fn apcStart(self: *StreamHandler) !void { - self.apc.start(); - } - - pub fn apcPut(self: *StreamHandler, byte: u8) !void { - self.apc.feed(self.alloc, byte); - } - - pub fn apcEnd(self: *StreamHandler) !void { - var cmd = self.apc.end() orelse return; - defer cmd.deinit(self.alloc); - - // log.warn("APC command: {}", .{cmd}); - switch (cmd) { - .kitty => |*kitty_cmd| { - if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { - var buf: [1024]u8 = undefined; - var buf_stream = std.io.fixedBufferStream(&buf); - try resp.encode(buf_stream.writer()); - const final = buf_stream.getWritten(); - if (final.len > 2) { - // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); - self.messageWriter(try termio.Message.writeReq(self.alloc, final)); - } - } - }, - } - } - - pub fn print(self: *StreamHandler, ch: u21) !void { - try self.terminal.print(ch); - } - - pub fn printRepeat(self: *StreamHandler, count: usize) !void { - try self.terminal.printRepeat(count); - } - - pub fn bell(self: StreamHandler) !void { - _ = self; - log.info("BELL", .{}); - } - - pub fn backspace(self: *StreamHandler) !void { - self.terminal.backspace(); - } - - pub fn horizontalTab(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn linefeed(self: *StreamHandler) !void { - // Small optimization: call index instead of linefeed because they're - // identical and this avoids one layer of function call overhead. - try self.terminal.index(); - } - - pub fn carriageReturn(self: *StreamHandler) !void { - self.terminal.carriageReturn(); - } - - pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorLeft(amount); - } - - pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorRight(amount); - } - - pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorDown(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorUp(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorCol(self: *StreamHandler, col: u16) !void { - self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); - } - - pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| offset, - ); - } - - pub fn setCursorRow(self: *StreamHandler, row: u16) !void { - self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); - } - - pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| offset, - self.terminal.screen.cursor.x + 1, - ); - } - - pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { - self.terminal.setCursorPos(row, col); - } - - pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { - if (mode == .complete) { - // Whenever we erase the full display, scroll to bottom. - try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - self.terminal.eraseDisplay(mode, protected); - } - - pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { - self.terminal.eraseLine(mode, protected); - } - - pub fn deleteChars(self: *StreamHandler, count: usize) !void { - self.terminal.deleteChars(count); - } - - pub fn eraseChars(self: *StreamHandler, count: usize) !void { - self.terminal.eraseChars(count); - } - - pub fn insertLines(self: *StreamHandler, count: usize) !void { - self.terminal.insertLines(count); - } - - pub fn insertBlanks(self: *StreamHandler, count: usize) !void { - self.terminal.insertBlanks(count); - } - - pub fn deleteLines(self: *StreamHandler, count: usize) !void { - self.terminal.deleteLines(count); - } - - pub fn reverseIndex(self: *StreamHandler) !void { - self.terminal.reverseIndex(); - } - - pub fn index(self: *StreamHandler) !void { - try self.terminal.index(); - } - - pub fn nextLine(self: *StreamHandler) !void { - try self.terminal.index(); - self.terminal.carriageReturn(); - } - - pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { - self.terminal.setTopAndBottomMargin(top, bot); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try self.setLeftAndRightMargin(0, 0); - } else { - try self.saveCursor(); - } - } - - pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { - self.terminal.setLeftAndRightMargin(left, right); - } - - pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { - self.terminal.flags.modify_other_keys_2 = false; - switch (format) { - .other_keys => |v| switch (v) { - .numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - }, - else => {}, - } - } - - pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - // Get the mode value and respond. - const code: u8 = code: { - const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; - if (self.terminal.modes.get(mode)) break :code 1; - break :code 2; - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B[{s}{};{}$y", - .{ - if (ansi) "" else "?", - mode_raw, - code, - }, - ); - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { - // log.debug("save mode={}", .{mode}); - self.terminal.modes.save(mode); - } - - pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { - // For restore mode we have to restore but if we set it, we - // always have to call setMode because setting some modes have - // side effects and we want to make sure we process those. - const v = self.terminal.modes.restore(mode); - // log.debug("restore mode={} v={}", .{ mode, v }); - try self.setMode(mode, v); - } - - pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { - // Note: this function doesn't need to grab the render state or - // terminal locks because it is only called from process() which - // grabs the lock. - - // If we are setting cursor blinking, we ignore it if we have - // a default cursor blink setting set. This is a really weird - // behavior so this comment will go deep into trying to explain it. - // - // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) - // and DEC mode 12. DECSCUSR is the modern approach and has a - // way to revert to the "default" (as defined by the terminal) - // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls - // blinking and is either on or off and has no way to set a - // default. DEC mode 12 is also the more antiquated approach. - // - // The problem is that if the user specifies a desired default - // cursor blink with `cursor-style-blink`, the moment a running - // program uses DEC mode 12, the cursor blink can never be reset - // to the default without an explicit DECSCUSR. But if a program - // is using mode 12, it is by definition not using DECSCUSR. - // This makes for somewhat annoying interactions where a poorly - // (or legacy) behaved program will stop blinking, and it simply - // never restarts. - // - // To get around this, we have a special case where if the user - // specifies some explicit default cursor blink desire, we ignore - // DEC mode 12. We allow DECSCUSR to still set the cursor blink - // because programs using DECSCUSR usually are well behaved and - // reset the cursor blink to the default when they exit. - // - // To be extra safe, users can also add a manual `CSI 0 q` to - // their shell config when they render prompts to ensure the - // cursor is exactly as they request. - if (mode == .cursor_blinking and - self.default_cursor_blink != null) - { - return; - } - - // We first always set the raw mode on our mode state. - self.terminal.modes.set(mode, enabled); - - // And then some modes require additional processing. - switch (mode) { - // Just noting here that autorepeat has no effect on - // the terminal. xterm ignores this mode and so do we. - // We know about just so that we don't log that it is - // an unknown mode. - .autorepeat => {}, - - // Schedule a render since we changed colors - .reverse_colors => { - self.terminal.flags.dirty.reverse_colors = true; - try self.queueRender(); - }, - - // Origin resets cursor pos. This is called whether or not - // we're enabling or disabling origin mode and whether or - // not the value changed. - .origin => self.terminal.setCursorPos(1, 1), - - .enable_left_and_right_margin => if (!enabled) { - // When we disable left/right margin mode we need to - // reset the left/right margins. - self.terminal.scrolling_region.left = 0; - self.terminal.scrolling_region.right = self.terminal.cols - 1; - }, - - .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - // Force resize back to the window size - .enable_mode_3 => self.terminal.resize( - self.alloc, - self.grid_size.columns, - self.grid_size.rows, - ) catch |err| { - log.err("error updating terminal size: {}", .{err}); - }, - - .@"132_column" => try self.terminal.deccolm( - self.alloc, - if (enabled) .@"132_cols" else .@"80_cols", - ), - - // We need to start a timer to prevent the emulator being hung - // forever. - .synchronized_output => { - if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); - try self.queueRender(); - }, - - .linefeed => { - self.messageWriter(.{ .linefeed_mode = enabled }); - }, - - .mouse_event_x10 => { - if (enabled) { - self.terminal.flags.mouse_event = .x10; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_normal => { - if (enabled) { - self.terminal.flags.mouse_event = .normal; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_button => { - if (enabled) { - self.terminal.flags.mouse_event = .button; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_any => { - if (enabled) { - self.terminal.flags.mouse_event = .any; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - - .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, - .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, - .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, - .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, - - else => {}, - } - } - - pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { - self.terminal.flags.mouse_shift_capture = if (v) .true else .false; - } - - pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { - switch (attr) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - - else => self.terminal.setAttribute(attr) catch |err| - log.warn("error setting attribute {}: {}", .{ attr, err }), - } - } - - pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { - try self.terminal.screen.startHyperlink(uri, id); - } - - pub fn endHyperlink(self: *StreamHandler) !void { - self.terminal.screen.endHyperlink(); - } - - pub fn deviceAttributes( - self: *StreamHandler, - req: terminal.DeviceAttributeReq, - params: []const u16, - ) !void { - _ = params; - - // For the below, we quack as a VT220. We don't quack as - // a 420 because we don't support DCS sequences. - switch (req) { - .primary => self.messageWriter(.{ - .write_stable = "\x1B[?62;22c", - }), - - .secondary => self.messageWriter(.{ - .write_stable = "\x1B[>1;10;0c", - }), - - else => log.warn("unimplemented device attributes req: {}", .{req}), - } - } - - pub fn deviceStatusReport( - self: *StreamHandler, - req: terminal.device_status.Request, - ) !void { - switch (req) { - .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), - - .cursor_position => { - const pos: struct { - x: usize, - y: usize, - } = if (self.terminal.modes.get(.origin)) .{ - .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, - .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, - } else .{ - .x = self.terminal.screen.cursor.x, - .y = self.terminal.screen.cursor.y, - }; - - // Response always is at least 4 chars, so this leaves the - // remainder for the row/column as base-10 numbers. This - // will support a very large terminal. - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ - pos.y + 1, - pos.x + 1, - }); - msg.write_small.len = @intCast(resp.len); - - self.messageWriter(msg); - }, - - .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), - } - } - - pub fn setCursorStyle( - self: *StreamHandler, - style: terminal.CursorStyleReq, - ) !void { - // Assume we're setting to a non-default. - self.default_cursor = false; - - switch (style) { - .default => { - self.default_cursor = true; - self.terminal.screen.cursor.cursor_style = self.default_cursor_style; - self.terminal.modes.set( - .cursor_blinking, - self.default_cursor_blink orelse true, - ); - }, - - .blinking_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, false); - }, - - else => log.warn("unimplemented cursor style: {}", .{style}), - } - } - - pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { - self.terminal.setProtectedMode(mode); - } - - pub fn decaln(self: *StreamHandler) !void { - try self.terminal.decaln(); - } - - pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { - self.terminal.tabClear(cmd); - } - - pub fn tabSet(self: *StreamHandler) !void { - self.terminal.tabSet(); - } - - pub fn tabReset(self: *StreamHandler) !void { - self.terminal.tabReset(); - } - - pub fn saveCursor(self: *StreamHandler) !void { - self.terminal.saveCursor(); - } - - pub fn restoreCursor(self: *StreamHandler) !void { - try self.terminal.restoreCursor(); - } - - pub fn enquiry(self: *StreamHandler) !void { - log.debug("sending enquiry response={s}", .{self.enquiry_response}); - self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); - } - - pub fn scrollDown(self: *StreamHandler, count: usize) !void { - self.terminal.scrollDown(count); - } - - pub fn scrollUp(self: *StreamHandler, count: usize) !void { - self.terminal.scrollUp(count); - } - - pub fn setActiveStatusDisplay( - self: *StreamHandler, - req: terminal.StatusDisplay, - ) !void { - self.terminal.status_display = req; - } - - pub fn configureCharset( - self: *StreamHandler, - slot: terminal.CharsetSlot, - set: terminal.Charset, - ) !void { - self.terminal.configureCharset(slot, set); - } - - pub fn invokeCharset( - self: *StreamHandler, - active: terminal.CharsetActiveSlot, - slot: terminal.CharsetSlot, - single: bool, - ) !void { - self.terminal.invokeCharset(active, slot, single); - } - - pub fn fullReset( - self: *StreamHandler, - ) !void { - self.terminal.fullReset(); - try self.setMouseShape(.text); - } - - pub fn queryKittyKeyboard(self: *StreamHandler) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("querying kitty keyboard mode", .{}); - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ - self.terminal.screen.kitty_keyboard.current().int(), - }); - - self.messageWriter(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, - }); - } - - pub fn pushKittyKeyboard( - self: *StreamHandler, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("pushing kitty keyboard mode: {}", .{flags}); - self.terminal.screen.kitty_keyboard.push(flags); - } - - pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("popping kitty keyboard mode n={}", .{n}); - self.terminal.screen.kitty_keyboard.pop(@intCast(n)); - } - - pub fn setKittyKeyboard( - self: *StreamHandler, - mode: terminal.kitty.KeySetMode, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); - self.terminal.screen.kitty_keyboard.set(mode, flags); - } - - pub fn reportXtversion( - self: *StreamHandler, - ) !void { - log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); - var buf: [288]u8 = undefined; - const resp = try std.fmt.bufPrint( - &buf, - "\x1BP>|{s} {s}\x1B\\", - .{ - "ghostty", - build_config.version_string, - }, - ); - const msg = try termio.Message.writeReq(self.alloc, resp); - self.messageWriter(msg); - } - - //------------------------------------------------------------------------- - // OSC - - pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { - var buf: [256]u8 = undefined; - if (title.len >= buf.len) { - log.warn("change title requested larger than our buffer size, ignoring", .{}); - return; - } - - @memcpy(buf[0..title.len], title); - buf[title.len] = 0; - - // Mark that we've seen a title - self.ev.seen_title = true; - self.surfaceMessageWriter(.{ .set_title = buf }); - } - - pub fn setMouseShape( - self: *StreamHandler, - shape: terminal.MouseShape, - ) !void { - // Avoid changing the shape it it is already set to avoid excess - // cross-thread messaging. - if (self.terminal.mouse_shape == shape) return; - - self.terminal.mouse_shape = shape; - self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); - } - - pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { - // Note: we ignore the "kind" field and always use the standard clipboard. - // iTerm also appears to do this but other terminals seem to only allow - // certain. Let's investigate more. - - const clipboard_type: apprt.Clipboard = switch (kind) { - 'c' => .standard, - 's' => .selection, - 'p' => .primary, - else => .standard, - }; - - // Get clipboard contents - if (data.len == 1 and data[0] == '?') { - self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); - return; - } - - // Write clipboard contents - self.surfaceMessageWriter(.{ - .clipboard_write = .{ - .req = try apprt.surface.Message.WriteReq.init( - self.alloc, - data, - ), - .clipboard_type = clipboard_type, - }, - }); - } - - pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt); - self.terminal.flags.shell_redraws_prompt = redraw; - } - - pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt_continuation); - } - - pub fn promptEnd(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.input); - } - - pub fn endOfInput(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.command); - } - - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { - if (builtin.os.tag == .windows) { - log.warn("reportPwd unimplemented on windows", .{}); - return; - } - - const uri = std.Uri.parse(url) catch |e| { - log.warn("invalid url in OSC 7: {}", .{e}); - return; - }; - - if (!std.mem.eql(u8, "file", uri.scheme) and - !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) - { - log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); - return; - } - - // OSC 7 is a little sketchy because anyone can send any value from - // any host (such an SSH session). The best practice terminals follow - // is to valid the hostname to be local. - const host_valid = host_valid: { - const host_component = uri.host orelse break :host_valid false; - - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { - break :host_valid true; - } - - // Otherwise, it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const hostname = posix.gethostname(&buf) catch |err| { - log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); - break :host_valid false; - }; - - break :host_valid std.mem.eql(u8, host, hostname); - }; - if (!host_valid) { - log.warn("OSC 7 host must be local", .{}); - return; - } - - // We need to unescape the path. We first try to unescape onto - // the stack and fall back to heap allocation if we have to. - var pathBuf: [1024]u8 = undefined; - const path, const heap = path: { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const path = switch (uri.path) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // If the path doesn't have any escapes, we can use it directly. - if (std.mem.indexOfScalar(u8, path, '%') == null) - break :path .{ path, false }; - - // First try to stack-allocate - var fba = std.heap.FixedBufferAllocator.init(&pathBuf); - if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| - break :path .{ v, false } - else |_| {} - - // Fall back to heap - if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| - break :path .{ v, true } - else |_| {} - - // Fall back to using it directly... - log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); - break :path .{ path, false }; - }; - defer if (heap) self.alloc.free(path); - - log.debug("terminal pwd: {s}", .{path}); - try self.terminal.setPwd(path); - - // If we haven't seen a title, use our pwd as the title. - if (!self.ev.seen_title) { - try self.changeWindowTitle(path); - self.ev.seen_title = false; - } - } - - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - terminator: terminal.osc.Terminator, - ) !void { - if (self.osc_color_report_format == .none) return; - - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color, - .background => self.background_color, - .cursor => self.cursor_color orelse self.foreground_color, - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - }, - - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); - - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } - } - }, - .foreground => { - self.foreground_color = self.default_foreground_color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = self.default_background_color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = self.default_cursor_color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); - }, - } - } - - pub fn showDesktopNotification( - self: *StreamHandler, - title: []const u8, - body: []const u8, - ) !void { - var message = apprt.surface.Message{ .desktop_notification = undefined }; - - const title_len = @min(title.len, message.desktop_notification.title.len); - @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); - message.desktop_notification.title[title_len] = 0; - - const body_len = @min(body.len, message.desktop_notification.body.len); - @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); - message.desktop_notification.body[body_len] = 0; - - self.surfaceMessageWriter(message); - } -}; From faef40b7694acc92797411d27aef08658ad0fa86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 15:21:50 -0700 Subject: [PATCH 10/32] termio: fix up rebase --- src/termio/stream_handler.zig | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b7774250f..14081746d 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -173,19 +173,31 @@ pub const StreamHandler = struct { } pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - self.dcs.hook(self.alloc, dcs); + var cmd = self.dcs.hook(self.alloc, dcs) orelse return; + defer cmd.deinit(); + try self.dcsCommand(&cmd); } pub fn dcsPut(self: *StreamHandler, byte: u8) !void { - self.dcs.put(byte); + var cmd = self.dcs.put(byte) orelse return; + defer cmd.deinit(); + try self.dcsCommand(&cmd); } pub fn dcsUnhook(self: *StreamHandler) !void { var cmd = self.dcs.unhook() orelse return; defer cmd.deinit(); + try self.dcsCommand(&cmd); + } + fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); - switch (cmd) { + switch (cmd.*) { + .tmux => |tmux| { + // TODO: process it + log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + }, + .xtgettcap => |*gettcap| { const map = comptime terminfo.ghostty.xtgettcapMap(); while (gettcap.next()) |key| { @@ -193,6 +205,7 @@ pub const StreamHandler = struct { self.messageWriter(.{ .write_stable = response }); } }, + .decrqss => |decrqss| { var response: [128]u8 = undefined; var stream = std.io.fixedBufferStream(&response); From 7c23d613794cd4cbf18489fc9b2c6a9587666876 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 18:50:37 -0700 Subject: [PATCH 11/32] termio: rename --- src/termio/Termio.zig | 62 +++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index a8920527a..c8fdd4717 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -71,6 +71,10 @@ surface_mailbox: apprt.surface.Mailbox, /// The cached grid size whenever a resize is called. grid_size: renderer.GridSize, +/// The pointer to the read data. This is only valid while the termio thread +/// is alive. This is protected by the renderer state lock. +read_data: ?*ReadData = null, + /// The configuration for this IO that is derived from the main /// configuration. This must be exported so that we don't need to /// pass around Config pointers which makes memory management a pain. @@ -709,28 +713,40 @@ fn queueWriteExec( } } -fn readInternal( - ev: *ReadData, - buf: []const u8, -) void { - // log.info("DATA: {d}", .{n}); - // log.info("DATA: {any}", .{buf[0..@intCast(usize, n)]}); +/// Process output from the pty. This is the manual API that users can +/// call with pty data but it is also called by the read thread when using +/// an exec subprocess. +pub fn processOutput(self: *Termio, buf: []const u8) !void { + // We are modifying terminal state from here on out and we need + // the lock to grab our read data. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); - // We are modifying terminal state from here on out - ev.renderer_state.mutex.lock(); - defer ev.renderer_state.mutex.unlock(); + // If we don't have read data, we can't process it. + const rd = self.read_data orelse return error.ReadDataNull; + processOutputLocked(rd, buf); +} +/// Process output when you ahve the read data pointer. +pub fn processOutputReadData(rd: *ReadData, buf: []const u8) void { + rd.renderer_state.mutex.lock(); + defer rd.renderer_state.mutex.unlock(); + processOutputLocked(rd, buf); +} + +/// Process output from readdata but the lock is already held. +fn processOutputLocked(rd: *ReadData, buf: []const u8) void { // Schedule a render. We can call this first because we have the lock. - ev.terminal_stream.handler.queueRender() catch unreachable; + rd.terminal_stream.handler.queueRender() catch unreachable; // Whenever a character is typed, we ensure the cursor is in the // non-blink state so it is rendered if visible. If we're under // HEAVY read load, we don't want to send a ton of these so we // use a timer under the covers - const now = ev.loop.now(); - if (now - ev.last_cursor_reset > 500) { - ev.last_cursor_reset = now; - _ = ev.renderer_mailbox.push(.{ + const now = rd.loop.now(); + if (now - rd.last_cursor_reset > 500) { + rd.last_cursor_reset = now; + _ = rd.renderer_mailbox.push(.{ .reset_cursor_blink = {}, }, .{ .forever = {} }); } @@ -739,26 +755,26 @@ fn readInternal( // process a byte at a time alternating between the inspector handler // and the termio handler. This is very slow compared to our optimizations // below but at least users only pay for it if they're using the inspector. - if (ev.renderer_state.inspector) |insp| { + if (rd.renderer_state.inspector) |insp| { for (buf, 0..) |byte, i| { insp.recordPtyRead(buf[i .. i + 1]) catch |err| { log.err("error recording pty read in inspector err={}", .{err}); }; - ev.terminal_stream.next(byte) catch |err| + rd.terminal_stream.next(byte) catch |err| log.err("error processing terminal data: {}", .{err}); } } else { - ev.terminal_stream.nextSlice(buf) catch |err| + rd.terminal_stream.nextSlice(buf) catch |err| log.err("error processing terminal data: {}", .{err}); } // If our stream handling caused messages to be sent to the writer // thread, then we need to wake it up so that it processes them. - if (ev.terminal_stream.handler.writer_messaged) { - ev.terminal_stream.handler.writer_messaged = false; + if (rd.terminal_stream.handler.writer_messaged) { + rd.terminal_stream.handler.writer_messaged = false; // TODO - // ev.writer_wakeup.notify() catch |err| { + // rd.writer_wakeup.notify() catch |err| { // log.warn("failed to wake up writer thread err={}", .{err}); // }; } @@ -804,7 +820,7 @@ pub const ThreadData = struct { } }; -/// Thread local data for the reader thread. +/// The data required for the read thread. pub const ReadData = struct { /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. @@ -1022,7 +1038,7 @@ const ReadThread = struct { if (n == 0) break; // log.info("DATA: {d}", .{n}); - @call(.always_inline, readInternal, .{ ev, buf[0..n] }); + @call(.always_inline, processOutputReadData, .{ ev, buf[0..n] }); } // Wait for data. @@ -1060,7 +1076,7 @@ const ReadThread = struct { } } - @call(.always_inline, readInternal, .{ ev, buf[0..n] }); + @call(.always_inline, processOutputReadData, .{ ev, buf[0..n] }); } var quit_bytes: windows.DWORD = 0; From e30e635bed649704f960fa6ee51263679a99413e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 19:23:45 -0700 Subject: [PATCH 12/32] termio: move all subprocess logic to termio.Exec --- src/termio/Exec.zig | 1797 +++++++++++++++++++++++++++-------------- src/termio/Termio.zig | 524 +----------- src/termio/reader.zig | 53 +- 3 files changed, 1228 insertions(+), 1146 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index ba2a7034d..199eb8a9e 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -7,13 +7,16 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const xev = @import("xev"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); +const fastmem = @import("../fastmem.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); const shell_integration = @import("shell_integration.zig"); +const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; @@ -23,634 +26,1252 @@ const windows = internal_os.windows; const log = std.log.scoped(.io_exec); -/// If we build with flatpak support then we have to keep track of -/// a potential execution on the host. -const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; +/// The subprocess state for our exec reader. +subprocess: Subprocess, -const c = @cImport({ - @cInclude("errno.h"); - @cInclude("signal.h"); - @cInclude("unistd.h"); -}); - -arena: std.heap.ArenaAllocator, -cwd: ?[]const u8, -env: EnvMap, -args: [][]const u8, -grid_size: renderer.GridSize, -screen_size: renderer.ScreenSize, -pty: ?Pty = null, -command: ?Command = null, -flatpak_command: ?FlatpakHostCommand = null, -linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, - -/// Initialize the subprocess. This will NOT start it, this only sets +/// Initialize the exec state. This will NOT start it, this only sets /// up the internal state necessary to start it later. -pub fn init(gpa: Allocator, opts: termio.Options) !Exec { - // We have a lot of maybe-allocations that all share the same lifetime - // so use an arena so we don't end up in an accounting nightmare. - var arena = std.heap.ArenaAllocator.init(gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); +pub fn init( + alloc: Allocator, + opts: termio.Options, + term: *terminal.Terminal, +) !Exec { + var subprocess = try Subprocess.init(alloc, opts); + errdefer subprocess.deinit(); - // Set our env vars. For Flatpak builds running in Flatpak we don't - // inherit our environment because the login shell on the host side - // will get it. - var env = env: { - if (comptime build_config.flatpak) { - if (internal_os.isFlatpak()) { - break :env std.process.EnvMap.init(alloc); - } - } - - break :env try std.process.getEnvMap(alloc); - }; - errdefer env.deinit(); - - // If we have a resources dir then set our env var - if (opts.resources_dir) |dir| { - log.info("found Ghostty resources dir: {s}", .{dir}); - try env.put("GHOSTTY_RESOURCES_DIR", dir); - } - - // Set our TERM var. This is a bit complicated because we want to use - // the ghostty TERM value but we want to only do that if we have - // ghostty in the TERMINFO database. - // - // For now, we just look up a bundled dir but in the future we should - // also load the terminfo database and look for it. - if (opts.resources_dir) |base| { - try env.put("TERM", opts.config.term); - try env.put("COLORTERM", "truecolor"); - - // Assume that the resources directory is adjacent to the terminfo - // database - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ - std.fs.path.dirname(base) orelse unreachable, - }); - try env.put("TERMINFO", dir); - } else { - if (comptime builtin.target.isDarwin()) { - log.warn("ghostty terminfo not found, using xterm-256color", .{}); - log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); - log.warn("you're using a valid app bundle.", .{}); - } - - try env.put("TERM", "xterm-256color"); - try env.put("COLORTERM", "truecolor"); - } - - // Add our binary to the path if we can find it. - ghostty_path: { - var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { - log.warn("failed to get ghostty exe path err={}", .{err}); - break :ghostty_path; - }; - const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; - log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); - - // We always set this so that if the shell overwrites the path - // scripts still have a way to find the Ghostty binary when - // running in Ghostty. - try env.put("GHOSTTY_BIN_DIR", exe_dir); - - // Append if we have a path. We want to append so that ghostty is - // the last priority in the path. If we don't have a path set - // then we just set it to the directory of the binary. - if (env.get("PATH")) |path| { - // Verify that our path doesn't already contain this entry - var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); - while (it.next()) |entry| { - if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; - } - - try env.put( - "PATH", - try internal_os.appendEnv(alloc, path, exe_dir), - ); - } else { - try env.put("PATH", exe_dir); - } - } - - // Add the man pages from our application bundle to MANPATH. - if (comptime builtin.target.isDarwin()) { - if (opts.resources_dir) |resources_dir| man: { - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { - log.warn("error building manpath, man pages may not be available err={}", .{err}); - break :man; - }; - - if (env.get("MANPATH")) |manpath| { - // Append to the existing MANPATH. It's very unlikely that our bundle's - // resources directory already appears here so we don't spend the time - // searching for it. - try env.put( - "MANPATH", - try internal_os.appendEnv(alloc, manpath, dir), - ); - } else { - try env.put("MANPATH", dir); - } - } - } - - // Set environment variables used by some programs (such as neovim) to detect - // which terminal emulator and version they're running under. - try env.put("TERM_PROGRAM", "ghostty"); - try env.put("TERM_PROGRAM_VERSION", build_config.version_string); - - // When embedding in macOS and running via XCode, XCode injects - // a bunch of things that break our shell process. We remove those. - if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { - if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { - env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); - env.remove("__XPC_DYLD_LIBRARY_PATH"); - env.remove("DYLD_FRAMEWORK_PATH"); - env.remove("DYLD_INSERT_LIBRARIES"); - env.remove("DYLD_LIBRARY_PATH"); - env.remove("LD_LIBRARY_PATH"); - env.remove("SECURITYSESSIONID"); - env.remove("XPC_SERVICE_NAME"); - } - - // Remove this so that running `ghostty` within Ghostty works. - env.remove("GHOSTTY_MAC_APP"); - } - - // Don't leak these environment variables to child processes. - if (comptime build_config.app_runtime == .gtk) { - env.remove("GDK_DEBUG"); - env.remove("GSK_RENDERER"); - } - - // Setup our shell integration, if we can. - const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { - const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { - .windows => "cmd.exe", - else => "sh", - }; - - const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { - .none => break :shell .{ null, default_shell_command }, - .detect => null, - .bash => .bash, - .elvish => .elvish, - .fish => .fish, - .zsh => .zsh, - }; - - const dir = opts.resources_dir orelse break :shell .{ - null, - default_shell_command, - }; - - const integration = try shell_integration.setup( - alloc, - dir, - default_shell_command, - &env, - force, - opts.full_config.@"shell-integration-features", - ) orelse break :shell .{ null, default_shell_command }; - - break :shell .{ integration.shell, integration.command }; + // If we have an initial pwd requested by the subprocess, then we + // set that on the terminal now. This allows rapidly initializing + // new surfaces to use the proper pwd. + if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { + log.warn("error setting initial pwd err={}", .{err}); }; - if (integrated_shell) |shell| { - log.info( - "shell integration automatically injected shell={}", - .{shell}, - ); - } else if (opts.full_config.@"shell-integration" != .none) { - log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); - } + // Initial width/height based on subprocess + term.width_px = subprocess.screen_size.width; + term.height_px = subprocess.screen_size.height; - // Build our args list - const args = args: { - const cap = 9; // the most we'll ever use - var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); - defer args.deinit(); - - // If we're on macOS, we have to use `login(1)` to get all of - // the proper environment variables set, a login shell, and proper - // hushlogin behavior. - if (comptime builtin.target.isDarwin()) darwin: { - const passwd = internal_os.passwd.get(alloc) catch |err| { - log.warn("failed to read passwd, not using a login shell err={}", .{err}); - break :darwin; - }; - - const username = passwd.name orelse { - log.warn("failed to get username, not using a login shell", .{}); - break :darwin; - }; - - const hush = if (passwd.home) |home| hush: { - var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { - log.warn( - "failed to open home dir, not checking for hushlogin err={}", - .{err}, - ); - break :hush false; - }; - defer dir.close(); - - break :hush if (dir.access(".hushlogin", .{})) true else |_| false; - } else false; - - const cmd = try std.fmt.allocPrint( - alloc, - "exec -l {s}", - .{shell_command}, - ); - - // The reason for executing login this way is unclear. This - // comment will attempt to explain but prepare for a truly - // unhinged reality. - // - // The first major issue is that on macOS, a lot of users - // put shell configurations in ~/.bash_profile instead of - // ~/.bashrc (or equivalent for another shell). This file is only - // loaded for a login shell so macOS users expect all their terminals - // to be login shells. No other platform behaves this way and its - // totally braindead but somehow the entire dev community on - // macOS has cargo culted their way to this reality so we have to - // do it... - // - // To get a login shell, you COULD just prepend argv0 with a `-` - // but that doesn't fully work because `getlogin()` C API will - // return the wrong value, SHELL won't be set, and various - // other login behaviors that macOS users expect. - // - // The proper way is to use `login(1)`. But login(1) forces - // the working directory to change to the home directory, - // which we may not want. If we specify "-l" then we can avoid - // this behavior but now the shell isn't a login shell. - // - // There is another issue: `login(1)` only checks for ".hushlogin" - // in the working directory. This means that if we specify "-l" - // then we won't get hushlogin honored if its in the home - // directory (which is standard). To get around this, we - // check for hushlogin ourselves and if present specify the - // "-q" flag to login(1). - // - // So to get all the behaviors we want, we specify "-l" but - // execute "bash" (which is built-in to macOS). We then use - // the bash builtin "exec" to replace the process with a login - // shell ("-l" on exec) with the command we really want. - // - // We use "bash" instead of other shells that ship with macOS - // because as of macOS Sonoma, we found with a microbenchmark - // that bash can `exec` into the desired command ~2x faster - // than zsh. - // - // To figure out a lot of this logic I read the login.c - // source code in the OSS distribution Apple provides for - // macOS. - // - // Awesome. - try args.append("/usr/bin/login"); - if (hush) try args.append("-q"); - try args.append("-flp"); - - // We execute bash with "--noprofile --norc" so that it doesn't - // load startup files so that (1) our shell integration doesn't - // break and (2) user configuration doesn't mess this process - // up. - try args.append(username); - try args.append("/bin/bash"); - try args.append("--noprofile"); - try args.append("--norc"); - try args.append("-c"); - try args.append(cmd); - break :args try args.toOwnedSlice(); - } - - if (comptime builtin.os.tag == .windows) { - // We run our shell wrapped in `cmd.exe` so that we don't have - // to parse the command line ourselves if it has arguments. - - // Note we don't free any of the memory below since it is - // allocated in the arena. - const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); - const cmd = try std.fs.path.join(alloc, &[_][]const u8{ - windir, - "System32", - "cmd.exe", - }); - - try args.append(cmd); - try args.append("/C"); - } else { - // We run our shell wrapped in `/bin/sh` so that we don't have - // to parse the command line ourselves if it has arguments. - // Additionally, some environments (NixOS, I found) use /bin/sh - // to setup some environment variables that are important to - // have set. - try args.append("/bin/sh"); - if (internal_os.isFlatpak()) try args.append("-l"); - try args.append("-c"); - } - - try args.append(shell_command); - break :args try args.toOwnedSlice(); - }; - - // We have to copy the cwd because there is no guarantee that - // pointers in full_config remain valid. - const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| - try alloc.dupe(u8, cwd) - else - null; - - // If we have a cgroup, then we copy that into our arena so the - // memory remains valid when we start. - const linux_cgroup: Command.LinuxCgroup = cgroup: { - const default = Command.linux_cgroup_default; - if (comptime builtin.os.tag != .linux) break :cgroup default; - const path = opts.linux_cgroup orelse break :cgroup default; - break :cgroup try alloc.dupe(u8, path); - }; - - // Our screen size should be our padded size - const padded_size = opts.screen_size.subPadding(opts.padding); - - return .{ - .arena = arena, - .env = env, - .cwd = cwd, - .args = args, - .grid_size = opts.grid_size, - .screen_size = padded_size, - .linux_cgroup = linux_cgroup, - }; + return .{ .subprocess = subprocess }; } -/// Clean up the subprocess. This will stop the subprocess if it is started. pub fn deinit(self: *Exec) void { - self.stop(); - if (self.pty) |*pty| pty.deinit(); - self.arena.deinit(); - self.* = undefined; + self.subprocess.deinit(); } -/// Start the subprocess. If the subprocess is already started this -/// will crash. -pub fn start(self: *Exec, alloc: Allocator) !struct { - read: Pty.Fd, - write: Pty.Fd, -} { - assert(self.pty == null and self.command == null); +pub fn threadEnter( + self: *Exec, + alloc: Allocator, + io: *termio.Termio, + td: *termio.Termio.ThreadData, +) !void { + // Start our subprocess + const pty_fds = self.subprocess.start(alloc) catch |err| { + // If we specifically got this error then we are in the forked + // process and our child failed to execute. In that case + if (err != error.Termio) return err; - // Create our pty - var pty = try Pty.open(.{ - .ws_row = @intCast(self.grid_size.rows), - .ws_col = @intCast(self.grid_size.columns), - .ws_xpixel = @intCast(self.screen_size.width), - .ws_ypixel = @intCast(self.screen_size.height), - }); - self.pty = pty; - errdefer { - pty.deinit(); - self.pty = null; - } + // Output an error message about the exec faililng and exit. + // This generally should NOT happen because we always wrap + // our command execution either in login (macOS) or /bin/sh + // (Linux) which are usually guaranteed to exist. Still, we + // want to handle this scenario. + execFailedInChild() catch {}; + posix.exit(1); + }; + errdefer self.subprocess.stop(); - log.debug("starting command command={s}", .{self.args}); + // Get the pid from the subprocess + const pid = pid: { + const command = self.subprocess.command orelse return error.ProcessNotStarted; + break :pid command.pid orelse return error.ProcessNoPid; + }; - // In flatpak, we use the HostCommand to execute our shell. - if (internal_os.isFlatpak()) flatpak: { - if (comptime !build_config.flatpak) { - log.warn("flatpak detected, but flatpak support not built-in", .{}); - break :flatpak; - } + // Track our process start time for abnormal exits + const process_start = try std.time.Instant.now(); - // Flatpak command must have a stable pointer. - self.flatpak_command = .{ - .argv = self.args, - .env = &self.env, - .stdin = pty.slave, - .stdout = pty.slave, - .stderr = pty.slave, - }; - var cmd = &self.flatpak_command.?; - const pid = try cmd.spawn(alloc); - errdefer killCommandFlatpak(cmd); + // Create our pipe that we'll use to kill our read thread. + // pipe[0] is the read end, pipe[1] is the write end. + const pipe = try internal_os.pipe(); + errdefer posix.close(pipe[0]); + errdefer posix.close(pipe[1]); - log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ - self.args[0], - pid, - }); + // Setup our stream so that we can write. + var stream = xev.Stream.initFd(pty_fds.write); + errdefer stream.deinit(); - // Once started, we can close the pty child side. We do this after - // wait right now but that is fine too. This lets us read the - // parent and detect EOF. - _ = posix.close(pty.slave); + // Watcher to detect subprocess exit + var process = try xev.Process.init(pid); + errdefer process.deinit(); - return .{ - .read = pty.master, - .write = pty.master, - }; - } + // Start our read thread + const read_thread = try std.Thread.spawn( + .{}, + if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, + .{ pty_fds.read, td.read_data, pipe[0] }, + ); + read_thread.setName("io-reader") catch {}; - // If we can't access the cwd, then don't set any cwd and inherit. - // This is important because our cwd can be set by the shell (OSC 7) - // and we don't want to break new windows. - const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { - if (std.fs.accessAbsolute(proposed, .{})) { - break :cwd proposed; - } else |err| { - log.warn("cannot access cwd, ignoring: {}", .{err}); - break :cwd null; - } - } else null; + // Setup our threadata reader state to be our own + td.reader = .{ .exec = .{ + .start = process_start, + .abnormal_runtime_threshold_ms = io.config.abnormal_runtime_threshold_ms, + .wait_after_command = io.config.wait_after_command, + .write_stream = stream, + .process = process, + .read_thread = read_thread, + .read_thread_pipe = pipe[1], + .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, + } }; - // Build our subcommand - var cmd: Command = .{ - .path = self.args[0], - .args = self.args, - .env = &self.env, - .cwd = cwd, - .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, - .pre_exec = if (builtin.os.tag == .windows) null else (struct { - fn callback(cmd: *Command) void { - const sp = cmd.getData(Exec) orelse unreachable; - sp.childPreExec() catch |err| log.err( - "error initializing child: {}", - .{err}, - ); + // Start our process watcher + process.wait( + td.loop, + &td.reader.exec.process_wait_c, + termio.Termio.ThreadData, + td, + processExit, + ); +} + +pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { + assert(td.reader == .exec); + const exec = &td.reader.exec; + + if (exec.exited) self.subprocess.externalExit(); + self.subprocess.stop(); + + // Quit our read thread after exiting the subprocess so that + // we don't get stuck waiting for data to stop flowing if it is + // a particularly noisy process. + _ = posix.write(exec.read_thread_pipe, "x") catch |err| + log.warn("error writing to read thread quit pipe err={}", .{err}); + + if (comptime builtin.os.tag == .windows) { + // Interrupt the blocking read so the thread can see the quit message + if (windows.kernel32.CancelIoEx(exec.read_thread_fd, null) == 0) { + switch (windows.kernel32.GetLastError()) { + .NOT_FOUND => {}, + else => |err| log.warn("error interrupting read thread err={}", .{err}), } - }).callback, - .data = self, - .linux_cgroup = self.linux_cgroup, - }; - try cmd.start(alloc); - errdefer killCommand(&cmd) catch |err| { - log.warn("error killing command during cleanup err={}", .{err}); - }; - log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); - if (comptime builtin.os.tag == .linux) { - log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); - } - - self.command = cmd; - return switch (builtin.os.tag) { - .windows => .{ - .read = pty.out_pipe, - .write = pty.in_pipe, - }, - - else => .{ - .read = pty.master, - .write = pty.master, - }, - }; -} - -/// This should be called after fork but before exec in the child process. -/// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before -/// exec is called; it does NOT run in the main Ghostty process. -fn childPreExec(self: *Exec) !void { - // Setup our pty - try self.pty.?.childPreExec(); -} - -/// Called to notify that we exited externally so we can unset our -/// running state. -pub fn externalExit(self: *Exec) void { - self.command = null; -} - -/// Stop the subprocess. This is safe to call anytime. This will wait -/// for the subprocess to register that it has been signalled, but not -/// for it to terminate, so it will not block. -/// This does not close the pty. -pub fn stop(self: *Exec) void { - // Kill our command - if (self.command) |*cmd| { - // Note: this will also wait for the command to exit, so - // DO NOT call cmd.wait - killCommand(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - self.command = null; - } - - // Kill our Flatpak command - if (FlatpakHostCommand != void) { - if (self.flatpak_command) |*cmd| { - killCommandFlatpak(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - _ = cmd.wait() catch |err| - log.err("error waiting for command to exit: {}", .{err}); - self.flatpak_command = null; } } + + exec.read_thread.join(); } -/// Resize the pty subprocess. This is safe to call anytime. pub fn resize( self: *Exec, grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, ) !void { - self.grid_size = grid_size; - self.screen_size = screen_size; + return try self.subprocess.resize(grid_size, screen_size); +} - if (self.pty) |*pty| { - try pty.setSize(.{ - .ws_row = @intCast(grid_size.rows), - .ws_col = @intCast(grid_size.columns), - .ws_xpixel = @intCast(screen_size.width), - .ws_ypixel = @intCast(screen_size.height), - }); +/// Called when the child process exited abnormally but before the surface +/// is notified. +pub fn childExitedAbnormally( + self: *Exec, + gpa: Allocator, + t: *terminal.Terminal, + exit_code: u32, + runtime_ms: u64, +) !void { + var arena = ArenaAllocator.init(gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", self.subprocess.args); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + +/// This outputs an error message when exec failed and we are the +/// child process. This returns so the caller should probably exit +/// after calling this. +/// +/// Note that this usually is only called under very very rare +/// circumstances because we wrap our command execution in login +/// (macOS) or /bin/sh (Linux). So this output can be pretty crude +/// because it should never happen. Notably, this is not the error +/// users see when `command` is invalid. +fn execFailedInChild() !void { + const stderr = std.io.getStdErr().writer(); + try stderr.writeAll("exec failed\n"); + try stderr.writeAll("press any key to exit\n"); + + var buf: [1]u8 = undefined; + var reader = std.io.getStdIn().reader(); + _ = try reader.read(&buf); +} + +fn processExit( + td_: ?*termio.Termio.ThreadData, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Process.WaitError!u32, +) xev.CallbackAction { + const exit_code = r catch unreachable; + + const td = td_.?; + assert(td.reader == .exec); + const execdata = &td.reader.exec; + execdata.exited = true; + + // Determine how long the process was running for. + const runtime_ms: ?u64 = runtime: { + const process_end = std.time.Instant.now() catch break :runtime null; + const runtime_ns = process_end.since(execdata.start); + const runtime_ms = runtime_ns / std.time.ns_per_ms; + break :runtime runtime_ms; + }; + log.debug( + "child process exited status={} runtime={}ms", + .{ exit_code, runtime_ms orelse 0 }, + ); + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (runtime_ms) |runtime| runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime !builtin.target.isDarwin()) { + // If our exit code is zero, then the command was successful + // and we don't ever consider it abnormal. + if (exit_code == 0) break :runtime; + } + + // Our runtime always has to be under the threshold to be + // considered abnormal. This is because a user can always + // manually do something like `exit 1` in their shell to + // force the exit code to be non-zero. We only want to detect + // abnormal exits that happen so quickly the user can't react. + if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime; + log.warn("abnormal process exit detected, showing error message", .{}); + + // Notify our main writer thread which has access to more + // information so it can show a better error message. + _ = td.writer_mailbox.push(.{ + .child_exited_abnormally = .{ + .exit_code = exit_code, + .runtime_ms = runtime, + }, + }, .{ .forever = {} }); + td.writer_wakeup.notify() catch break :runtime; + + return .disarm; + } + + // If we're purposely waiting then we just return since the process + // exited flag is set to true. This allows the terminal window to remain + // open. + if (execdata.wait_after_command) { + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. + terminal: { + td.renderer_state.mutex.lock(); + defer td.renderer_state.mutex.unlock(); + const t = td.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + + return .disarm; + } + + // Notify our surface we want to close + _ = td.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + + return .disarm; +} + +pub fn queueWrite( + self: *Exec, + alloc: Allocator, + td: *termio.Termio.ThreadData, + data: []const u8, + linefeed: bool, +) !void { + _ = self; + const exec = &td.reader.exec; + + // If our process is exited then we send our surface a message + // about it but we don't queue any more writes. + if (exec.exited) { + _ = td.surface_mailbox.push(.{ + .child_exited = {}, + }, .{ .forever = {} }); + return; + } + + // We go through and chunk the data if necessary to fit into + // our cached buffers that we can queue to the stream. + var i: usize = 0; + while (i < data.len) { + const req = try exec.write_req_pool.getGrow(alloc); + const buf = try exec.write_buf_pool.getGrow(alloc); + const slice = slice: { + // The maximum end index is either the end of our data or + // the end of our buffer, whichever is smaller. + const max = @min(data.len, i + buf.len); + + // Fast + if (!linefeed) { + fastmem.copy(u8, buf, data[i..max]); + const len = max - i; + i = max; + break :slice buf[0..len]; + } + + // Slow, have to replace \r with \r\n + var buf_i: usize = 0; + while (i < data.len and buf_i < buf.len - 1) { + const ch = data[i]; + i += 1; + + if (ch != '\r') { + buf[buf_i] = ch; + buf_i += 1; + continue; + } + + // CRLF + buf[buf_i] = '\r'; + buf[buf_i + 1] = '\n'; + buf_i += 2; + } + + break :slice buf[0..buf_i]; + }; + + //for (slice) |b| log.warn("write: {x}", .{b}); + + exec.write_stream.queueWrite( + td.loop, + &exec.write_queue, + req, + .{ .slice = slice }, + termio.Exec.ThreadData, + exec, + ttyWrite, + ); } } -/// Kill the underlying subprocess. This sends a SIGHUP to the child -/// process. This also waits for the command to exit and will return the -/// exit code. -fn killCommand(command: *Command) !void { - if (command.pid) |pid| { - switch (builtin.os.tag) { - .windows => { - if (windows.kernel32.TerminateProcess(pid, 0) == 0) { - return windows.unexpectedError(windows.kernel32.GetLastError()); +fn ttyWrite( + td_: ?*ThreadData, + _: *xev.Loop, + _: *xev.Completion, + _: xev.Stream, + _: xev.WriteBuffer, + r: xev.Stream.WriteError!usize, +) xev.CallbackAction { + const td = td_.?; + td.write_req_pool.put(); + td.write_buf_pool.put(); + + const d = r catch |err| { + log.err("write error: {}", .{err}); + return .disarm; + }; + _ = d; + //log.info("WROTE: {d}", .{d}); + + return .disarm; +} + +/// The thread local data for the exec implementation. +pub const ThreadData = struct { + // The preallocation size for the write request pool. This should be big + // enough to satisfy most write requests. It must be a power of 2. + const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); + + /// Process start time and boolean of whether its already exited. + start: std.time.Instant, + exited: bool = false, + + /// The number of milliseconds below which we consider a process + /// exit to be abnormal. This is used to show an error message + /// when the process exits too quickly. + abnormal_runtime_threshold_ms: u32, + + /// If true, do not immediately send a child exited message to the + /// surface to close the surface when the command exits. If this is + /// false we'll show a process exited message and wait for user input + /// to close the surface. + wait_after_command: bool, + + /// The data stream is the main IO for the pty. + write_stream: xev.Stream, + + /// The process watcher + process: xev.Process, + + /// This is the pool of available (unused) write requests. If you grab + /// one from the pool, you must put it back when you're done! + write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, + + /// The pool of available buffers for writing to the pty. + write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, + + /// The write queue for the data stream. + write_queue: xev.Stream.WriteQueue = .{}, + + /// This is used for both waiting for the process to exit and then + /// subsequently to wait for the data_stream to close. + process_wait_c: xev.Completion = .{}, + + /// Reader thread state + read_thread: std.Thread, + read_thread_pipe: posix.fd_t, + read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, + + pub fn deinit(self: *ThreadData, alloc: Allocator) void { + posix.close(self.read_thread_pipe); + + // Clear our write pools. We know we aren't ever going to do + // any more IO since we stop our data stream below so we can just + // drop this. + self.write_req_pool.deinit(alloc); + self.write_buf_pool.deinit(alloc); + + // Stop our process watcher + self.process.deinit(); + + // Stop our write stream + self.write_stream.deinit(); + } +}; + +const Subprocess = struct { + /// If we build with flatpak support then we have to keep track of + /// a potential execution on the host. + const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; + + const c = @cImport({ + @cInclude("errno.h"); + @cInclude("signal.h"); + @cInclude("unistd.h"); + }); + + arena: std.heap.ArenaAllocator, + cwd: ?[]const u8, + env: EnvMap, + args: [][]const u8, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + pty: ?Pty = null, + command: ?Command = null, + flatpak_command: ?FlatpakHostCommand = null, + linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, + + /// Initialize the subprocess. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { + // We have a lot of maybe-allocations that all share the same lifetime + // so use an arena so we don't end up in an accounting nightmare. + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Set our env vars. For Flatpak builds running in Flatpak we don't + // inherit our environment because the login shell on the host side + // will get it. + var env = env: { + if (comptime build_config.flatpak) { + if (internal_os.isFlatpak()) { + break :env std.process.EnvMap.init(alloc); + } + } + + break :env try std.process.getEnvMap(alloc); + }; + errdefer env.deinit(); + + // If we have a resources dir then set our env var + if (opts.resources_dir) |dir| { + log.info("found Ghostty resources dir: {s}", .{dir}); + try env.put("GHOSTTY_RESOURCES_DIR", dir); + } + + // Set our TERM var. This is a bit complicated because we want to use + // the ghostty TERM value but we want to only do that if we have + // ghostty in the TERMINFO database. + // + // For now, we just look up a bundled dir but in the future we should + // also load the terminfo database and look for it. + if (opts.resources_dir) |base| { + try env.put("TERM", opts.config.term); + try env.put("COLORTERM", "truecolor"); + + // Assume that the resources directory is adjacent to the terminfo + // database + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{ + std.fs.path.dirname(base) orelse unreachable, + }); + try env.put("TERMINFO", dir); + } else { + if (comptime builtin.target.isDarwin()) { + log.warn("ghostty terminfo not found, using xterm-256color", .{}); + log.warn("the terminfo SHOULD exist on macos, please ensure", .{}); + log.warn("you're using a valid app bundle.", .{}); + } + + try env.put("TERM", "xterm-256color"); + try env.put("COLORTERM", "truecolor"); + } + + // Add our binary to the path if we can find it. + ghostty_path: { + var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { + log.warn("failed to get ghostty exe path err={}", .{err}); + break :ghostty_path; + }; + const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; + log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); + + // We always set this so that if the shell overwrites the path + // scripts still have a way to find the Ghostty binary when + // running in Ghostty. + try env.put("GHOSTTY_BIN_DIR", exe_dir); + + // Append if we have a path. We want to append so that ghostty is + // the last priority in the path. If we don't have a path set + // then we just set it to the directory of the binary. + if (env.get("PATH")) |path| { + // Verify that our path doesn't already contain this entry + var it = std.mem.tokenizeScalar(u8, path, internal_os.PATH_SEP[0]); + while (it.next()) |entry| { + if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path; } - _ = try command.wait(false); + try env.put( + "PATH", + try internal_os.appendEnv(alloc, path, exe_dir), + ); + } else { + try env.put("PATH", exe_dir); + } + } + + // Add the man pages from our application bundle to MANPATH. + if (comptime builtin.target.isDarwin()) { + if (opts.resources_dir) |resources_dir| man: { + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { + log.warn("error building manpath, man pages may not be available err={}", .{err}); + break :man; + }; + + if (env.get("MANPATH")) |manpath| { + // Append to the existing MANPATH. It's very unlikely that our bundle's + // resources directory already appears here so we don't spend the time + // searching for it. + try env.put( + "MANPATH", + try internal_os.appendEnv(alloc, manpath, dir), + ); + } else { + try env.put("MANPATH", dir); + } + } + } + + // Set environment variables used by some programs (such as neovim) to detect + // which terminal emulator and version they're running under. + try env.put("TERM_PROGRAM", "ghostty"); + try env.put("TERM_PROGRAM_VERSION", build_config.version_string); + + // When embedding in macOS and running via XCode, XCode injects + // a bunch of things that break our shell process. We remove those. + if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) { + if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { + env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); + env.remove("__XPC_DYLD_LIBRARY_PATH"); + env.remove("DYLD_FRAMEWORK_PATH"); + env.remove("DYLD_INSERT_LIBRARIES"); + env.remove("DYLD_LIBRARY_PATH"); + env.remove("LD_LIBRARY_PATH"); + env.remove("SECURITYSESSIONID"); + env.remove("XPC_SERVICE_NAME"); + } + + // Remove this so that running `ghostty` within Ghostty works. + env.remove("GHOSTTY_MAC_APP"); + } + + // Don't leak these environment variables to child processes. + if (comptime build_config.app_runtime == .gtk) { + env.remove("GDK_DEBUG"); + env.remove("GSK_RENDERER"); + } + + // Setup our shell integration, if we can. + const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { + const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { + .windows => "cmd.exe", + else => "sh", + }; + + const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + .none => break :shell .{ null, default_shell_command }, + .detect => null, + .bash => .bash, + .elvish => .elvish, + .fish => .fish, + .zsh => .zsh, + }; + + const dir = opts.resources_dir orelse break :shell .{ + null, + default_shell_command, + }; + + const integration = try shell_integration.setup( + alloc, + dir, + default_shell_command, + &env, + force, + opts.full_config.@"shell-integration-features", + ) orelse break :shell .{ null, default_shell_command }; + + break :shell .{ integration.shell, integration.command }; + }; + + if (integrated_shell) |shell| { + log.info( + "shell integration automatically injected shell={}", + .{shell}, + ); + } else if (opts.full_config.@"shell-integration" != .none) { + log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); + } + + // Build our args list + const args = args: { + const cap = 9; // the most we'll ever use + var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); + defer args.deinit(); + + // If we're on macOS, we have to use `login(1)` to get all of + // the proper environment variables set, a login shell, and proper + // hushlogin behavior. + if (comptime builtin.target.isDarwin()) darwin: { + const passwd = internal_os.passwd.get(alloc) catch |err| { + log.warn("failed to read passwd, not using a login shell err={}", .{err}); + break :darwin; + }; + + const username = passwd.name orelse { + log.warn("failed to get username, not using a login shell", .{}); + break :darwin; + }; + + const hush = if (passwd.home) |home| hush: { + var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { + log.warn( + "failed to open home dir, not checking for hushlogin err={}", + .{err}, + ); + break :hush false; + }; + defer dir.close(); + + break :hush if (dir.access(".hushlogin", .{})) true else |_| false; + } else false; + + const cmd = try std.fmt.allocPrint( + alloc, + "exec -l {s}", + .{shell_command}, + ); + + // The reason for executing login this way is unclear. This + // comment will attempt to explain but prepare for a truly + // unhinged reality. + // + // The first major issue is that on macOS, a lot of users + // put shell configurations in ~/.bash_profile instead of + // ~/.bashrc (or equivalent for another shell). This file is only + // loaded for a login shell so macOS users expect all their terminals + // to be login shells. No other platform behaves this way and its + // totally braindead but somehow the entire dev community on + // macOS has cargo culted their way to this reality so we have to + // do it... + // + // To get a login shell, you COULD just prepend argv0 with a `-` + // but that doesn't fully work because `getlogin()` C API will + // return the wrong value, SHELL won't be set, and various + // other login behaviors that macOS users expect. + // + // The proper way is to use `login(1)`. But login(1) forces + // the working directory to change to the home directory, + // which we may not want. If we specify "-l" then we can avoid + // this behavior but now the shell isn't a login shell. + // + // There is another issue: `login(1)` only checks for ".hushlogin" + // in the working directory. This means that if we specify "-l" + // then we won't get hushlogin honored if its in the home + // directory (which is standard). To get around this, we + // check for hushlogin ourselves and if present specify the + // "-q" flag to login(1). + // + // So to get all the behaviors we want, we specify "-l" but + // execute "bash" (which is built-in to macOS). We then use + // the bash builtin "exec" to replace the process with a login + // shell ("-l" on exec) with the command we really want. + // + // We use "bash" instead of other shells that ship with macOS + // because as of macOS Sonoma, we found with a microbenchmark + // that bash can `exec` into the desired command ~2x faster + // than zsh. + // + // To figure out a lot of this logic I read the login.c + // source code in the OSS distribution Apple provides for + // macOS. + // + // Awesome. + try args.append("/usr/bin/login"); + if (hush) try args.append("-q"); + try args.append("-flp"); + + // We execute bash with "--noprofile --norc" so that it doesn't + // load startup files so that (1) our shell integration doesn't + // break and (2) user configuration doesn't mess this process + // up. + try args.append(username); + try args.append("/bin/bash"); + try args.append("--noprofile"); + try args.append("--norc"); + try args.append("-c"); + try args.append(cmd); + break :args try args.toOwnedSlice(); + } + + if (comptime builtin.os.tag == .windows) { + // We run our shell wrapped in `cmd.exe` so that we don't have + // to parse the command line ourselves if it has arguments. + + // Note we don't free any of the memory below since it is + // allocated in the arena. + const windir = try std.process.getEnvVarOwned(alloc, "WINDIR"); + const cmd = try std.fs.path.join(alloc, &[_][]const u8{ + windir, + "System32", + "cmd.exe", + }); + + try args.append(cmd); + try args.append("/C"); + } else { + // We run our shell wrapped in `/bin/sh` so that we don't have + // to parse the command line ourselves if it has arguments. + // Additionally, some environments (NixOS, I found) use /bin/sh + // to setup some environment variables that are important to + // have set. + try args.append("/bin/sh"); + if (internal_os.isFlatpak()) try args.append("-l"); + try args.append("-c"); + } + + try args.append(shell_command); + break :args try args.toOwnedSlice(); + }; + + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| + try alloc.dupe(u8, cwd) + else + null; + + // If we have a cgroup, then we copy that into our arena so the + // memory remains valid when we start. + const linux_cgroup: Command.LinuxCgroup = cgroup: { + const default = Command.linux_cgroup_default; + if (comptime builtin.os.tag != .linux) break :cgroup default; + const path = opts.linux_cgroup orelse break :cgroup default; + break :cgroup try alloc.dupe(u8, path); + }; + + // Our screen size should be our padded size + const padded_size = opts.screen_size.subPadding(opts.padding); + + return .{ + .arena = arena, + .env = env, + .cwd = cwd, + .args = args, + .grid_size = opts.grid_size, + .screen_size = padded_size, + .linux_cgroup = linux_cgroup, + }; + } + + /// Clean up the subprocess. This will stop the subprocess if it is started. + pub fn deinit(self: *Subprocess) void { + self.stop(); + if (self.pty) |*pty| pty.deinit(); + self.arena.deinit(); + self.* = undefined; + } + + /// Start the subprocess. If the subprocess is already started this + /// will crash. + pub fn start(self: *Subprocess, alloc: Allocator) !struct { + read: Pty.Fd, + write: Pty.Fd, + } { + assert(self.pty == null and self.command == null); + + // Create our pty + var pty = try Pty.open(.{ + .ws_row = @intCast(self.grid_size.rows), + .ws_col = @intCast(self.grid_size.columns), + .ws_xpixel = @intCast(self.screen_size.width), + .ws_ypixel = @intCast(self.screen_size.height), + }); + self.pty = pty; + errdefer { + pty.deinit(); + self.pty = null; + } + + log.debug("starting command command={s}", .{self.args}); + + // In flatpak, we use the HostCommand to execute our shell. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but flatpak support not built-in", .{}); + break :flatpak; + } + + // Flatpak command must have a stable pointer. + self.flatpak_command = .{ + .argv = self.args, + .env = &self.env, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + var cmd = &self.flatpak_command.?; + const pid = try cmd.spawn(alloc); + errdefer killCommandFlatpak(cmd); + + log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ + self.args[0], + pid, + }); + + // Once started, we can close the pty child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = posix.close(pty.slave); + + return .{ + .read = pty.master, + .write = pty.master, + }; + } + + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { + if (std.fs.accessAbsolute(proposed, .{})) { + break :cwd proposed; + } else |err| { + log.warn("cannot access cwd, ignoring: {}", .{err}); + break :cwd null; + } + } else null; + + // Build our subcommand + var cmd: Command = .{ + .path = self.args[0], + .args = self.args, + .env = &self.env, + .cwd = cwd, + .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, + .pre_exec = if (builtin.os.tag == .windows) null else (struct { + fn callback(cmd: *Command) void { + const sp = cmd.getData(Subprocess) orelse unreachable; + sp.childPreExec() catch |err| log.err( + "error initializing child: {}", + .{err}, + ); + } + }).callback, + .data = self, + .linux_cgroup = self.linux_cgroup, + }; + try cmd.start(alloc); + errdefer killCommand(&cmd) catch |err| { + log.warn("error killing command during cleanup err={}", .{err}); + }; + log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid }); + if (comptime builtin.os.tag == .linux) { + log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); + } + + self.command = cmd; + return switch (builtin.os.tag) { + .windows => .{ + .read = pty.out_pipe, + .write = pty.in_pipe, }, - else => if (getpgid(pid)) |pgid| { - // It is possible to send a killpg between the time that - // our child process calls setsid but before or simultaneous - // to calling execve. In this case, the direct child dies - // but grandchildren survive. To work around this, we loop - // and repeatedly kill the process group until all - // descendents are well and truly dead. We will not rest - // until the entire family tree is obliterated. - while (true) { - if (c.killpg(pgid, c.SIGHUP) < 0) { - log.warn("error killing process group pgid={}", .{pgid}); - return error.KillFailed; + else => .{ + .read = pty.master, + .write = pty.master, + }, + }; + } + + /// This should be called after fork but before exec in the child process. + /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before + /// exec is called; it does NOT run in the main Ghostty process. + fn childPreExec(self: *Subprocess) !void { + // Setup our pty + try self.pty.?.childPreExec(); + } + + /// Called to notify that we exited externally so we can unset our + /// running state. + pub fn externalExit(self: *Subprocess) void { + self.command = null; + } + + /// Stop the subprocess. This is safe to call anytime. This will wait + /// for the subprocess to register that it has been signalled, but not + /// for it to terminate, so it will not block. + /// This does not close the pty. + pub fn stop(self: *Subprocess) void { + // Kill our command + if (self.command) |*cmd| { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + killCommand(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + self.command = null; + } + + // Kill our Flatpak command + if (FlatpakHostCommand != void) { + if (self.flatpak_command) |*cmd| { + killCommandFlatpak(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + _ = cmd.wait() catch |err| + log.err("error waiting for command to exit: {}", .{err}); + self.flatpak_command = null; + } + } + } + + /// Resize the pty subprocess. This is safe to call anytime. + pub fn resize( + self: *Subprocess, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + ) !void { + self.grid_size = grid_size; + self.screen_size = screen_size; + + if (self.pty) |*pty| { + try pty.setSize(.{ + .ws_row = @intCast(grid_size.rows), + .ws_col = @intCast(grid_size.columns), + .ws_xpixel = @intCast(screen_size.width), + .ws_ypixel = @intCast(screen_size.height), + }); + } + } + + /// Kill the underlying subprocess. This sends a SIGHUP to the child + /// process. This also waits for the command to exit and will return the + /// exit code. + fn killCommand(command: *Command) !void { + if (command.pid) |pid| { + switch (builtin.os.tag) { + .windows => { + if (windows.kernel32.TerminateProcess(pid, 0) == 0) { + return windows.unexpectedError(windows.kernel32.GetLastError()); } - // See Command.zig wait for why we specify WNOHANG. - // The gist is that it lets us detect when children - // are still alive without blocking so that we can - // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - if (res.pid != 0) break; - std.time.sleep(10 * std.time.ns_per_ms); + _ = try command.wait(false); + }, + + else => if (getpgid(pid)) |pgid| { + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + while (true) { + if (c.killpg(pgid, c.SIGHUP) < 0) { + log.warn("error killing process group pgid={}", .{pgid}); + return error.KillFailed; + } + + // See Command.zig wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + const res = posix.waitpid(pid, std.c.W.NOHANG); + if (res.pid != 0) break; + std.time.sleep(10 * std.time.ns_per_ms); + } + }, + } + } + } + + fn getpgid(pid: c.pid_t) ?c.pid_t { + // Get our process group ID. Before the child pid calls setsid + // the pgid will be ours because we forked it. Its possible that + // we may be calling this before setsid if we are killing a surface + // VERY quickly after starting it. + const my_pgid = c.getpgid(0); + + // We loop while pgid == my_pgid. The expectation if we have a valid + // pid is that setsid will eventually be called because it is the + // FIRST thing the child process does and as far as I can tell, + // setsid cannot fail. I'm sure that's not true, but I'd rather + // have a bug reported than defensively program against it now. + while (true) { + const pgid = c.getpgid(pid); + if (pgid == my_pgid) { + log.warn("pgid is our own, retrying", .{}); + std.time.sleep(10 * std.time.ns_per_ms); + continue; + } + + // Don't know why it would be zero but its not a valid pid + if (pgid == 0) return null; + + // If the pid doesn't exist then... we're done! + if (pgid == c.ESRCH) return null; + + // If we have an error we're done. + if (pgid < 0) { + log.warn("error getting pgid for kill", .{}); + return null; + } + + return pgid; + } + } + + /// Kill the underlying process started via Flatpak host command. + /// This sends a signal via the Flatpak API. + fn killCommandFlatpak(command: *FlatpakHostCommand) !void { + try command.signal(c.SIGHUP, true); + } +}; + +/// The read thread sits in a loop doing the following pseudo code: +/// +/// while (true) { blocking_read(); exit_if_eof(); process(); } +/// +/// Almost all terminal-modifying activity is from the pty read, so +/// putting this on a dedicated thread keeps performance very predictable +/// while also almost optimal. "Locking is fast, lock contention is slow." +/// and since we rarely have contention, this is fast. +/// +/// This is also empirically fast compared to putting the read into +/// an async mechanism like io_uring/epoll because the reads are generally +/// small. +/// +/// We use a basic poll syscall here because we are only monitoring two +/// fds and this is still much faster and lower overhead than any async +/// mechanism. +pub const ReadThread = struct { + fn threadMainPosix(fd: posix.fd_t, ev: *termio.Termio.ReadData, quit: posix.fd_t) void { + // Always close our end of the pipe when we exit. + defer posix.close(quit); + + // First thing, we want to set the fd to non-blocking. We do this + // so that we can try to read from the fd in a tight loop and only + // check the quit fd occasionally. + if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { + _ = posix.fcntl( + fd, + posix.F.SETFL, + flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), + ) catch |err| { + log.warn("read thread failed to set flags err={}", .{err}); + log.warn("this isn't a fatal error, but may cause performance issues", .{}); + }; + } else |err| { + log.warn("read thread failed to get flags err={}", .{err}); + log.warn("this isn't a fatal error, but may cause performance issues", .{}); + } + + // Build up the list of fds we're going to poll. We are looking + // for data on the pty and our quit notification. + var pollfds: [2]posix.pollfd = .{ + .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, + .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, + }; + + var buf: [1024]u8 = undefined; + while (true) { + // We try to read from the file descriptor as long as possible + // to maximize performance. We only check the quit fd if the + // main fd blocks. This optimizes for the realistic scenario that + // the data will eventually stop while we're trying to quit. This + // is always true because we kill the process. + while (true) { + const n = posix.read(fd, &buf) catch |err| { + switch (err) { + // This means our pty is closed. We're probably + // gracefully shutting down. + error.NotOpenForReading, + error.InputOutput, + => { + log.info("io reader exiting", .{}); + return; + }, + + // No more data, fall back to poll and check for + // exit conditions. + error.WouldBlock => break, + + else => { + log.err("io reader error err={}", .{err}); + unreachable; + }, + } + }; + + // This happens on macOS instead of WouldBlock when the + // child process dies. To be safe, we just break the loop + // and let our poll happen. + if (n == 0) break; + + // log.info("DATA: {d}", .{n}); + @call(.always_inline, termio.Termio.processOutputReadData, .{ ev, buf[0..n] }); + } + + // Wait for data. + _ = posix.poll(&pollfds, -1) catch |err| { + log.warn("poll failed on read thread, exiting early err={}", .{err}); + return; + }; + + // If our quit fd is set, we're done. + if (pollfds[1].revents & posix.POLL.IN != 0) { + log.info("read thread got quit signal", .{}); + return; + } + } + } + + fn threadMainWindows(fd: posix.fd_t, ev: *termio.Termio.ReadData, quit: posix.fd_t) void { + // Always close our end of the pipe when we exit. + defer posix.close(quit); + + var buf: [1024]u8 = undefined; + while (true) { + while (true) { + var n: windows.DWORD = 0; + if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { + const err = windows.kernel32.GetLastError(); + switch (err) { + // Check for a quit signal + .OPERATION_ABORTED => break, + + else => { + log.err("io reader error err={}", .{err}); + unreachable; + }, + } } - }, + + @call(.always_inline, termio.Termio.processOutputReadData, .{ ev, buf[0..n] }); + } + + var quit_bytes: windows.DWORD = 0; + if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { + const err = windows.kernel32.GetLastError(); + log.err("quit pipe reader error err={}", .{err}); + unreachable; + } + + if (quit_bytes > 0) { + log.info("read thread got quit signal", .{}); + return; + } } } -} - -fn getpgid(pid: c.pid_t) ?c.pid_t { - // Get our process group ID. Before the child pid calls setsid - // the pgid will be ours because we forked it. Its possible that - // we may be calling this before setsid if we are killing a surface - // VERY quickly after starting it. - const my_pgid = c.getpgid(0); - - // We loop while pgid == my_pgid. The expectation if we have a valid - // pid is that setsid will eventually be called because it is the - // FIRST thing the child process does and as far as I can tell, - // setsid cannot fail. I'm sure that's not true, but I'd rather - // have a bug reported than defensively program against it now. - while (true) { - const pgid = c.getpgid(pid); - if (pgid == my_pgid) { - log.warn("pgid is our own, retrying", .{}); - std.time.sleep(10 * std.time.ns_per_ms); - continue; - } - - // Don't know why it would be zero but its not a valid pid - if (pgid == 0) return null; - - // If the pid doesn't exist then... we're done! - if (pgid == c.ESRCH) return null; - - // If we have an error we're done. - if (pgid < 0) { - log.warn("error getting pgid for kill", .{}); - return null; - } - - return pgid; - } -} - -/// Kill the underlying process started via Flatpak host command. -/// This sends a signal via the Flatpak API. -fn killCommandFlatpak(command: *FlatpakHostCommand) !void { - try command.signal(c.SIGHUP, true); -} +}; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index c8fdd4717..e38d5470c 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -172,20 +172,11 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio { // Set our default cursor style term.screen.cursor.cursor_style = opts.config.cursor_style; - var subprocess = try termio.Exec.init(alloc, opts); + // Setup our reader. + // TODO: for manual, we need to set the terminal width/height + var subprocess = try termio.Exec.init(alloc, opts, &term); errdefer subprocess.deinit(); - // If we have an initial pwd requested by the subprocess, then we - // set that on the terminal now. This allows rapidly initializing - // new surfaces to use the proper pwd. - if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { - log.warn("error setting initial pwd err={}", .{err}); - }; - - // Initial width/height based on subprocess - term.width_px = subprocess.screen_size.width; - term.height_px = subprocess.screen_size.height; - return .{ .alloc = alloc, .terminal = term, @@ -208,52 +199,14 @@ pub fn deinit(self: *Termio) void { pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { const alloc = self.alloc; - // Start our subprocess - const pty_fds = self.subprocess.start(alloc) catch |err| { - // If we specifically got this error then we are in the forked - // process and our child failed to execute. In that case - if (err != error.Termio) return err; - - // Output an error message about the exec faililng and exit. - // This generally should NOT happen because we always wrap - // our command execution either in login (macOS) or /bin/sh - // (Linux) which are usually guaranteed to exist. Still, we - // want to handle this scenario. - self.execFailedInChild() catch {}; - posix.exit(1); - }; - errdefer self.subprocess.stop(); - const pid = pid: { - const command = self.subprocess.command orelse return error.ProcessNotStarted; - break :pid command.pid orelse return error.ProcessNoPid; - }; - - // Track our process start time so we know how long it was - // running for. - const process_start = try std.time.Instant.now(); - - // Create our pipe that we'll use to kill our read thread. - // pipe[0] is the read end, pipe[1] is the write end. - const pipe = try internal_os.pipe(); - errdefer posix.close(pipe[0]); - errdefer posix.close(pipe[1]); - // Setup our data that is used for callbacks var read_data_ptr = try alloc.create(ReadData); errdefer alloc.destroy(read_data_ptr); - // Setup our stream so that we can write. - var stream = xev.Stream.initFd(pty_fds.write); - errdefer stream.deinit(); - // Wakeup watcher for the writer thread. var wakeup = try xev.Async.init(); errdefer wakeup.deinit(); - // Watcher to detect subprocess exit - var process = try xev.Process.init(pid); - errdefer process.deinit(); - // Create our stream handler const handler: StreamHandler = handler: { const default_cursor_color = if (self.config.cursor_color) |col| @@ -303,15 +256,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo }; errdefer read_data_ptr.deinit(); - // Start our reader thread - const read_thread = try std.Thread.spawn( - .{}, - if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, - .{ pty_fds.read, read_data_ptr, pipe[0] }, - ); - read_thread.setName("io-reader") catch {}; - - // Return our thread data + // Setup our thread data data.* = .{ .alloc = alloc, .loop = &thread.loop, @@ -319,27 +264,14 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .surface_mailbox = self.surface_mailbox, .writer_mailbox = thread.mailbox, .writer_wakeup = thread.wakeup, - .reader = .{ .exec = .{ - .start = process_start, - .abnormal_runtime_threshold_ms = self.config.abnormal_runtime_threshold_ms, - .wait_after_command = self.config.wait_after_command, - .write_stream = stream, - .process = process, - } }, - .read_thread = read_thread, - .read_thread_pipe = pipe[1], - .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, - .read_thread_data = read_data_ptr, + .read_data = read_data_ptr, + + // Placeholder until setup below + .reader = .{ .manual = {} }, }; - // Start our process watcher - process.wait( - &thread.loop, - &data.reader.exec.process_wait_c, - ThreadData, - data, - processExit, - ); + // Setup our reader + try self.subprocess.threadEnter(alloc, self, data); } /// This outputs an error message when exec failed and we are the @@ -363,33 +295,7 @@ fn execFailedInChild(self: *Termio) !void { } pub fn threadExit(self: *Termio, data: *ThreadData) void { - // Stop our reader - switch (data.reader) { - .manual => {}, - - .exec => |exec| { - if (exec.exited) self.subprocess.externalExit(); - self.subprocess.stop(); - - // Quit our read thread after exiting the subprocess so that - // we don't get stuck waiting for data to stop flowing if it is - // a particularly noisy process. - _ = posix.write(data.read_thread_pipe, "x") catch |err| - log.warn("error writing to read thread quit pipe err={}", .{err}); - - if (comptime builtin.os.tag == .windows) { - // Interrupt the blocking read so the thread can see the quit message - if (windows.kernel32.CancelIoEx(data.read_thread_fd, null) == 0) { - switch (windows.kernel32.GetLastError()) { - .NOT_FOUND => {}, - else => |err| log.warn("error interrupting read thread err={}", .{err}), - } - } - } - - data.read_thread.join(); - }, - } + self.subprocess.threadExit(data); } /// Update the configuration. @@ -408,7 +314,7 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // Update our stream handler. The stream handler uses the same // renderer mutex so this is safe to do despite being executed // from another thread. - td.read_thread_data.terminal_stream.handler.changeConfig(&self.config); + td.read_data.terminal_stream.handler.changeConfig(&self.config); td.reader.changeConfig(&self.config); // Update the configuration that we know about. @@ -552,78 +458,10 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { /// Called when the child process exited abnormally but before /// the surface is notified. pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const alloc = arena.allocator(); - - // Build up our command for the error message - const command = try std.mem.join(alloc, " ", self.subprocess.args); - const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); - - // Modify the terminal to show our error message. This - // requires grabbing the renderer state lock. self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t = self.renderer_state.terminal; - - // No matter what move the cursor back to the column 0. - t.carriageReturn(); - - // Reset styles - try t.setAttribute(.{ .unset = {} }); - - // If there is data in the viewport, we want to scroll down - // a little bit and write a horizontal rule before writing - // our message. This lets the use see the error message the - // command may have output. - const viewport_str = try t.plainString(alloc); - if (viewport_str.len > 0) { - try t.linefeed(); - for (0..t.cols) |_| try t.print(0x2501); - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - } - - // Output our error message - try t.setAttribute(.{ .@"8_fg" = .bright_red }); - try t.setAttribute(.{ .bold = {} }); - try t.printString("Ghostty failed to launch the requested command:"); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString(command); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Runtime: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(runtime_str); - try t.setAttribute(.{ .unset = {} }); - - // We don't print this on macOS because the exit code is always 0 - // due to the way we launch the process. - if (comptime !builtin.target.isDarwin()) { - const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); - t.carriageReturn(); - try t.linefeed(); - try t.printString("Exit Code: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(exit_code_str); - try t.setAttribute(.{ .unset = {} }); - } - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Press any key to close the window."); - - // Hide the cursor - t.modes.set(.cursor_visible, false); + try self.subprocess.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); } pub inline fn queueWrite( @@ -632,85 +470,7 @@ pub inline fn queueWrite( data: []const u8, linefeed: bool, ) !void { - switch (td.reader) { - .manual => {}, - .exec => try self.queueWriteExec( - td, - data, - linefeed, - ), - } -} - -fn queueWriteExec( - self: *Termio, - td: *ThreadData, - data: []const u8, - linefeed: bool, -) !void { - const exec = &td.reader.exec; - - // If our process is exited then we send our surface a message - // about it but we don't queue any more writes. - if (exec.exited) { - _ = td.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } - - // We go through and chunk the data if necessary to fit into - // our cached buffers that we can queue to the stream. - var i: usize = 0; - while (i < data.len) { - const req = try exec.write_req_pool.getGrow(self.alloc); - const buf = try exec.write_buf_pool.getGrow(self.alloc); - const slice = slice: { - // The maximum end index is either the end of our data or - // the end of our buffer, whichever is smaller. - const max = @min(data.len, i + buf.len); - - // Fast - if (!linefeed) { - fastmem.copy(u8, buf, data[i..max]); - const len = max - i; - i = max; - break :slice buf[0..len]; - } - - // Slow, have to replace \r with \r\n - var buf_i: usize = 0; - while (i < data.len and buf_i < buf.len - 1) { - const ch = data[i]; - i += 1; - - if (ch != '\r') { - buf[buf_i] = ch; - buf_i += 1; - continue; - } - - // CRLF - buf[buf_i] = '\r'; - buf[buf_i + 1] = '\n'; - buf_i += 2; - } - - break :slice buf[0..buf_i]; - }; - - //for (slice) |b| log.warn("write: {x}", .{b}); - - exec.write_stream.queueWrite( - td.loop, - &exec.write_queue, - req, - .{ .slice = slice }, - termio.reader.ThreadData.Exec, - exec, - ttyWrite, - ); - } + try self.subprocess.queueWrite(self.alloc, td, data, linefeed); } /// Process output from the pty. This is the manual API that users can @@ -804,18 +564,12 @@ pub const ThreadData = struct { /// Data associated with the reader implementation (i.e. pty/exec state) reader: termio.reader.ThreadData, - - /// Our read thread - read_thread: std.Thread, - read_thread_pipe: posix.fd_t, - read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, - read_thread_data: *ReadData, + read_data: *ReadData, pub fn deinit(self: *ThreadData) void { - posix.close(self.read_thread_pipe); - self.read_thread_data.deinit(); self.reader.deinit(self.alloc); - self.alloc.destroy(self.read_thread_data); + self.read_data.deinit(); + self.alloc.destroy(self.read_data); self.* = undefined; } }; @@ -849,247 +603,3 @@ pub const ReadData = struct { self.terminal_stream.deinit(); } }; - -fn processExit( - td_: ?*ThreadData, - _: *xev.Loop, - _: *xev.Completion, - r: xev.Process.WaitError!u32, -) xev.CallbackAction { - const exit_code = r catch unreachable; - - const td = td_.?; - assert(td.reader == .exec); - const execdata = &td.reader.exec; - execdata.exited = true; - - // Determine how long the process was running for. - const runtime_ms: ?u64 = runtime: { - const process_end = std.time.Instant.now() catch break :runtime null; - const runtime_ns = process_end.since(execdata.start); - const runtime_ms = runtime_ns / std.time.ns_per_ms; - break :runtime runtime_ms; - }; - log.debug( - "child process exited status={} runtime={}ms", - .{ exit_code, runtime_ms orelse 0 }, - ); - - // If our runtime was below some threshold then we assume that this - // was an abnormal exit and we show an error message. - if (runtime_ms) |runtime| runtime: { - // On macOS, our exit code detection doesn't work, possibly - // because of our `login` wrapper. More investigation required. - if (comptime !builtin.target.isDarwin()) { - // If our exit code is zero, then the command was successful - // and we don't ever consider it abnormal. - if (exit_code == 0) break :runtime; - } - - // Our runtime always has to be under the threshold to be - // considered abnormal. This is because a user can always - // manually do something like `exit 1` in their shell to - // force the exit code to be non-zero. We only want to detect - // abnormal exits that happen so quickly the user can't react. - if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime; - log.warn("abnormal process exit detected, showing error message", .{}); - - // Notify our main writer thread which has access to more - // information so it can show a better error message. - _ = td.writer_mailbox.push(.{ - .child_exited_abnormally = .{ - .exit_code = exit_code, - .runtime_ms = runtime, - }, - }, .{ .forever = {} }); - td.writer_wakeup.notify() catch break :runtime; - - return .disarm; - } - - // If we're purposely waiting then we just return since the process - // exited flag is set to true. This allows the terminal window to remain - // open. - if (execdata.wait_after_command) { - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. - terminal: { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); - const t = td.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - return .disarm; - } - - // Notify our surface we want to close - _ = td.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - - return .disarm; -} - -fn ttyWrite( - td_: ?*termio.reader.ThreadData.Exec, - _: *xev.Loop, - _: *xev.Completion, - _: xev.Stream, - _: xev.WriteBuffer, - r: xev.Stream.WriteError!usize, -) xev.CallbackAction { - const td = td_.?; - td.write_req_pool.put(); - td.write_buf_pool.put(); - - const d = r catch |err| { - log.err("write error: {}", .{err}); - return .disarm; - }; - _ = d; - //log.info("WROTE: {d}", .{d}); - - return .disarm; -} - -/// The read thread sits in a loop doing the following pseudo code: -/// -/// while (true) { blocking_read(); exit_if_eof(); process(); } -/// -/// Almost all terminal-modifying activity is from the pty read, so -/// putting this on a dedicated thread keeps performance very predictable -/// while also almost optimal. "Locking is fast, lock contention is slow." -/// and since we rarely have contention, this is fast. -/// -/// This is also empirically fast compared to putting the read into -/// an async mechanism like io_uring/epoll because the reads are generally -/// small. -/// -/// We use a basic poll syscall here because we are only monitoring two -/// fds and this is still much faster and lower overhead than any async -/// mechanism. -const ReadThread = struct { - fn threadMainPosix(fd: posix.fd_t, ev: *ReadData, quit: posix.fd_t) void { - // Always close our end of the pipe when we exit. - defer posix.close(quit); - - // First thing, we want to set the fd to non-blocking. We do this - // so that we can try to read from the fd in a tight loop and only - // check the quit fd occasionally. - if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { - _ = posix.fcntl( - fd, - posix.F.SETFL, - flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), - ) catch |err| { - log.warn("read thread failed to set flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - }; - } else |err| { - log.warn("read thread failed to get flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - } - - // Build up the list of fds we're going to poll. We are looking - // for data on the pty and our quit notification. - var pollfds: [2]posix.pollfd = .{ - .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, - .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, - }; - - var buf: [1024]u8 = undefined; - while (true) { - // We try to read from the file descriptor as long as possible - // to maximize performance. We only check the quit fd if the - // main fd blocks. This optimizes for the realistic scenario that - // the data will eventually stop while we're trying to quit. This - // is always true because we kill the process. - while (true) { - const n = posix.read(fd, &buf) catch |err| { - switch (err) { - // This means our pty is closed. We're probably - // gracefully shutting down. - error.NotOpenForReading, - error.InputOutput, - => { - log.info("io reader exiting", .{}); - return; - }, - - // No more data, fall back to poll and check for - // exit conditions. - error.WouldBlock => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - }; - - // This happens on macOS instead of WouldBlock when the - // child process dies. To be safe, we just break the loop - // and let our poll happen. - if (n == 0) break; - - // log.info("DATA: {d}", .{n}); - @call(.always_inline, processOutputReadData, .{ ev, buf[0..n] }); - } - - // Wait for data. - _ = posix.poll(&pollfds, -1) catch |err| { - log.warn("poll failed on read thread, exiting early err={}", .{err}); - return; - }; - - // If our quit fd is set, we're done. - if (pollfds[1].revents & posix.POLL.IN != 0) { - log.info("read thread got quit signal", .{}); - return; - } - } - } - - fn threadMainWindows(fd: posix.fd_t, ev: *ReadData, quit: posix.fd_t) void { - // Always close our end of the pipe when we exit. - defer posix.close(quit); - - var buf: [1024]u8 = undefined; - while (true) { - while (true) { - var n: windows.DWORD = 0; - if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { - const err = windows.kernel32.GetLastError(); - switch (err) { - // Check for a quit signal - .OPERATION_ABORTED => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - } - - @call(.always_inline, processOutputReadData, .{ ev, buf[0..n] }); - } - - var quit_bytes: windows.DWORD = 0; - if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { - const err = windows.kernel32.GetLastError(); - log.err("quit pipe reader error err={}", .{err}); - unreachable; - } - - if (quit_bytes > 0) { - log.info("read thread got quit signal", .{}); - return; - } - } - } -}; diff --git a/src/termio/reader.zig b/src/termio/reader.zig index 11af2f70b..f8df5a3d7 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -44,61 +44,12 @@ pub const Config = union(enum) { /// Termio thread data. See termio.ThreadData for docs. pub const ThreadData = union(Kind) { manual: void, - exec: ThreadData.Exec, - - pub const Exec = struct { - /// Process start time and boolean of whether its already exited. - start: std.time.Instant, - exited: bool = false, - - /// The number of milliseconds below which we consider a process - /// exit to be abnormal. This is used to show an error message - /// when the process exits too quickly. - abnormal_runtime_threshold_ms: u32, - - /// If true, do not immediately send a child exited message to the - /// surface to close the surface when the command exits. If this is - /// false we'll show a process exited message and wait for user input - /// to close the surface. - wait_after_command: bool, - - /// The data stream is the main IO for the pty. - write_stream: xev.Stream, - - /// The process watcher - process: xev.Process, - - /// This is the pool of available (unused) write requests. If you grab - /// one from the pool, you must put it back when you're done! - write_req_pool: SegmentedPool(xev.Stream.WriteRequest, WRITE_REQ_PREALLOC) = .{}, - - /// The pool of available buffers for writing to the pty. - write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, - - /// The write queue for the data stream. - write_queue: xev.Stream.WriteQueue = .{}, - - /// This is used for both waiting for the process to exit and then - /// subsequently to wait for the data_stream to close. - process_wait_c: xev.Completion = .{}, - }; + exec: termio.Exec.ThreadData, pub fn deinit(self: *ThreadData, alloc: Allocator) void { switch (self.*) { .manual => {}, - .exec => |*exec| { - // Clear our write pools. We know we aren't ever going to do - // any more IO since we stop our data stream below so we can just - // drop this. - exec.write_req_pool.deinit(alloc); - exec.write_buf_pool.deinit(alloc); - - // Stop our process watcher - exec.process.deinit(); - - // Stop our write stream - exec.write_stream.deinit(); - }, + .exec => |*exec| exec.deinit(alloc), } } From eec33f10e22b1dc9bc1f7829d81fe0542dc89450 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 19:27:17 -0700 Subject: [PATCH 13/32] termio: remove more unused things --- src/termio/Termio.zig | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index e38d5470c..be7614af2 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -30,17 +30,6 @@ const shell_integration = @import("shell_integration.zig"); const log = std.log.scoped(.io_exec); -const c = @cImport({ - @cInclude("errno.h"); - @cInclude("signal.h"); - @cInclude("unistd.h"); -}); - -/// True if we should disable the kitty keyboard protocol. We have to -/// disable this on GLFW because GLFW input events don't support the -/// correct granularity of events. -const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; - /// Allocator alloc: Allocator, @@ -272,30 +261,20 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo // Setup our reader try self.subprocess.threadEnter(alloc, self, data); -} -/// This outputs an error message when exec failed and we are the -/// child process. This returns so the caller should probably exit -/// after calling this. -/// -/// Note that this usually is only called under very very rare -/// circumstances because we wrap our command execution in login -/// (macOS) or /bin/sh (Linux). So this output can be pretty crude -/// because it should never happen. Notably, this is not the error -/// users see when `command` is invalid. -fn execFailedInChild(self: *Termio) !void { - _ = self; - const stderr = std.io.getStdErr().writer(); - try stderr.writeAll("exec failed\n"); - try stderr.writeAll("press any key to exit\n"); - - var buf: [1]u8 = undefined; - var reader = std.io.getStdIn().reader(); - _ = try reader.read(&buf); + // Store our read data pointer + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.read_data = read_data_ptr; } pub fn threadExit(self: *Termio, data: *ThreadData) void { self.subprocess.threadExit(data); + + // Clear our read data pointer + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.read_data = null; } /// Update the configuration. From f50c15c35014cd8a63350e1ea43e8ee1ce44b462 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 09:40:53 -0700 Subject: [PATCH 14/32] termio: ReadData doesn't need a loop --- src/termio/Termio.zig | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index be7614af2..5eebb3c69 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -231,7 +231,6 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .renderer_state = self.renderer_state, .renderer_wakeup = self.renderer_wakeup, .renderer_mailbox = self.renderer_mailbox, - .loop = &thread.loop, .terminal_stream = .{ .handler = handler, .parser = .{ @@ -482,12 +481,19 @@ fn processOutputLocked(rd: *ReadData, buf: []const u8) void { // non-blink state so it is rendered if visible. If we're under // HEAVY read load, we don't want to send a ton of these so we // use a timer under the covers - const now = rd.loop.now(); - if (now - rd.last_cursor_reset > 500) { + if (std.time.Instant.now()) |now| cursor_reset: { + if (rd.last_cursor_reset) |last| { + if (now.since(last) <= (500 / std.time.ns_per_ms)) { + break :cursor_reset; + } + } + rd.last_cursor_reset = now; _ = rd.renderer_mailbox.push(.{ .reset_cursor_blink = {}, - }, .{ .forever = {} }); + }, .{ .instant = {} }); + } else |err| { + log.warn("failed to get current time err={}", .{err}); } // If we have an inspector, we enter SLOW MODE because we need to @@ -569,12 +575,9 @@ pub const ReadData = struct { /// The mailbox for notifying the renderer of things. renderer_mailbox: *renderer.Thread.Mailbox, - /// The event loop, - loop: *xev.Loop, - /// Last time the cursor was reset. This is used to prevent message /// flooding with cursor resets. - last_cursor_reset: i64 = 0, + last_cursor_reset: ?std.time.Instant = null, pub fn deinit(self: *ReadData) void { // Clear any StreamHandler state From 31144da8456cdb4a1d1d7765a02f38ac814765a1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 10:27:58 -0700 Subject: [PATCH 15/32] termio: Thread doesn't need to hold termio pointer --- src/Surface.zig | 4 +- src/termio/Thread.zig | 100 +++++++++++++++++++++++------------------- 2 files changed, 57 insertions(+), 47 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 94bb058b3..68ab9214a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -422,7 +422,7 @@ pub fn init( errdefer io.deinit(); // Create the IO thread - var io_thread = try termio.Thread.init(alloc, &self.io); + var io_thread = try termio.Thread.init(alloc); errdefer io_thread.deinit(); self.* = .{ @@ -483,7 +483,7 @@ pub fn init( self.io_thr = try std.Thread.spawn( .{}, termio.Thread.threadMain, - .{&self.io_thread}, + .{ &self.io_thread, &self.io }, ); self.io_thr.setName("io") catch {}; diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 97acb2acf..44d851998 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -1,5 +1,14 @@ -//! Represents the IO thread logic. The IO thread is responsible for -//! the child process and pty management. +//! 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"); @@ -58,9 +67,6 @@ sync_reset: xev.Timer, sync_reset_c: xev.Completion = .{}, sync_reset_cancel_c: xev.Completion = .{}, -/// The main termio state. -termio: *termio.Termio, - /// The mailbox that can be used to send this thread messages. Note /// this is a blocking queue so if it is full you will get errors (or block). mailbox: *Mailbox, @@ -83,7 +89,6 @@ flags: packed struct { /// is up to the caller to start the thread with the threadMain entrypoint. pub fn init( alloc: Allocator, - t: *termio.Termio, ) !Thread { // Create our event loop. var loop = try xev.Loop.init(.{}); @@ -116,7 +121,6 @@ pub fn init( .stop = stop_h, .coalesce = coalesce_h, .sync_reset = sync_reset_h, - .termio = t, .mailbox = mailbox, }; } @@ -135,9 +139,9 @@ pub fn deinit(self: *Thread) void { } /// The main entrypoint for the thread. -pub fn threadMain(self: *Thread) void { +pub fn threadMain(self: *Thread, io: *termio.Termio) void { // Call child function so we can use errors... - self.threadMain_() catch |err| { + self.threadMain_(io) catch |err| { log.warn("error in io thread err={}", .{err}); // Use an arena to simplify memory management below @@ -150,9 +154,9 @@ pub fn threadMain(self: *Thread) void { // 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. - self.termio.renderer_state.mutex.lock(); - defer self.termio.renderer_state.mutex.unlock(); - const t = self.termio.renderer_state.terminal; + 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); @@ -216,20 +220,20 @@ pub fn threadMain(self: *Thread) void { } } -fn threadMain_(self: *Thread) !void { +fn threadMain_(self: *Thread, io: *termio.Termio) !void { defer log.debug("IO thread exited", .{}); // 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 }; + 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 self.termio.threadEnter(self, &cb.data); + try io.threadEnter(self, &cb.data); defer cb.data.deinit(); - defer self.termio.threadExit(&cb.data); + defer io.threadExit(&cb.data); // Start the async handlers. self.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback); @@ -244,17 +248,24 @@ fn threadMain_(self: *Thread) !void { /// 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, data: *termio.Termio.ThreadData) !void { +fn drainMailbox( + self: *Thread, + cb: *CallbackData, +) !void { // If we're draining, we just drain the mailbox and return. if (self.flags.drain) { while (self.mailbox.pop()) |_| {} return; } + const io = cb.io; + const data = &cb.data; + // 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. @@ -267,29 +278,29 @@ fn drainMailbox(self: *Thread, data: *termio.Termio.ThreadData) !void { switch (message) { .change_config => |config| { defer config.alloc.destroy(config.ptr); - try self.termio.changeConfig(data, config.ptr); + try io.changeConfig(data, config.ptr); }, .inspector => |v| self.flags.has_inspector = v, - .resize => |v| self.handleResize(v), - .clear_screen => |v| try self.termio.clearScreen(data, v.history), - .scroll_viewport => |v| try self.termio.scrollViewport(v), - .jump_to_prompt => |v| try self.termio.jumpToPrompt(v), - .start_synchronized_output => self.startSynchronizedOutput(), + .resize => |v| self.handleResize(cb, v), + .clear_screen => |v| try io.clearScreen(data, v.history), + .scroll_viewport => |v| try io.scrollViewport(v), + .jump_to_prompt => |v| try io.jumpToPrompt(v), + .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, - .child_exited_abnormally => |v| try self.termio.childExitedAbnormally(v.exit_code, v.runtime_ms), - .write_small => |v| try self.termio.queueWrite( + .child_exited_abnormally => |v| try io.childExitedAbnormally(v.exit_code, v.runtime_ms), + .write_small => |v| try io.queueWrite( data, v.data[0..v.len], self.flags.linefeed_mode, ), - .write_stable => |v| try self.termio.queueWrite( + .write_stable => |v| try io.queueWrite( data, v, self.flags.linefeed_mode, ), .write_alloc => |v| { defer v.alloc.free(v.data); - try self.termio.queueWrite( + try io.queueWrite( data, v.data, self.flags.linefeed_mode, @@ -301,23 +312,23 @@ fn drainMailbox(self: *Thread, data: *termio.Termio.ThreadData) !void { // Trigger a redraw after we've drained so we don't waste cyces // messaging a redraw. if (redraw) { - try self.termio.renderer_wakeup.notify(); + try io.renderer_wakeup.notify(); } } -fn startSynchronizedOutput(self: *Thread) void { +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, - Thread, - self, + CallbackData, + cb, syncResetCallback, ); } -fn handleResize(self: *Thread, resize: termio.Message.Resize) void { +fn handleResize(self: *Thread, cb: *CallbackData, resize: termio.Message.Resize) void { self.coalesce_data.resize = resize; // If the timer is already active we just return. In the future we want @@ -330,14 +341,14 @@ fn handleResize(self: *Thread, resize: termio.Message.Resize) void { &self.coalesce_c, &self.coalesce_cancel_c, Coalesce.min_ms, - Thread, - self, + CallbackData, + cb, coalesceCallback, ); } fn syncResetCallback( - self_: ?*Thread, + cb_: ?*CallbackData, _: *xev.Loop, _: *xev.Completion, r: xev.Timer.RunError!void, @@ -350,13 +361,13 @@ fn syncResetCallback( }, }; - const self = self_ orelse return .disarm; - self.termio.resetSynchronizedOutput(); + const cb = cb_ orelse return .disarm; + cb.io.resetSynchronizedOutput(); return .disarm; } fn coalesceCallback( - self_: ?*Thread, + cb_: ?*CallbackData, _: *xev.Loop, _: *xev.Completion, r: xev.Timer.RunError!void, @@ -369,11 +380,11 @@ fn coalesceCallback( }, }; - const self = self_ orelse return .disarm; + const cb = cb_ orelse return .disarm; - if (self.coalesce_data.resize) |v| { - self.coalesce_data.resize = null; - self.termio.resize(v.grid_size, v.screen_size, v.padding) catch |err| { + if (cb.self.coalesce_data.resize) |v| { + cb.self.coalesce_data.resize = null; + cb.io.resize(v.grid_size, v.screen_size, v.padding) catch |err| { log.warn("error during resize err={}", .{err}); }; } @@ -392,11 +403,10 @@ fn wakeupCallback( return .rearm; }; - const cb = cb_ orelse return .rearm; - // When we wake up, we check the mailbox. Mailbox producers should // wake up our thread after publishing. - cb.self.drainMailbox(&cb.data) catch |err| + const cb = cb_ orelse return .rearm; + cb.self.drainMailbox(cb) catch |err| log.err("error draining mailbox err={}", .{err}); return .rearm; From af7adedb50dd954d94adde976b613cf9634aa3fd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 14:48:48 -0700 Subject: [PATCH 16/32] termio: writer abstraction --- src/Surface.zig | 195 +++++++++++++--------------------- src/termio.zig | 4 +- src/termio/Exec.zig | 6 +- src/termio/Options.zig | 4 + src/termio/Termio.zig | 57 +++++++--- src/termio/Thread.zig | 51 ++++----- src/termio/stream_handler.zig | 32 +----- src/termio/writer.zig | 108 +++++++++++++++++++ 8 files changed, 252 insertions(+), 205 deletions(-) create mode 100644 src/termio/writer.zig diff --git a/src/Surface.zig b/src/Surface.zig index 68ab9214a..d0d320511 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -397,12 +397,15 @@ pub fn init( errdefer render_thread.deinit(); // Start our IO implementation + var io_writer = try termio.Writer.initMailbox(alloc); + errdefer io_writer.deinit(alloc); var io = try termio.Termio.init(alloc, .{ .grid_size = grid_size, .screen_size = screen_size, .padding = padding, .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), + .writer = io_writer, .resources_dir = main.state.resources_dir, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, @@ -616,7 +619,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - _ = self.io_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); + self.io.queueMessage(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -633,7 +636,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - _ = self.io_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); + self.io.queueMessage(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -733,8 +736,7 @@ fn reportColorScheme(self: *Surface) !void { .dark => "\x1B[?997;1n", }; - _ = self.io_thread.mailbox.push(.{ .write_stable = output }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + self.io.queueMessage(.{ .write_stable = output }, .unlocked); } /// Call this when modifiers change. This is safe to call even if modifiers @@ -815,20 +817,17 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void { errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, }, - }, .{ .forever = {} }); + }, .unlocked); // With mailbox messages sent, we have to wake them up so they process it. self.queueRender() catch |err| { log.warn("failed to notify renderer of config change err={}", .{err}); }; - self.io_thread.wakeup.notify() catch |err| { - log.warn("failed to notify io thread of config change err={}", .{err}); - }; } /// Returns true if the terminal has a selection. @@ -1066,14 +1065,13 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void { ); // Notify the terminal - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .resize = .{ .grid_size = self.grid_size, .screen_size = self.screen_size, .padding = self.padding, }, - }, .{ .forever = {} }); - self.io_thread.wakeup.notify() catch {}; + }, .unlocked); // Notify the window try self.rt_surface.setCellSize(size.width, size.height); @@ -1169,14 +1167,13 @@ fn resize(self: *Surface, size: renderer.ScreenSize) !void { } // Mail the IO thread - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .resize = .{ .grid_size = self.grid_size, .screen_size = self.screen_size, .padding = self.padding, }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); } /// Called to set the preedit state for character input. Preedit is used @@ -1542,12 +1539,11 @@ pub fn keyCallback( ev.pty = copy; } - _ = self.io_thread.mailbox.push(switch (write_req) { + self.io.queueMessage(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); // If our event is any keypress that isn't a modifier and we generated // some data to send to the pty, then we move the viewport down to the @@ -1647,11 +1643,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { if (focus_event) { const seq = if (focused) "\x1b[I" else "\x1b[O"; - _ = self.io_thread.mailbox.push(.{ - .write_stable = seq, - }, .{ .forever = {} }); - - try self.io_thread.wakeup.notify(); + self.io.queueMessage(.{ .write_stable = seq }, .unlocked); } } } @@ -1786,14 +1778,10 @@ pub fn scrollCallback( break :seq if (y.delta < 0) "\x1b[A" else "\x1b[B"; }; for (0..y.delta_unsigned) |_| { - _ = self.io_thread.mailbox.push(.{ - .write_stable = seq, - }, .{ .instant = {} }); + self.io.queueMessage(.{ .write_stable = seq }, .locked); } } - // After sending all our messages we have to notify our IO thread - try self.io_thread.wakeup.notify(); return; } @@ -1995,12 +1983,10 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - _ = self.io_thread.mailbox.push(.{ - .write_small = .{ - .data = data, - .len = 6, - }, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_small = .{ + .data = data, + .len = 6, + } }, .locked); }, .utf8 => { @@ -2020,12 +2006,10 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - _ = self.io_thread.mailbox.push(.{ - .write_small = .{ - .data = data, - .len = @intCast(i), - }, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_small = .{ + .data = data, + .len = @intCast(i), + } }, .locked); }, .sgr => { @@ -2043,12 +2027,10 @@ fn mouseReport( }); // Ask our IO thread to write the data - _ = self.io_thread.mailbox.push(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_small = .{ + .data = data, + .len = @intCast(resp.len), + } }, .locked); }, .urxvt => { @@ -2062,12 +2044,10 @@ fn mouseReport( }); // Ask our IO thread to write the data - _ = self.io_thread.mailbox.push(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_small = .{ + .data = data, + .len = @intCast(resp.len), + } }, .locked); }, .sgr_pixels => { @@ -2085,17 +2065,12 @@ fn mouseReport( }); // Ask our IO thread to write the data - _ = self.io_thread.mailbox.push(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_small = .{ + .data = data, + .len = @intCast(resp.len), + } }, .locked); }, } - - // After sending all our messages we have to notify our IO thread - try self.io_thread.wakeup.notify(); } /// Returns true if the shift modifier is allowed to be captured by modifier @@ -2496,9 +2471,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - _ = self.io_thread.mailbox.push(.{ - .write_stable = arrow, - }, .{ .instant = {} }); + self.io.queueMessage(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -2508,13 +2481,9 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - _ = self.io_thread.mailbox.push(.{ - .write_stable = arrow, - }, .{ .instant = {} }); + self.io.queueMessage(.{ .write_stable = arrow }, .locked); } } - - try self.io_thread.wakeup.notify(); } /// Returns the link at the given cursor position, if any. @@ -3188,11 +3157,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.io.queueMessage(try termio.Message.writeReq( self.alloc, full_data, - ), .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + ), .unlocked); // CSI/ESC triggers a scroll. { @@ -3216,11 +3184,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.io.queueMessage(try termio.Message.writeReq( self.alloc, text, - ), .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + ), .unlocked); // Text triggers a scroll. { @@ -3250,16 +3217,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - _ = self.io_thread.mailbox.push(.{ - .write_stable = ck.normal, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); } else { - _ = self.io_thread.mailbox.push(.{ - .write_stable = ck.application, - }, .{ .forever = {} }); + self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); } - - try self.io_thread.wakeup.notify(); }, .reset => { @@ -3341,63 +3302,55 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (self.io.terminal.active_screen == .alternate) return false; } - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .clear_screen = .{ .history = true }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .scroll_to_top => { - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .scroll_viewport = .{ .top = {} }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .scroll_to_bottom => { - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .scroll_viewport = .{ .bottom = {} }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .scroll_page_up => { const rows: isize = @intCast(self.grid_size.rows); - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .scroll_viewport = .{ .delta = -1 * rows }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.grid_size.rows); - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .scroll_viewport = .{ .delta = rows }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.grid_size.rows); const delta: isize = @intFromFloat(@floor(fraction * rows)); - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .scroll_viewport = .{ .delta = delta }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .scroll_page_lines => |lines| { - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .scroll_viewport = .{ .delta = lines }, - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .jump_to_prompt => |delta| { - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .jump_to_prompt = @intCast(delta), - }, .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + }, .unlocked); }, .write_scrollback_file => write_scrollback_file: { @@ -3441,11 +3394,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; const path = try tmp_dir.dir.realpath("scrollback", &path_buf); - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.io.queueMessage(try termio.Message.writeReq( self.alloc, path, - ), .{ .forever = {} }); - try self.io_thread.wakeup.notify(); + ), .unlocked); }, .new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }), @@ -3700,16 +3652,16 @@ fn completeClipboardPaste( if (critical.bracketed) { // If we're bracketd we write the data as-is to the terminal with // the bracketed paste escape codes around it. - _ = self.io_thread.mailbox.push(.{ + self.io.queueMessage(.{ .write_stable = "\x1B[200~", - }, .{ .forever = {} }); - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + }, .unlocked); + self.io.queueMessage(try termio.Message.writeReq( self.alloc, data, - ), .{ .forever = {} }); - _ = self.io_thread.mailbox.push(.{ + ), .unlocked); + self.io.queueMessage(.{ .write_stable = "\x1B[201~", - }, .{ .forever = {} }); + }, .unlocked); } else { // If its not bracketed the input bytes are indistinguishable from // keystrokes, so we must be careful. For example, we must replace @@ -3736,13 +3688,11 @@ fn completeClipboardPaste( len += 1; } - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.io.queueMessage(try termio.Message.writeReq( self.alloc, buf[0..len], - ), .{ .forever = {} }); + ), .unlocked); } - - try self.io_thread.wakeup.notify(); } fn completeClipboardReadOSC52( @@ -3784,11 +3734,10 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.io.queueMessage(try termio.Message.writeReq( self.alloc, buf, - ), .{ .forever = {} }); - self.io_thread.wakeup.notify() catch {}; + ), .unlocked); } fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { diff --git a/src/termio.zig b/src/termio.zig index d868dfd6d..4fce4df18 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -6,13 +6,15 @@ const stream_handler = @import("termio/stream_handler.zig"); pub usingnamespace @import("termio/message.zig"); pub const reader = @import("termio/reader.zig"); +pub const writer = @import("termio/writer.zig"); pub const Exec = @import("termio/Exec.zig"); pub const Options = @import("termio/Options.zig"); pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); pub const DerivedConfig = Termio.DerivedConfig; -pub const Mailbox = Thread.Mailbox; +pub const Mailbox = writer.Mailbox; pub const StreamHandler = stream_handler.StreamHandler; +pub const Writer = writer.Writer; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 199eb8a9e..91f44a69b 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -308,13 +308,13 @@ fn processExit( // Notify our main writer thread which has access to more // information so it can show a better error message. - _ = td.writer_mailbox.push(.{ + td.writer.send(.{ .child_exited_abnormally = .{ .exit_code = exit_code, .runtime_ms = runtime, }, - }, .{ .forever = {} }); - td.writer_wakeup.notify() catch break :runtime; + }, null); + td.writer.notify(); return .disarm; } diff --git a/src/termio/Options.zig b/src/termio/Options.zig index ac12c31eb..2cb636d60 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -25,6 +25,10 @@ full_config: *const Config, /// The derived configuration for this termio implementation. config: termio.Termio.DerivedConfig, +/// The writer for the terminal. This is how messages are delivered. +/// If you're using termio.Thread this MUST be "mailbox". +writer: termio.Writer, + /// The application resources directory. resources_dir: ?[]const u8, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 5eebb3c69..a329e2880 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -60,6 +60,9 @@ surface_mailbox: apprt.surface.Mailbox, /// The cached grid size whenever a resize is called. grid_size: renderer.GridSize, +/// The writer implementation to use. +writer: termio.Writer, + /// The pointer to the read data. This is only valid while the termio thread /// is alive. This is protected by the renderer state lock. read_data: ?*ReadData = null, @@ -176,6 +179,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio { .renderer_mailbox = opts.renderer_mailbox, .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, + .writer = opts.writer, }; } @@ -183,6 +187,7 @@ pub fn deinit(self: *Termio) void { self.subprocess.deinit(); self.terminal.deinit(self.alloc); self.config.deinit(); + self.writer.deinit(self.alloc); } pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { @@ -205,8 +210,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo break :handler .{ .alloc = self.alloc, - .writer_mailbox = thread.mailbox, - .writer_wakeup = thread.wakeup, + .writer = &self.writer, .surface_mailbox = self.surface_mailbox, .renderer_state = self.renderer_state, .renderer_wakeup = self.renderer_wakeup, @@ -250,9 +254,8 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .loop = &thread.loop, .renderer_state = self.renderer_state, .surface_mailbox = self.surface_mailbox, - .writer_mailbox = thread.mailbox, - .writer_wakeup = thread.wakeup, .read_data = read_data_ptr, + .writer = &self.writer, // Placeholder until setup below .reader = .{ .manual = {} }, @@ -276,6 +279,40 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { self.read_data = null; } +/// Send a message using the writer. Depending on the writer type in +/// use this may process now or it may just enqueue and process later. +/// +/// This will also notify the writer thread to process the message. If +/// you're sending a lot of messages, it may be more efficient to use +/// the writer directly and then call notify separately. +pub fn queueMessage( + self: *Termio, + msg: termio.Message, + mutex: enum { locked, unlocked }, +) void { + self.writer.send(msg, switch (mutex) { + .locked => self.renderer_state.mutex, + .unlocked => null, + }); + self.writer.notify(); +} + +/// Queue a write directly to the pty. +/// +/// If you're using termio.Thread, this must ONLY be called from the +/// writer thread. If you're not on the thread, use queueMessage with +/// writer messages instead. +/// +/// If you're not using termio.Thread, this is not threadsafe. +pub inline fn queueWrite( + self: *Termio, + td: *ThreadData, + data: []const u8, + linefeed: bool, +) !void { + try self.subprocess.queueWrite(self.alloc, td, data, linefeed); +} + /// Update the configuration. pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !void { // The remainder of this function is modifying terminal state or @@ -442,15 +479,6 @@ pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !vo try self.subprocess.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); } -pub inline fn queueWrite( - self: *Termio, - td: *ThreadData, - data: []const u8, - linefeed: bool, -) !void { - try self.subprocess.queueWrite(self.alloc, td, data, linefeed); -} - /// Process output from the pty. This is the manual API that users can /// call with pty data but it is also called by the read thread when using /// an exec subprocess. @@ -544,12 +572,11 @@ pub const ThreadData = struct { /// Mailboxes for different threads surface_mailbox: apprt.surface.Mailbox, - writer_mailbox: *termio.Mailbox, - writer_wakeup: xev.Async, /// Data associated with the reader implementation (i.e. pty/exec state) reader: termio.reader.ThreadData, read_data: *ReadData, + writer: *termio.Writer, pub fn deinit(self: *ThreadData) void { self.reader.deinit(self.alloc); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 44d851998..f24fcf0df 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -21,11 +21,6 @@ const BlockingQueue = @import("../blocking_queue.zig").BlockingQueue; const Allocator = std.mem.Allocator; const log = std.log.scoped(.io_thread); -/// The type used for sending messages to the IO thread. For now this is -/// hardcoded with a capacity. We can make this a comptime parameter in -/// the future if we want it configurable. -pub const Mailbox = BlockingQueue(termio.Message, 64); - /// This stores the information that is coalesced. const Coalesce = struct { /// The number of milliseconds to coalesce certain messages like resize for. @@ -47,8 +42,8 @@ alloc: std.mem.Allocator, /// so that users of the loop always have an allocator. loop: xev.Loop, -/// This can be used to wake up the thread. -wakeup: xev.Async, +/// 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. @@ -67,10 +62,6 @@ sync_reset: xev.Timer, sync_reset_c: xev.Completion = .{}, sync_reset_cancel_c: xev.Completion = .{}, -/// The mailbox that can be used to send this thread messages. Note -/// this is a blocking queue so if it is full you will get errors (or block). -mailbox: *Mailbox, - 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. @@ -94,10 +85,6 @@ pub fn init( var loop = try xev.Loop.init(.{}); errdefer loop.deinit(); - // This async handle is used to "wake up" the renderer and force a render. - var wakeup_h = try xev.Async.init(); - errdefer wakeup_h.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(); @@ -110,18 +97,12 @@ pub fn init( var sync_reset_h = try xev.Timer.init(); errdefer sync_reset_h.deinit(); - // The mailbox for messaging this thread - var mailbox = try Mailbox.create(alloc); - errdefer mailbox.destroy(alloc); - return Thread{ .alloc = alloc, .loop = loop, - .wakeup = wakeup_h, .stop = stop_h, .coalesce = coalesce_h, .sync_reset = sync_reset_h, - .mailbox = mailbox, }; } @@ -131,11 +112,7 @@ pub fn deinit(self: *Thread) void { self.coalesce.deinit(); self.sync_reset.deinit(); self.stop.deinit(); - self.wakeup.deinit(); self.loop.deinit(); - - // Nothing can possibly access the mailbox anymore, destroy it. - self.mailbox.destroy(self.alloc); } /// The main entrypoint for the thread. @@ -223,6 +200,12 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { fn threadMain_(self: *Thread, io: *termio.Termio) !void { defer log.debug("IO thread exited", .{}); + // Get the writer. This must be a mailbox writer for threading. + const writer = switch (io.writer) { + .mailbox => |v| v, + // else => return error.TermioUnsupportedWriter, + }; + // 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 }; @@ -236,7 +219,7 @@ fn threadMain_(self: *Thread, io: *termio.Termio) !void { defer io.threadExit(&cb.data); // Start the async handlers. - self.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback); + writer.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback); self.stop.wait(&self.loop, &self.stop_c, CallbackData, &cb, stopCallback); // Run @@ -257,20 +240,22 @@ fn drainMailbox( self: *Thread, cb: *CallbackData, ) !void { - // If we're draining, we just drain the mailbox and return. - if (self.flags.drain) { - while (self.mailbox.pop()) |_| {} - return; - } - + // We assert when starting the thread that this is the state + const mailbox = cb.io.writer.mailbox.mailbox; 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 (self.mailbox.pop()) |message| { + while (mailbox.pop()) |message| { // If we have a message we always redraw redraw = true; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 14081746d..9a047bcfc 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -28,8 +28,7 @@ pub const StreamHandler = struct { terminal: *terminal.Terminal, /// Mailbox for data to the writer thread. - writer_mailbox: *termio.Mailbox, - writer_wakeup: xev.Async, + writer: *termio.Writer, /// Mailbox for the surface. surface_mailbox: apprt.surface.Mailbox, @@ -141,34 +140,7 @@ pub const StreamHandler = struct { } inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { - // Try to write to the mailbox with an instant timeout. This is the - // fast path because we can queue without a lock. - if (self.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { - // If we enter this conditional, the mailbox is full. We wake up - // the writer thread so that it can process messages to clear up - // space. However, the writer thread may require the renderer - // lock so we need to unlock. - self.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer, data will be dropped err={}", .{err}); - return; - }; - - // Unlock the renderer state so the writer thread can acquire it. - // Then try to queue our message before continuing. This is a very - // slow path because we are having a lot of contention for data. - // But this only gets triggered in certain pathological cases. - // - // Note that writes themselves don't require a lock, but there - // are other messages in the writer mailbox (resize, focus) that - // could acquire the lock. This is why we have to release our lock - // here. - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); - _ = self.writer_mailbox.push(msg, .{ .forever = {} }); - } - - // Normally, we just flag this true to wake up the writer thread - // once per batch of data. + self.writer.send(msg, self.renderer_state.mutex); self.writer_messaged = true; } diff --git a/src/termio/writer.zig b/src/termio/writer.zig new file mode 100644 index 000000000..a82169230 --- /dev/null +++ b/src/termio/writer.zig @@ -0,0 +1,108 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const xev = @import("xev"); +const renderer = @import("../renderer.zig"); +const termio = @import("../termio.zig"); +const BlockingQueue = @import("../blocking_queue.zig").BlockingQueue; + +const log = std.log.scoped(.io_writer); + +/// A mailbox used for storing messages that is periodically drained. +/// Typically used by a multi-threaded application. The capacity is +/// hardcoded to a value that empirically has made sense for Ghostty usage +/// but I'm open to changing it with good arguments. +pub const Mailbox = BlockingQueue(termio.Message, 64); + +/// The location to where write-related messages are sent. +pub const Writer = union(enum) { + // /// Write messages to an unbounded list backed by an allocator. + // /// This is useful for single-threaded applications where you're not + // /// afraid of running out of memory. You should be careful that you're + // /// processing this in a timely manner though since some heavy workloads + // /// will produce a LOT of messages. + // /// + // /// At the time of authoring this, the primary use case for this is + // /// testing more than anything, but it probably will have a use case + // /// in libghostty eventually. + // unbounded: std.ArrayList(termio.Message), + + /// Write messages to a SPSC mailbox for multi-threaded applications. + mailbox: struct { + mailbox: *Mailbox, + wakeup: xev.Async, + }, + + /// Init the mailbox writer. + pub fn initMailbox(alloc: Allocator) !Writer { + var mailbox = try Mailbox.create(alloc); + errdefer mailbox.destroy(alloc); + + var wakeup = try xev.Async.init(); + errdefer wakeup.deinit(); + + return .{ .mailbox = .{ .mailbox = mailbox, .wakeup = wakeup } }; + } + + pub fn deinit(self: *Writer, alloc: Allocator) void { + switch (self.*) { + .mailbox => |*v| { + v.mailbox.destroy(alloc); + v.wakeup.deinit(); + }, + } + } + + /// Sends the given message without notifying there are messages. + /// + /// If the optional mutex is given, it must already be LOCKED. If the + /// send would block, we'll unlock this mutex, resend the message, and + /// lock it again. This handles an edge case where mailboxes are full. + /// This may not apply to all writer types. + pub fn send( + self: *Writer, + msg: termio.Message, + mutex: ?*std.Thread.Mutex, + ) void { + switch (self.*) { + .mailbox => |mb| send: { + // Try to write to the mailbox with an instant timeout. This is the + // fast path because we can queue without a lock. + if (mb.mailbox.push(msg, .{ .instant = {} }) > 0) break :send; + + // If we enter this conditional, the mailbox is full. We wake up + // the writer thread so that it can process messages to clear up + // space. However, the writer thread may require the renderer + // lock so we need to unlock. + mb.wakeup.notify() catch |err| { + log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + return; + }; + + // Unlock the renderer state so the writer thread can acquire it. + // Then try to queue our message before continuing. This is a very + // slow path because we are having a lot of contention for data. + // But this only gets triggered in certain pathological cases. + // + // Note that writes themselves don't require a lock, but there + // are other messages in the writer mailbox (resize, focus) that + // could acquire the lock. This is why we have to release our lock + // here. + if (mutex) |m| m.unlock(); + defer if (mutex) |m| m.lock(); + _ = mb.mailbox.push(msg, .{ .forever = {} }); + }, + } + } + + /// Notify that there are new messages. This may be a noop depending + /// on the writer type. + pub fn notify(self: *Writer) void { + switch (self.*) { + .mailbox => |v| v.wakeup.notify() catch |err| { + log.warn("failed to notify writer, data will be dropped err={}", .{err}); + }, + } + } +}; From a848a53d26c48a0ba82992b60da247ddbafcf7db Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 15:10:05 -0700 Subject: [PATCH 17/32] termio: remove a ton of state --- src/Surface.zig | 62 ++++++++-------- src/termio/Exec.zig | 6 +- src/termio/Termio.zig | 162 ++++++++++++++++++------------------------ 3 files changed, 102 insertions(+), 128 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index d0d320511..208a1e258 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -396,10 +396,39 @@ pub fn init( ); errdefer render_thread.deinit(); + // Create the IO thread + var io_thread = try termio.Thread.init(alloc); + errdefer io_thread.deinit(); + + self.* = .{ + .alloc = alloc, + .app = app, + .rt_app = rt_app, + .rt_surface = rt_surface, + .font_grid_key = font_grid_key, + .font_size = font_size, + .renderer = renderer_impl, + .renderer_thread = render_thread, + .renderer_state = .{ + .mutex = mutex, + .terminal = &self.io.terminal, + }, + .renderer_thr = undefined, + .mouse = .{}, + .io = undefined, + .io_thread = io_thread, + .io_thr = undefined, + .screen_size = .{ .width = 0, .height = 0 }, + .grid_size = .{}, + .cell_size = cell_size, + .padding = padding, + .config = derived_config, + }; + // Start our IO implementation var io_writer = try termio.Writer.initMailbox(alloc); errdefer io_writer.deinit(alloc); - var io = try termio.Termio.init(alloc, .{ + try termio.Termio.init(&self.io, alloc, .{ .grid_size = grid_size, .screen_size = screen_size, .padding = padding, @@ -422,36 +451,7 @@ pub fn init( else Command.linux_cgroup_default, }); - errdefer io.deinit(); - - // Create the IO thread - var io_thread = try termio.Thread.init(alloc); - errdefer io_thread.deinit(); - - self.* = .{ - .alloc = alloc, - .app = app, - .rt_app = rt_app, - .rt_surface = rt_surface, - .font_grid_key = font_grid_key, - .font_size = font_size, - .renderer = renderer_impl, - .renderer_thread = render_thread, - .renderer_state = .{ - .mutex = mutex, - .terminal = &self.io.terminal, - }, - .renderer_thr = undefined, - .mouse = .{}, - .io = io, - .io_thread = io_thread, - .io_thr = undefined, - .screen_size = .{ .width = 0, .height = 0 }, - .grid_size = .{}, - .cell_size = cell_size, - .padding = padding, - .config = derived_config, - }; + errdefer self.io.deinit(); // Report initial cell size on surface creation try rt_surface.setCellSize(cell_size.width, cell_size.height); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 91f44a69b..71fd6b777 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -106,7 +106,7 @@ pub fn threadEnter( const read_thread = try std.Thread.spawn( .{}, if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, - .{ pty_fds.read, td.read_data, pipe[0] }, + .{ pty_fds.read, io, pipe[0] }, ); read_thread.setName("io-reader") catch {}; @@ -1156,7 +1156,7 @@ const Subprocess = struct { /// fds and this is still much faster and lower overhead than any async /// mechanism. pub const ReadThread = struct { - fn threadMainPosix(fd: posix.fd_t, ev: *termio.Termio.ReadData, quit: posix.fd_t) void { + fn threadMainPosix(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1220,7 +1220,7 @@ pub const ReadThread = struct { if (n == 0) break; // log.info("DATA: {d}", .{n}); - @call(.always_inline, termio.Termio.processOutputReadData, .{ ev, buf[0..n] }); + @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); } // Wait for data. diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index a329e2880..a23679285 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -67,6 +67,14 @@ writer: termio.Writer, /// is alive. This is protected by the renderer state lock. read_data: ?*ReadData = null, +/// The stream parser. This parses the stream of escape codes and so on +/// from the child process and calls callbacks in the stream handler. +terminal_stream: terminal.Stream(StreamHandler), + +/// Last time the cursor was reset. This is used to prevent message +/// flooding with cursor resets. +last_cursor_reset: ?std.time.Instant = null, + /// The configuration for this IO that is derived from the main /// configuration. This must be exported so that we don't need to /// pass around Config pointers which makes memory management a pain. @@ -125,7 +133,7 @@ pub const DerivedConfig = struct { /// /// This will also start the child process if the termio is configured /// to run a child process. -pub fn init(alloc: Allocator, opts: termio.Options) !Termio { +pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { // Create our terminal var term = try terminal.Terminal.init(alloc, .{ .cols = opts.grid_size.columns, @@ -169,7 +177,37 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio { var subprocess = try termio.Exec.init(alloc, opts, &term); errdefer subprocess.deinit(); - return .{ + // Create our stream handler. This points to memory in self so it + // isn't safe to use until self.* is set. + const handler: StreamHandler = handler: { + const default_cursor_color = if (opts.config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + break :handler .{ + .alloc = alloc, + .writer = &self.writer, + .surface_mailbox = opts.surface_mailbox, + .renderer_state = opts.renderer_state, + .renderer_wakeup = opts.renderer_wakeup, + .renderer_mailbox = opts.renderer_mailbox, + .grid_size = &self.grid_size, + .terminal = &self.terminal, + .osc_color_report_format = opts.config.osc_color_report_format, + .enquiry_response = opts.config.enquiry_response, + .default_foreground_color = opts.config.foreground.toTerminalRGB(), + .default_background_color = opts.config.background.toTerminalRGB(), + .default_cursor_style = opts.config.cursor_style, + .default_cursor_blink = opts.config.cursor_blink, + .default_cursor_color = default_cursor_color, + .cursor_color = default_cursor_color, + .foreground_color = opts.config.foreground.toTerminalRGB(), + .background_color = opts.config.background.toTerminalRGB(), + }; + }; + + self.* = .{ .alloc = alloc, .terminal = term, .subprocess = subprocess, @@ -180,6 +218,16 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio { .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, .writer = opts.writer, + .terminal_stream = .{ + .handler = handler, + .parser = .{ + .osc_parser = .{ + // Populate the OSC parser allocator (optional) because + // we want to support large OSC payloads such as OSC 52. + .alloc = alloc, + }, + }, + }, }; } @@ -188,73 +236,25 @@ pub fn deinit(self: *Termio) void { self.terminal.deinit(self.alloc); self.config.deinit(); self.writer.deinit(self.alloc); + + // Clear any StreamHandler state + self.terminal_stream.handler.deinit(); + self.terminal_stream.deinit(); } pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { const alloc = self.alloc; - // Setup our data that is used for callbacks - var read_data_ptr = try alloc.create(ReadData); - errdefer alloc.destroy(read_data_ptr); - // Wakeup watcher for the writer thread. var wakeup = try xev.Async.init(); errdefer wakeup.deinit(); - // Create our stream handler - const handler: StreamHandler = handler: { - const default_cursor_color = if (self.config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - break :handler .{ - .alloc = self.alloc, - .writer = &self.writer, - .surface_mailbox = self.surface_mailbox, - .renderer_state = self.renderer_state, - .renderer_wakeup = self.renderer_wakeup, - .renderer_mailbox = self.renderer_mailbox, - .grid_size = &self.grid_size, - .terminal = &self.terminal, - .osc_color_report_format = self.config.osc_color_report_format, - .enquiry_response = self.config.enquiry_response, - .default_foreground_color = self.config.foreground.toTerminalRGB(), - .default_background_color = self.config.background.toTerminalRGB(), - .default_cursor_style = self.config.cursor_style, - .default_cursor_blink = self.config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = default_cursor_color, - .foreground_color = self.config.foreground.toTerminalRGB(), - .background_color = self.config.background.toTerminalRGB(), - }; - }; - - // Setup our event data before we start - read_data_ptr.* = .{ - .renderer_state = self.renderer_state, - .renderer_wakeup = self.renderer_wakeup, - .renderer_mailbox = self.renderer_mailbox, - .terminal_stream = .{ - .handler = handler, - .parser = .{ - .osc_parser = .{ - // Populate the OSC parser allocator (optional) because - // we want to support large OSC payloads such as OSC 52. - .alloc = self.alloc, - }, - }, - }, - }; - errdefer read_data_ptr.deinit(); - // Setup our thread data data.* = .{ .alloc = alloc, .loop = &thread.loop, .renderer_state = self.renderer_state, .surface_mailbox = self.surface_mailbox, - .read_data = read_data_ptr, .writer = &self.writer, // Placeholder until setup below @@ -263,20 +263,10 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo // Setup our reader try self.subprocess.threadEnter(alloc, self, data); - - // Store our read data pointer - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.read_data = read_data_ptr; } pub fn threadExit(self: *Termio, data: *ThreadData) void { self.subprocess.threadExit(data); - - // Clear our read data pointer - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.read_data = null; } /// Send a message using the writer. Depending on the writer type in @@ -329,7 +319,7 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // Update our stream handler. The stream handler uses the same // renderer mutex so this is safe to do despite being executed // from another thread. - td.read_data.terminal_stream.handler.changeConfig(&self.config); + self.terminal_stream.handler.changeConfig(&self.config); td.reader.changeConfig(&self.config); // Update the configuration that we know about. @@ -482,42 +472,32 @@ pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !vo /// Process output from the pty. This is the manual API that users can /// call with pty data but it is also called by the read thread when using /// an exec subprocess. -pub fn processOutput(self: *Termio, buf: []const u8) !void { +pub fn processOutput(self: *Termio, buf: []const u8) void { // We are modifying terminal state from here on out and we need // the lock to grab our read data. self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - - // If we don't have read data, we can't process it. - const rd = self.read_data orelse return error.ReadDataNull; - processOutputLocked(rd, buf); -} - -/// Process output when you ahve the read data pointer. -pub fn processOutputReadData(rd: *ReadData, buf: []const u8) void { - rd.renderer_state.mutex.lock(); - defer rd.renderer_state.mutex.unlock(); - processOutputLocked(rd, buf); + self.processOutputLocked(buf); } /// Process output from readdata but the lock is already held. -fn processOutputLocked(rd: *ReadData, buf: []const u8) void { +fn processOutputLocked(self: *Termio, buf: []const u8) void { // Schedule a render. We can call this first because we have the lock. - rd.terminal_stream.handler.queueRender() catch unreachable; + self.terminal_stream.handler.queueRender() catch unreachable; // Whenever a character is typed, we ensure the cursor is in the // non-blink state so it is rendered if visible. If we're under // HEAVY read load, we don't want to send a ton of these so we // use a timer under the covers if (std.time.Instant.now()) |now| cursor_reset: { - if (rd.last_cursor_reset) |last| { + if (self.last_cursor_reset) |last| { if (now.since(last) <= (500 / std.time.ns_per_ms)) { break :cursor_reset; } } - rd.last_cursor_reset = now; - _ = rd.renderer_mailbox.push(.{ + self.last_cursor_reset = now; + _ = self.renderer_mailbox.push(.{ .reset_cursor_blink = {}, }, .{ .instant = {} }); } else |err| { @@ -528,28 +508,25 @@ fn processOutputLocked(rd: *ReadData, buf: []const u8) void { // process a byte at a time alternating between the inspector handler // and the termio handler. This is very slow compared to our optimizations // below but at least users only pay for it if they're using the inspector. - if (rd.renderer_state.inspector) |insp| { + if (self.renderer_state.inspector) |insp| { for (buf, 0..) |byte, i| { insp.recordPtyRead(buf[i .. i + 1]) catch |err| { log.err("error recording pty read in inspector err={}", .{err}); }; - rd.terminal_stream.next(byte) catch |err| + self.terminal_stream.next(byte) catch |err| log.err("error processing terminal data: {}", .{err}); } } else { - rd.terminal_stream.nextSlice(buf) catch |err| + self.terminal_stream.nextSlice(buf) catch |err| log.err("error processing terminal data: {}", .{err}); } // If our stream handling caused messages to be sent to the writer // thread, then we need to wake it up so that it processes them. - if (rd.terminal_stream.handler.writer_messaged) { - rd.terminal_stream.handler.writer_messaged = false; - // TODO - // rd.writer_wakeup.notify() catch |err| { - // log.warn("failed to wake up writer thread err={}", .{err}); - // }; + if (self.terminal_stream.handler.writer_messaged) { + self.terminal_stream.handler.writer_messaged = false; + self.writer.notify(); } } @@ -575,13 +552,10 @@ pub const ThreadData = struct { /// Data associated with the reader implementation (i.e. pty/exec state) reader: termio.reader.ThreadData, - read_data: *ReadData, writer: *termio.Writer, pub fn deinit(self: *ThreadData) void { self.reader.deinit(self.alloc); - self.read_data.deinit(); - self.alloc.destroy(self.read_data); self.* = undefined; } }; From 6b0b840a812b43fba1a3e6ee7657d729f77b2221 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 15:11:09 -0700 Subject: [PATCH 18/32] termio: writer needs a mut pointer --- src/termio/writer.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/writer.zig b/src/termio/writer.zig index a82169230..7795dba34 100644 --- a/src/termio/writer.zig +++ b/src/termio/writer.zig @@ -66,7 +66,7 @@ pub const Writer = union(enum) { mutex: ?*std.Thread.Mutex, ) void { switch (self.*) { - .mailbox => |mb| send: { + .mailbox => |*mb| send: { // Try to write to the mailbox with an instant timeout. This is the // fast path because we can queue without a lock. if (mb.mailbox.push(msg, .{ .instant = {} }) > 0) break :send; From 89d407dd6a2d3e437a6b292534c17d108ab70abf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 15:13:41 -0700 Subject: [PATCH 19/32] termio: excessive cursor reset --- src/termio/Termio.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index a23679285..b73dd8291 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -491,7 +491,7 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { // use a timer under the covers if (std.time.Instant.now()) |now| cursor_reset: { if (self.last_cursor_reset) |last| { - if (now.since(last) <= (500 / std.time.ns_per_ms)) { + if (now.since(last) <= (500 * std.time.ns_per_ms)) { break :cursor_reset; } } From 08ed60ceec0204e4221cdc09c91c7ba4565ca1f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 15:15:22 -0700 Subject: [PATCH 20/32] termio: ReadData is gone! --- src/termio/Termio.zig | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index b73dd8291..d5521ba55 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -63,10 +63,6 @@ grid_size: renderer.GridSize, /// The writer implementation to use. writer: termio.Writer, -/// The pointer to the read data. This is only valid while the termio thread -/// is alive. This is protected by the renderer state lock. -read_data: ?*ReadData = null, - /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. terminal_stream: terminal.Stream(StreamHandler), @@ -559,30 +555,3 @@ pub const ThreadData = struct { self.* = undefined; } }; - -/// The data required for the read thread. -pub const ReadData = struct { - /// The stream parser. This parses the stream of escape codes and so on - /// from the child process and calls callbacks in the stream handler. - terminal_stream: terminal.Stream(StreamHandler), - - /// The shared render state - renderer_state: *renderer.State, - - /// A handle to wake up the renderer. This hints to the renderer that that - /// a repaint should happen. - renderer_wakeup: xev.Async, - - /// The mailbox for notifying the renderer of things. - renderer_mailbox: *renderer.Thread.Mailbox, - - /// Last time the cursor was reset. This is used to prevent message - /// flooding with cursor resets. - last_cursor_reset: ?std.time.Instant = null, - - pub fn deinit(self: *ReadData) void { - // Clear any StreamHandler state - self.terminal_stream.handler.deinit(); - self.terminal_stream.deinit(); - } -}; From b0cd40d1de896f6d23e68b2f50a6fb3b22923a76 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 15:16:16 -0700 Subject: [PATCH 21/32] termio: fix windows build --- src/termio/Exec.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 71fd6b777..4e692cde1 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1237,7 +1237,7 @@ pub const ReadThread = struct { } } - fn threadMainWindows(fd: posix.fd_t, ev: *termio.Termio.ReadData, quit: posix.fd_t) void { + fn threadMainWindows(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1258,7 +1258,7 @@ pub const ReadThread = struct { } } - @call(.always_inline, termio.Termio.processOutputReadData, .{ ev, buf[0..n] }); + @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); } var quit_bytes: windows.DWORD = 0; From f0d896e11d9d6521095c9b5397b29a08fbaeafad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 15:17:40 -0700 Subject: [PATCH 22/32] termio: more windows fixes --- src/termio/writer.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/writer.zig b/src/termio/writer.zig index 7795dba34..a3931d5e1 100644 --- a/src/termio/writer.zig +++ b/src/termio/writer.zig @@ -100,7 +100,7 @@ pub const Writer = union(enum) { /// on the writer type. pub fn notify(self: *Writer) void { switch (self.*) { - .mailbox => |v| v.wakeup.notify() catch |err| { + .mailbox => |*v| v.wakeup.notify() catch |err| { log.warn("failed to notify writer, data will be dropped err={}", .{err}); }, } From 485346c69446296499296c6e0854e4c33e7c4b00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Jul 2024 18:15:19 -0700 Subject: [PATCH 23/32] termio: more windows fixes --- src/termio/Thread.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index f24fcf0df..d61301f5c 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -202,7 +202,7 @@ fn threadMain_(self: *Thread, io: *termio.Termio) !void { // Get the writer. This must be a mailbox writer for threading. const writer = switch (io.writer) { - .mailbox => |v| v, + .mailbox => |*v| v, // else => return error.TermioUnsupportedWriter, }; From 4a4b9f24115d059b7873a149d5463bb45173617b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 09:45:58 -0700 Subject: [PATCH 24/32] termio: trying to get Exec to not have access to full Opts --- src/termio/Exec.zig | 82 +++++++++++++++++++++++++++---------------- src/termio/Termio.zig | 16 ++++++++- src/termio/reader.zig | 25 +++++++------ 3 files changed, 81 insertions(+), 42 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 4e692cde1..b0c8cf14f 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -33,23 +33,11 @@ subprocess: Subprocess, /// up the internal state necessary to start it later. pub fn init( alloc: Allocator, - opts: termio.Options, - term: *terminal.Terminal, + cfg: Config, ) !Exec { - var subprocess = try Subprocess.init(alloc, opts); + var subprocess = try Subprocess.init(alloc, cfg); errdefer subprocess.deinit(); - // If we have an initial pwd requested by the subprocess, then we - // set that on the terminal now. This allows rapidly initializing - // new surfaces to use the proper pwd. - if (subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { - log.warn("error setting initial pwd err={}", .{err}); - }; - - // Initial width/height based on subprocess - term.width_px = subprocess.screen_size.width; - term.height_px = subprocess.screen_size.height; - return .{ .subprocess = subprocess }; } @@ -57,6 +45,29 @@ pub fn deinit(self: *Exec) void { self.subprocess.deinit(); } +/// Call to initialize the terminal state as necessary for this reader. +/// This is called before any termio begins. This should not be called +/// after termio begins because it may put the internal terminal state +/// into a bad state. +pub fn initTerminal(self: *Exec, term: *terminal.Terminal) void { + // If we have an initial pwd requested by the subprocess, then we + // set that on the terminal now. This allows rapidly initializing + // new surfaces to use the proper pwd. + if (self.subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| { + log.warn("error setting initial pwd err={}", .{err}); + }; + + // Setup our initial grid/screen size from the terminal. This + // can't fail because the pty should not exist at this point. + self.resize(.{ + .columns = term.cols, + .rows = term.rows, + }, .{ + .width = term.width_px, + .height = term.height_px, + }) catch unreachable; +} + pub fn threadEnter( self: *Exec, alloc: Allocator, @@ -505,6 +516,16 @@ pub const ThreadData = struct { } }; +pub const Config = struct { + command: ?[]const u8 = null, + shell_integration: configpkg.Config.ShellIntegration = .detect, + shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, + working_directory: ?[]const u8 = null, + resources_dir: ?[]const u8, + term: []const u8, + linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, +}; + const Subprocess = struct { /// If we build with flatpak support then we have to keep track of /// a potential execution on the host. @@ -529,7 +550,7 @@ const Subprocess = struct { /// Initialize the subprocess. This will NOT start it, this only sets /// up the internal state necessary to start it later. - pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { + pub fn init(gpa: Allocator, cfg: Config) !Subprocess { // We have a lot of maybe-allocations that all share the same lifetime // so use an arena so we don't end up in an accounting nightmare. var arena = std.heap.ArenaAllocator.init(gpa); @@ -551,7 +572,7 @@ const Subprocess = struct { errdefer env.deinit(); // If we have a resources dir then set our env var - if (opts.resources_dir) |dir| { + if (cfg.resources_dir) |dir| { log.info("found Ghostty resources dir: {s}", .{dir}); try env.put("GHOSTTY_RESOURCES_DIR", dir); } @@ -562,8 +583,8 @@ const Subprocess = struct { // // For now, we just look up a bundled dir but in the future we should // also load the terminfo database and look for it. - if (opts.resources_dir) |base| { - try env.put("TERM", opts.config.term); + if (cfg.resources_dir) |base| { + try env.put("TERM", cfg.term); try env.put("COLORTERM", "truecolor"); // Assume that the resources directory is adjacent to the terminfo @@ -620,7 +641,7 @@ const Subprocess = struct { // Add the man pages from our application bundle to MANPATH. if (comptime builtin.target.isDarwin()) { - if (opts.resources_dir) |resources_dir| man: { + if (cfg.resources_dir) |resources_dir| man: { var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; const dir = std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir}) catch |err| { log.warn("error building manpath, man pages may not be available err={}", .{err}); @@ -672,12 +693,12 @@ const Subprocess = struct { // Setup our shell integration, if we can. const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { - const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { + const default_shell_command = cfg.command orelse switch (builtin.os.tag) { .windows => "cmd.exe", else => "sh", }; - const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + const force: ?shell_integration.Shell = switch (cfg.shell_integration) { .none => break :shell .{ null, default_shell_command }, .detect => null, .bash => .bash, @@ -686,7 +707,7 @@ const Subprocess = struct { .zsh => .zsh, }; - const dir = opts.resources_dir orelse break :shell .{ + const dir = cfg.resources_dir orelse break :shell .{ null, default_shell_command, }; @@ -697,7 +718,7 @@ const Subprocess = struct { default_shell_command, &env, force, - opts.full_config.@"shell-integration-features", + cfg.shell_integration_features, ) orelse break :shell .{ null, default_shell_command }; break :shell .{ integration.shell, integration.command }; @@ -708,7 +729,7 @@ const Subprocess = struct { "shell integration automatically injected shell={}", .{shell}, ); - } else if (opts.full_config.@"shell-integration" != .none) { + } else if (cfg.shell_integration != .none) { log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); } @@ -845,7 +866,7 @@ const Subprocess = struct { // We have to copy the cwd because there is no guarantee that // pointers in full_config remain valid. - const cwd: ?[]u8 = if (opts.full_config.@"working-directory") |cwd| + const cwd: ?[]u8 = if (cfg.working_directory) |cwd| try alloc.dupe(u8, cwd) else null; @@ -855,21 +876,20 @@ const Subprocess = struct { const linux_cgroup: Command.LinuxCgroup = cgroup: { const default = Command.linux_cgroup_default; if (comptime builtin.os.tag != .linux) break :cgroup default; - const path = opts.linux_cgroup orelse break :cgroup default; + const path = cfg.linux_cgroup orelse break :cgroup default; break :cgroup try alloc.dupe(u8, path); }; - // Our screen size should be our padded size - const padded_size = opts.screen_size.subPadding(opts.padding); - return .{ .arena = arena, .env = env, .cwd = cwd, .args = args, - .grid_size = opts.grid_size, - .screen_size = padded_size, .linux_cgroup = linux_cgroup, + + // Should be initialized with initTerminal call. + .grid_size = .{}, + .screen_size = .{ .width = 1, .height = 1 }, }; } diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index d5521ba55..8a0a7051c 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -170,8 +170,22 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { // Setup our reader. // TODO: for manual, we need to set the terminal width/height - var subprocess = try termio.Exec.init(alloc, opts, &term); + var subprocess = try termio.Exec.init(alloc, .{ + .command = opts.full_config.command, + .shell_integration = opts.full_config.@"shell-integration", + .shell_integration_features = opts.full_config.@"shell-integration-features", + .working_directory = opts.full_config.@"working-directory", + .resources_dir = opts.resources_dir, + .term = opts.config.term, + .linux_cgroup = opts.linux_cgroup, + }); errdefer subprocess.deinit(); + subprocess.initTerminal(&term); + + // Setup our terminal size in pixels for certain requests. + const screen_size = opts.screen_size.subPadding(opts.padding); + term.width_px = screen_size.width; + term.height_px = screen_size.height; // Create our stream handler. This points to memory in self so it // isn't safe to use until self.* is set. diff --git a/src/termio/reader.zig b/src/termio/reader.zig index f8df5a3d7..add25d8b0 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -19,10 +19,10 @@ const Pty = @import("../pty.zig").Pty; const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); /// The kinds of readers. -pub const Kind = std.meta.Tag(Config); +pub const Kind = enum { manual, exec }; /// Configuration for the various reader types. -pub const Config = union(enum) { +pub const Config = union(Kind) { /// Manual means that the termio caller will handle reading input /// and passing it to the termio implementation. Note that even if you /// select a different reader, you can always still manually provide input; @@ -30,15 +30,20 @@ pub const Config = union(enum) { manual: void, /// Exec uses posix exec to run a command with a pty. - exec: Config.Exec, + exec: termio.Exec.Config, +}; - pub const Exec = struct { - command: ?[]const u8 = null, - shell_integration: configpkg.Config.ShellIntegration = .detect, - shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, - working_directory: ?[]const u8 = null, - linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, - }; +/// Reader implementations +pub const Reader = union(Kind) { + manual: void, + exec: termio.Exec, + + pub fn deinit(self: *Reader) void { + switch (self.*) { + .manual => {}, + .exec => |*exec| exec.deinit(), + } + } }; /// Termio thread data. See termio.ThreadData for docs. From 3625e1e58e4617e741668c1543763b0f4bdc2487 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 09:55:31 -0700 Subject: [PATCH 25/32] termio: take reader as option --- src/Surface.zig | 20 ++++++++++++ src/termio.zig | 1 + src/termio/Options.zig | 3 ++ src/termio/Termio.zig | 32 +++++++------------- src/termio/reader.zig | 69 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 21 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 208a1e258..00dc9ce55 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -426,6 +426,25 @@ pub fn init( }; // Start our IO implementation + var io_exec = try termio.Exec.init(alloc, .{ + .command = config.command, + .shell_integration = config.@"shell-integration", + .shell_integration_features = config.@"shell-integration-features", + .working_directory = config.@"working-directory", + .resources_dir = main.state.resources_dir, + .term = config.term, + + // Get the cgroup if we're on linux and have the decl. I'd love + // to change this from a decl to a surface options struct because + // then we can do memory management better (don't need to retain + // the string around). + .linux_cgroup = if (comptime builtin.os.tag == .linux and + @hasDecl(apprt.runtime.Surface, "cgroup")) + rt_surface.cgroup() + else + Command.linux_cgroup_default, + }); + errdefer io_exec.deinit(); var io_writer = try termio.Writer.initMailbox(alloc); errdefer io_writer.deinit(alloc); try termio.Termio.init(&self.io, alloc, .{ @@ -434,6 +453,7 @@ pub fn init( .padding = padding, .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), + .reader = .{ .exec = io_exec }, .writer = io_writer, .resources_dir = main.state.resources_dir, .renderer_state = &self.renderer_state, diff --git a/src/termio.zig b/src/termio.zig index 4fce4df18..a780786ae 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -13,6 +13,7 @@ pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = writer.Mailbox; +pub const Reader = reader.Reader; pub const StreamHandler = stream_handler.StreamHandler; pub const Writer = writer.Writer; diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 2cb636d60..7e97f7dfd 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -25,6 +25,9 @@ full_config: *const Config, /// The derived configuration for this termio implementation. config: termio.Termio.DerivedConfig, +/// The reader for the terminal. +reader: termio.Reader, + /// The writer for the terminal. This is how messages are delivered. /// If you're using termio.Thread this MUST be "mailbox". writer: termio.Writer, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 8a0a7051c..02e8f28d4 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -33,8 +33,8 @@ const log = std.log.scoped(.io_exec); /// Allocator alloc: Allocator, -/// This is the pty fd created for the subcommand. -subprocess: termio.Exec, +/// This is the implementation responsible for io. +reader: termio.Reader, /// The derived configuration for this termio implementation. config: DerivedConfig, @@ -169,18 +169,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { term.screen.cursor.cursor_style = opts.config.cursor_style; // Setup our reader. - // TODO: for manual, we need to set the terminal width/height - var subprocess = try termio.Exec.init(alloc, .{ - .command = opts.full_config.command, - .shell_integration = opts.full_config.@"shell-integration", - .shell_integration_features = opts.full_config.@"shell-integration-features", - .working_directory = opts.full_config.@"working-directory", - .resources_dir = opts.resources_dir, - .term = opts.config.term, - .linux_cgroup = opts.linux_cgroup, - }); - errdefer subprocess.deinit(); - subprocess.initTerminal(&term); + var reader = opts.reader; + reader.initTerminal(&term); // Setup our terminal size in pixels for certain requests. const screen_size = opts.screen_size.subPadding(opts.padding); @@ -220,13 +210,13 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { self.* = .{ .alloc = alloc, .terminal = term, - .subprocess = subprocess, .config = opts.config, .renderer_state = opts.renderer_state, .renderer_wakeup = opts.renderer_wakeup, .renderer_mailbox = opts.renderer_mailbox, .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, + .reader = opts.reader, .writer = opts.writer, .terminal_stream = .{ .handler = handler, @@ -242,7 +232,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { } pub fn deinit(self: *Termio) void { - self.subprocess.deinit(); + self.reader.deinit(); self.terminal.deinit(self.alloc); self.config.deinit(); self.writer.deinit(self.alloc); @@ -272,11 +262,11 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo }; // Setup our reader - try self.subprocess.threadEnter(alloc, self, data); + try self.reader.threadEnter(alloc, self, data); } pub fn threadExit(self: *Termio, data: *ThreadData) void { - self.subprocess.threadExit(data); + self.reader.threadExit(data); } /// Send a message using the writer. Depending on the writer type in @@ -310,7 +300,7 @@ pub inline fn queueWrite( data: []const u8, linefeed: bool, ) !void { - try self.subprocess.queueWrite(self.alloc, td, data, linefeed); + try self.reader.queueWrite(self.alloc, td, data, linefeed); } /// Update the configuration. @@ -373,7 +363,7 @@ pub fn resize( ) !void { // Update the size of our pty. const padded_size = screen_size.subPadding(padding); - try self.subprocess.resize(grid_size, padded_size); + try self.reader.resize(grid_size, padded_size); // Update our cached grid size self.grid_size = grid_size; @@ -476,7 +466,7 @@ pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !vo self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t = self.renderer_state.terminal; - try self.subprocess.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); + try self.reader.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); } /// Process output from the pty. This is the manual API that users can diff --git a/src/termio/reader.zig b/src/termio/reader.zig index add25d8b0..de7536078 100644 --- a/src/termio/reader.zig +++ b/src/termio/reader.zig @@ -9,6 +9,7 @@ const configpkg = @import("../config.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); const shell_integration = @import("shell_integration.zig"); +const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; @@ -44,6 +45,74 @@ pub const Reader = union(Kind) { .exec => |*exec| exec.deinit(), } } + + pub fn initTerminal(self: *Reader, t: *terminal.Terminal) void { + switch (self.*) { + .manual => {}, + .exec => |*exec| exec.initTerminal(t), + } + } + + pub fn threadEnter( + self: *Reader, + alloc: Allocator, + io: *termio.Termio, + td: *termio.Termio.ThreadData, + ) !void { + switch (self.*) { + .manual => {}, + .exec => |*exec| try exec.threadEnter(alloc, io, td), + } + } + + pub fn threadExit(self: *Reader, td: *termio.Termio.ThreadData) void { + switch (self.*) { + .manual => {}, + .exec => |*exec| exec.threadExit(td), + } + } + + pub fn resize( + self: *Reader, + grid_size: renderer.GridSize, + screen_size: renderer.ScreenSize, + ) !void { + switch (self.*) { + .manual => {}, + .exec => |*exec| try exec.resize(grid_size, screen_size), + } + } + + pub fn queueWrite( + self: *Reader, + alloc: Allocator, + td: *termio.Termio.ThreadData, + data: []const u8, + linefeed: bool, + ) !void { + switch (self.*) { + .manual => {}, + .exec => |*exec| try exec.queueWrite(alloc, td, data, linefeed), + } + } + + pub fn childExitedAbnormally( + self: *Reader, + gpa: Allocator, + t: *terminal.Terminal, + exit_code: u32, + runtime_ms: u64, + ) !void { + switch (self.*) { + .manual => {}, + .exec => |*exec| try exec.childExitedAbnormally( + gpa, + t, + exit_code, + runtime_ms, + ), + } + } }; /// Termio thread data. See termio.ThreadData for docs. From dc6dc1d3d2ceb2dc9b2cd580f8b7d1e77ec4275b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 10:02:02 -0700 Subject: [PATCH 26/32] termio: remove more state --- src/Surface.zig | 85 +++++++++++++++++++++--------------------- src/termio/Options.zig | 7 ---- 2 files changed, 42 insertions(+), 50 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 00dc9ce55..554c3b01c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -426,51 +426,50 @@ pub fn init( }; // Start our IO implementation - var io_exec = try termio.Exec.init(alloc, .{ - .command = config.command, - .shell_integration = config.@"shell-integration", - .shell_integration_features = config.@"shell-integration-features", - .working_directory = config.@"working-directory", - .resources_dir = main.state.resources_dir, - .term = config.term, + // This separate block ({}) is important because our errdefers must + // be scoped here to be valid. + { + // Initialize our IO backend + var io_exec = try termio.Exec.init(alloc, .{ + .command = config.command, + .shell_integration = config.@"shell-integration", + .shell_integration_features = config.@"shell-integration-features", + .working_directory = config.@"working-directory", + .resources_dir = main.state.resources_dir, + .term = config.term, - // Get the cgroup if we're on linux and have the decl. I'd love - // to change this from a decl to a surface options struct because - // then we can do memory management better (don't need to retain - // the string around). - .linux_cgroup = if (comptime builtin.os.tag == .linux and - @hasDecl(apprt.runtime.Surface, "cgroup")) - rt_surface.cgroup() - else - Command.linux_cgroup_default, - }); - errdefer io_exec.deinit(); - var io_writer = try termio.Writer.initMailbox(alloc); - errdefer io_writer.deinit(alloc); - try termio.Termio.init(&self.io, alloc, .{ - .grid_size = grid_size, - .screen_size = screen_size, - .padding = padding, - .full_config = config, - .config = try termio.Termio.DerivedConfig.init(alloc, config), - .reader = .{ .exec = io_exec }, - .writer = io_writer, - .resources_dir = main.state.resources_dir, - .renderer_state = &self.renderer_state, - .renderer_wakeup = render_thread.wakeup, - .renderer_mailbox = render_thread.mailbox, - .surface_mailbox = .{ .surface = self, .app = app_mailbox }, + // Get the cgroup if we're on linux and have the decl. I'd love + // to change this from a decl to a surface options struct because + // then we can do memory management better (don't need to retain + // the string around). + .linux_cgroup = if (comptime builtin.os.tag == .linux and + @hasDecl(apprt.runtime.Surface, "cgroup")) + rt_surface.cgroup() + else + Command.linux_cgroup_default, + }); + errdefer io_exec.deinit(); - // Get the cgroup if we're on linux and have the decl. I'd love - // to change this from a decl to a surface options struct because - // then we can do memory management better (don't need to retain - // the string around). - .linux_cgroup = if (comptime builtin.os.tag == .linux and - @hasDecl(apprt.runtime.Surface, "cgroup")) - rt_surface.cgroup() - else - Command.linux_cgroup_default, - }); + // Initialize our IO writer + var io_writer = try termio.Writer.initMailbox(alloc); + errdefer io_writer.deinit(alloc); + + try termio.Termio.init(&self.io, alloc, .{ + .grid_size = grid_size, + .screen_size = screen_size, + .padding = padding, + .full_config = config, + .config = try termio.Termio.DerivedConfig.init(alloc, config), + .reader = .{ .exec = io_exec }, + .writer = io_writer, + .renderer_state = &self.renderer_state, + .renderer_wakeup = render_thread.wakeup, + .renderer_mailbox = render_thread.mailbox, + .surface_mailbox = .{ .surface = self, .app = app_mailbox }, + }); + } + // Outside the block, IO has now taken ownership of our temporary state + // so we can just defer this and not the subcomponents. errdefer self.io.deinit(); // Report initial cell size on surface creation diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 7e97f7dfd..19de39756 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -32,9 +32,6 @@ reader: termio.Reader, /// If you're using termio.Thread this MUST be "mailbox". writer: termio.Writer, -/// The application resources directory. -resources_dir: ?[]const u8, - /// The render state. The IO implementation can modify anything here. The /// surface thread will setup the initial "terminal" pointer but the IO impl /// is free to change that if that is useful (i.e. doing some sort of dual @@ -50,7 +47,3 @@ renderer_mailbox: *renderer.Thread.Mailbox, /// The mailbox for sending the surface messages. surface_mailbox: apprt.surface.Mailbox, - -/// The cgroup to apply to the started termio process, if able by -/// the termio implementation. This only applies to Linux. -linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, From 001a6d2624ce0721a4eb5a793f5c46219f0afb3f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 10:14:05 -0700 Subject: [PATCH 27/32] termio: reader => backend --- src/Surface.zig | 2 +- src/termio.zig | 4 +-- src/termio/Exec.zig | 20 +++++++-------- src/termio/Options.zig | 4 +-- src/termio/Termio.zig | 34 +++++++++++++------------- src/termio/{reader.zig => backend.zig} | 19 +++++++------- 6 files changed, 42 insertions(+), 41 deletions(-) rename src/termio/{reader.zig => backend.zig} (89%) diff --git a/src/Surface.zig b/src/Surface.zig index 554c3b01c..0ed6cdc1d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -460,7 +460,7 @@ pub fn init( .padding = padding, .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), - .reader = .{ .exec = io_exec }, + .backend = .{ .exec = io_exec }, .writer = io_writer, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, diff --git a/src/termio.zig b/src/termio.zig index a780786ae..299fdae49 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -5,15 +5,15 @@ const stream_handler = @import("termio/stream_handler.zig"); pub usingnamespace @import("termio/message.zig"); -pub const reader = @import("termio/reader.zig"); +pub const backend = @import("termio/backend.zig"); pub const writer = @import("termio/writer.zig"); pub const Exec = @import("termio/Exec.zig"); pub const Options = @import("termio/Options.zig"); pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); +pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = writer.Mailbox; -pub const Reader = reader.Reader; pub const StreamHandler = stream_handler.StreamHandler; pub const Writer = writer.Writer; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index b0c8cf14f..f6f41f9ea 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -26,7 +26,7 @@ const windows = internal_os.windows; const log = std.log.scoped(.io_exec); -/// The subprocess state for our exec reader. +/// The subprocess state for our exec backend. subprocess: Subprocess, /// Initialize the exec state. This will NOT start it, this only sets @@ -45,7 +45,7 @@ pub fn deinit(self: *Exec) void { self.subprocess.deinit(); } -/// Call to initialize the terminal state as necessary for this reader. +/// Call to initialize the terminal state as necessary for this backend. /// This is called before any termio begins. This should not be called /// after termio begins because it may put the internal terminal state /// into a bad state. @@ -121,8 +121,8 @@ pub fn threadEnter( ); read_thread.setName("io-reader") catch {}; - // Setup our threadata reader state to be our own - td.reader = .{ .exec = .{ + // Setup our threadata backend state to be our own + td.backend = .{ .exec = .{ .start = process_start, .abnormal_runtime_threshold_ms = io.config.abnormal_runtime_threshold_ms, .wait_after_command = io.config.wait_after_command, @@ -136,7 +136,7 @@ pub fn threadEnter( // Start our process watcher process.wait( td.loop, - &td.reader.exec.process_wait_c, + &td.backend.exec.process_wait_c, termio.Termio.ThreadData, td, processExit, @@ -144,8 +144,8 @@ pub fn threadEnter( } pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { - assert(td.reader == .exec); - const exec = &td.reader.exec; + assert(td.backend == .exec); + const exec = &td.backend.exec; if (exec.exited) self.subprocess.externalExit(); self.subprocess.stop(); @@ -282,8 +282,8 @@ fn processExit( const exit_code = r catch unreachable; const td = td_.?; - assert(td.reader == .exec); - const execdata = &td.reader.exec; + assert(td.backend == .exec); + const execdata = &td.backend.exec; execdata.exited = true; // Determine how long the process was running for. @@ -366,7 +366,7 @@ pub fn queueWrite( linefeed: bool, ) !void { _ = self; - const exec = &td.reader.exec; + const exec = &td.backend.exec; // If our process is exited then we send our surface a message // about it but we don't queue any more writes. diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 19de39756..f45e7439a 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -25,8 +25,8 @@ full_config: *const Config, /// The derived configuration for this termio implementation. config: termio.Termio.DerivedConfig, -/// The reader for the terminal. -reader: termio.Reader, +/// The backend for termio that implements where reads/writes are sourced. +backend: termio.Backend, /// The writer for the terminal. This is how messages are delivered. /// If you're using termio.Thread this MUST be "mailbox". diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 02e8f28d4..7c48d5ff7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -34,7 +34,7 @@ const log = std.log.scoped(.io_exec); alloc: Allocator, /// This is the implementation responsible for io. -reader: termio.Reader, +backend: termio.Backend, /// The derived configuration for this termio implementation. config: DerivedConfig, @@ -168,9 +168,9 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { // Set our default cursor style term.screen.cursor.cursor_style = opts.config.cursor_style; - // Setup our reader. - var reader = opts.reader; - reader.initTerminal(&term); + // Setup our backend. + var backend = opts.backend; + backend.initTerminal(&term); // Setup our terminal size in pixels for certain requests. const screen_size = opts.screen_size.subPadding(opts.padding); @@ -216,7 +216,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .renderer_mailbox = opts.renderer_mailbox, .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, - .reader = opts.reader, + .backend = opts.backend, .writer = opts.writer, .terminal_stream = .{ .handler = handler, @@ -232,7 +232,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { } pub fn deinit(self: *Termio) void { - self.reader.deinit(); + self.backend.deinit(); self.terminal.deinit(self.alloc); self.config.deinit(); self.writer.deinit(self.alloc); @@ -258,15 +258,15 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .writer = &self.writer, // Placeholder until setup below - .reader = .{ .manual = {} }, + .backend = .{ .manual = {} }, }; - // Setup our reader - try self.reader.threadEnter(alloc, self, data); + // Setup our backend + try self.backend.threadEnter(alloc, self, data); } pub fn threadExit(self: *Termio, data: *ThreadData) void { - self.reader.threadExit(data); + self.backend.threadExit(data); } /// Send a message using the writer. Depending on the writer type in @@ -300,7 +300,7 @@ pub inline fn queueWrite( data: []const u8, linefeed: bool, ) !void { - try self.reader.queueWrite(self.alloc, td, data, linefeed); + try self.backend.queueWrite(self.alloc, td, data, linefeed); } /// Update the configuration. @@ -320,7 +320,7 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // renderer mutex so this is safe to do despite being executed // from another thread. self.terminal_stream.handler.changeConfig(&self.config); - td.reader.changeConfig(&self.config); + td.backend.changeConfig(&self.config); // Update the configuration that we know about. // @@ -363,7 +363,7 @@ pub fn resize( ) !void { // Update the size of our pty. const padded_size = screen_size.subPadding(padding); - try self.reader.resize(grid_size, padded_size); + try self.backend.resize(grid_size, padded_size); // Update our cached grid size self.grid_size = grid_size; @@ -466,7 +466,7 @@ pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !vo self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t = self.renderer_state.terminal; - try self.reader.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); + try self.backend.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); } /// Process output from the pty. This is the manual API that users can @@ -550,12 +550,12 @@ pub const ThreadData = struct { /// Mailboxes for different threads surface_mailbox: apprt.surface.Mailbox, - /// Data associated with the reader implementation (i.e. pty/exec state) - reader: termio.reader.ThreadData, + /// Data associated with the backend implementation (i.e. pty/exec state) + backend: termio.backend.ThreadData, writer: *termio.Writer, pub fn deinit(self: *ThreadData) void { - self.reader.deinit(self.alloc); + self.backend.deinit(self.alloc); self.* = undefined; } }; diff --git a/src/termio/reader.zig b/src/termio/backend.zig similarity index 89% rename from src/termio/reader.zig rename to src/termio/backend.zig index de7536078..75342cbc7 100644 --- a/src/termio/reader.zig +++ b/src/termio/backend.zig @@ -34,19 +34,20 @@ pub const Config = union(Kind) { exec: termio.Exec.Config, }; -/// Reader implementations -pub const Reader = union(Kind) { +/// Backend implementations. A backend is responsible for owning the pty +/// behavior and providing read/write capabilities. +pub const Backend = union(Kind) { manual: void, exec: termio.Exec, - pub fn deinit(self: *Reader) void { + pub fn deinit(self: *Backend) void { switch (self.*) { .manual => {}, .exec => |*exec| exec.deinit(), } } - pub fn initTerminal(self: *Reader, t: *terminal.Terminal) void { + pub fn initTerminal(self: *Backend, t: *terminal.Terminal) void { switch (self.*) { .manual => {}, .exec => |*exec| exec.initTerminal(t), @@ -54,7 +55,7 @@ pub const Reader = union(Kind) { } pub fn threadEnter( - self: *Reader, + self: *Backend, alloc: Allocator, io: *termio.Termio, td: *termio.Termio.ThreadData, @@ -65,7 +66,7 @@ pub const Reader = union(Kind) { } } - pub fn threadExit(self: *Reader, td: *termio.Termio.ThreadData) void { + pub fn threadExit(self: *Backend, td: *termio.Termio.ThreadData) void { switch (self.*) { .manual => {}, .exec => |*exec| exec.threadExit(td), @@ -73,7 +74,7 @@ pub const Reader = union(Kind) { } pub fn resize( - self: *Reader, + self: *Backend, grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, ) !void { @@ -84,7 +85,7 @@ pub const Reader = union(Kind) { } pub fn queueWrite( - self: *Reader, + self: *Backend, alloc: Allocator, td: *termio.Termio.ThreadData, data: []const u8, @@ -97,7 +98,7 @@ pub const Reader = union(Kind) { } pub fn childExitedAbnormally( - self: *Reader, + self: *Backend, gpa: Allocator, t: *terminal.Terminal, exit_code: u32, From 835d622baa9d0026798f9f23fbc09abcc41eaf5f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 10:23:09 -0700 Subject: [PATCH 28/32] termio: writer => mailbox --- src/Surface.zig | 8 ++--- src/termio.zig | 5 ++- src/termio/Exec.zig | 4 +-- src/termio/Options.zig | 4 +-- src/termio/Termio.zig | 36 +++++++++---------- src/termio/Thread.zig | 12 +++---- src/termio/{writer.zig => mailbox.zig} | 48 +++++++++++++------------- src/termio/stream_handler.zig | 14 ++++---- 8 files changed, 65 insertions(+), 66 deletions(-) rename src/termio/{writer.zig => mailbox.zig} (71%) diff --git a/src/Surface.zig b/src/Surface.zig index 0ed6cdc1d..7ee05d642 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -450,9 +450,9 @@ pub fn init( }); errdefer io_exec.deinit(); - // Initialize our IO writer - var io_writer = try termio.Writer.initMailbox(alloc); - errdefer io_writer.deinit(alloc); + // Initialize our IO mailbox + var io_mailbox = try termio.Mailbox.initSPSC(alloc); + errdefer io_mailbox.deinit(alloc); try termio.Termio.init(&self.io, alloc, .{ .grid_size = grid_size, @@ -461,7 +461,7 @@ pub fn init( .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), .backend = .{ .exec = io_exec }, - .writer = io_writer, + .mailbox = io_mailbox, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, .renderer_mailbox = render_thread.mailbox, diff --git a/src/termio.zig b/src/termio.zig index 299fdae49..2be1e1dbb 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -6,16 +6,15 @@ const stream_handler = @import("termio/stream_handler.zig"); pub usingnamespace @import("termio/message.zig"); pub const backend = @import("termio/backend.zig"); -pub const writer = @import("termio/writer.zig"); +pub const mailbox = @import("termio/mailbox.zig"); pub const Exec = @import("termio/Exec.zig"); pub const Options = @import("termio/Options.zig"); pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; -pub const Mailbox = writer.Mailbox; +pub const Mailbox = mailbox.Mailbox; pub const StreamHandler = stream_handler.StreamHandler; -pub const Writer = writer.Writer; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index f6f41f9ea..65607c67f 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -319,13 +319,13 @@ fn processExit( // Notify our main writer thread which has access to more // information so it can show a better error message. - td.writer.send(.{ + td.mailbox.send(.{ .child_exited_abnormally = .{ .exit_code = exit_code, .runtime_ms = runtime, }, }, null); - td.writer.notify(); + td.mailbox.notify(); return .disarm; } diff --git a/src/termio/Options.zig b/src/termio/Options.zig index f45e7439a..fe862a503 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -28,9 +28,9 @@ config: termio.Termio.DerivedConfig, /// The backend for termio that implements where reads/writes are sourced. backend: termio.Backend, -/// The writer for the terminal. This is how messages are delivered. +/// The mailbox for the terminal. This is how messages are delivered. /// If you're using termio.Thread this MUST be "mailbox". -writer: termio.Writer, +mailbox: termio.Mailbox, /// The render state. The IO implementation can modify anything here. The /// surface thread will setup the initial "terminal" pointer but the IO impl diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 7c48d5ff7..457a66361 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -60,8 +60,8 @@ surface_mailbox: apprt.surface.Mailbox, /// The cached grid size whenever a resize is called. grid_size: renderer.GridSize, -/// The writer implementation to use. -writer: termio.Writer, +/// The mailbox implementation to use. +mailbox: termio.Mailbox, /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. @@ -187,7 +187,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { break :handler .{ .alloc = alloc, - .writer = &self.writer, + .termio_mailbox = &self.mailbox, .surface_mailbox = opts.surface_mailbox, .renderer_state = opts.renderer_state, .renderer_wakeup = opts.renderer_wakeup, @@ -217,7 +217,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, .backend = opts.backend, - .writer = opts.writer, + .mailbox = opts.mailbox, .terminal_stream = .{ .handler = handler, .parser = .{ @@ -235,7 +235,7 @@ pub fn deinit(self: *Termio) void { self.backend.deinit(); self.terminal.deinit(self.alloc); self.config.deinit(); - self.writer.deinit(self.alloc); + self.mailbox.deinit(self.alloc); // Clear any StreamHandler state self.terminal_stream.handler.deinit(); @@ -255,7 +255,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .loop = &thread.loop, .renderer_state = self.renderer_state, .surface_mailbox = self.surface_mailbox, - .writer = &self.writer, + .mailbox = &self.mailbox, // Placeholder until setup below .backend = .{ .manual = {} }, @@ -269,29 +269,29 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { self.backend.threadExit(data); } -/// Send a message using the writer. Depending on the writer type in +/// Send a message to the the mailbox. Depending on the mailbox type in /// use this may process now or it may just enqueue and process later. /// -/// This will also notify the writer thread to process the message. If +/// This will also notify the mailbox thread to process the message. If /// you're sending a lot of messages, it may be more efficient to use -/// the writer directly and then call notify separately. +/// the mailbox directly and then call notify separately. pub fn queueMessage( self: *Termio, msg: termio.Message, mutex: enum { locked, unlocked }, ) void { - self.writer.send(msg, switch (mutex) { + self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, .unlocked => null, }); - self.writer.notify(); + self.mailbox.notify(); } /// Queue a write directly to the pty. /// /// If you're using termio.Thread, this must ONLY be called from the -/// writer thread. If you're not on the thread, use queueMessage with -/// writer messages instead. +/// mailbox thread. If you're not on the thread, use queueMessage with +/// mailbox messages instead. /// /// If you're not using termio.Thread, this is not threadsafe. pub inline fn queueWrite( @@ -522,11 +522,11 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { log.err("error processing terminal data: {}", .{err}); } - // If our stream handling caused messages to be sent to the writer + // If our stream handling caused messages to be sent to the mailbox // thread, then we need to wake it up so that it processes them. - if (self.terminal_stream.handler.writer_messaged) { - self.terminal_stream.handler.writer_messaged = false; - self.writer.notify(); + if (self.terminal_stream.handler.termio_messaged) { + self.terminal_stream.handler.termio_messaged = false; + self.mailbox.notify(); } } @@ -552,7 +552,7 @@ pub const ThreadData = struct { /// Data associated with the backend implementation (i.e. pty/exec state) backend: termio.backend.ThreadData, - writer: *termio.Writer, + mailbox: *termio.Mailbox, pub fn deinit(self: *ThreadData) void { self.backend.deinit(self.alloc); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index d61301f5c..73e384f51 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -200,10 +200,10 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { fn threadMain_(self: *Thread, io: *termio.Termio) !void { defer log.debug("IO thread exited", .{}); - // Get the writer. This must be a mailbox writer for threading. - const writer = switch (io.writer) { - .mailbox => |*v| v, - // else => return error.TermioUnsupportedWriter, + // 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 @@ -219,7 +219,7 @@ fn threadMain_(self: *Thread, io: *termio.Termio) !void { defer io.threadExit(&cb.data); // Start the async handlers. - writer.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback); + mailbox.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback); self.stop.wait(&self.loop, &self.stop_c, CallbackData, &cb, stopCallback); // Run @@ -241,7 +241,7 @@ fn drainMailbox( cb: *CallbackData, ) !void { // We assert when starting the thread that this is the state - const mailbox = cb.io.writer.mailbox.mailbox; + const mailbox = cb.io.mailbox.spsc.queue; const io = cb.io; const data = &cb.data; diff --git a/src/termio/writer.zig b/src/termio/mailbox.zig similarity index 71% rename from src/termio/writer.zig rename to src/termio/mailbox.zig index a3931d5e1..85471009d 100644 --- a/src/termio/writer.zig +++ b/src/termio/mailbox.zig @@ -9,14 +9,14 @@ const BlockingQueue = @import("../blocking_queue.zig").BlockingQueue; const log = std.log.scoped(.io_writer); -/// A mailbox used for storing messages that is periodically drained. +/// A queue used for storing messages that is periodically drained. /// Typically used by a multi-threaded application. The capacity is /// hardcoded to a value that empirically has made sense for Ghostty usage /// but I'm open to changing it with good arguments. -pub const Mailbox = BlockingQueue(termio.Message, 64); +const Queue = BlockingQueue(termio.Message, 64); /// The location to where write-related messages are sent. -pub const Writer = union(enum) { +pub const Mailbox = union(enum) { // /// Write messages to an unbounded list backed by an allocator. // /// This is useful for single-threaded applications where you're not // /// afraid of running out of memory. You should be careful that you're @@ -28,27 +28,27 @@ pub const Writer = union(enum) { // /// in libghostty eventually. // unbounded: std.ArrayList(termio.Message), - /// Write messages to a SPSC mailbox for multi-threaded applications. - mailbox: struct { - mailbox: *Mailbox, + /// Write messages to a SPSC queue for multi-threaded applications. + spsc: struct { + queue: *Queue, wakeup: xev.Async, }, - /// Init the mailbox writer. - pub fn initMailbox(alloc: Allocator) !Writer { - var mailbox = try Mailbox.create(alloc); - errdefer mailbox.destroy(alloc); + /// Init the SPSC writer. + pub fn initSPSC(alloc: Allocator) !Mailbox { + var queue = try Queue.create(alloc); + errdefer queue.destroy(alloc); var wakeup = try xev.Async.init(); errdefer wakeup.deinit(); - return .{ .mailbox = .{ .mailbox = mailbox, .wakeup = wakeup } }; + return .{ .spsc = .{ .queue = queue, .wakeup = wakeup } }; } - pub fn deinit(self: *Writer, alloc: Allocator) void { + pub fn deinit(self: *Mailbox, alloc: Allocator) void { switch (self.*) { - .mailbox => |*v| { - v.mailbox.destroy(alloc); + .spsc => |*v| { + v.queue.destroy(alloc); v.wakeup.deinit(); }, } @@ -58,20 +58,20 @@ pub const Writer = union(enum) { /// /// If the optional mutex is given, it must already be LOCKED. If the /// send would block, we'll unlock this mutex, resend the message, and - /// lock it again. This handles an edge case where mailboxes are full. + /// lock it again. This handles an edge case where queues are full. /// This may not apply to all writer types. pub fn send( - self: *Writer, + self: *Mailbox, msg: termio.Message, mutex: ?*std.Thread.Mutex, ) void { switch (self.*) { - .mailbox => |*mb| send: { - // Try to write to the mailbox with an instant timeout. This is the + .spsc => |*mb| send: { + // Try to write to the queue with an instant timeout. This is the // fast path because we can queue without a lock. - if (mb.mailbox.push(msg, .{ .instant = {} }) > 0) break :send; + if (mb.queue.push(msg, .{ .instant = {} }) > 0) break :send; - // If we enter this conditional, the mailbox is full. We wake up + // If we enter this conditional, the queue is full. We wake up // the writer thread so that it can process messages to clear up // space. However, the writer thread may require the renderer // lock so we need to unlock. @@ -86,21 +86,21 @@ pub const Writer = union(enum) { // But this only gets triggered in certain pathological cases. // // Note that writes themselves don't require a lock, but there - // are other messages in the writer mailbox (resize, focus) that + // are other messages in the writer queue (resize, focus) that // could acquire the lock. This is why we have to release our lock // here. if (mutex) |m| m.unlock(); defer if (mutex) |m| m.lock(); - _ = mb.mailbox.push(msg, .{ .forever = {} }); + _ = mb.queue.push(msg, .{ .forever = {} }); }, } } /// Notify that there are new messages. This may be a noop depending /// on the writer type. - pub fn notify(self: *Writer) void { + pub fn notify(self: *Mailbox) void { switch (self.*) { - .mailbox => |*v| v.wakeup.notify() catch |err| { + .spsc => |*v| v.wakeup.notify() catch |err| { log.warn("failed to notify writer, data will be dropped err={}", .{err}); }, } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 9a047bcfc..bb45cd480 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -27,8 +27,8 @@ pub const StreamHandler = struct { grid_size: *renderer.GridSize, terminal: *terminal.Terminal, - /// Mailbox for data to the writer thread. - writer: *termio.Writer, + /// Mailbox for data to the termio thread. + termio_mailbox: *termio.Mailbox, /// Mailbox for the surface. surface_mailbox: apprt.surface.Mailbox, @@ -86,10 +86,10 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, - /// This is set to true when a message was written to the writer + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need - /// to wake up the writer. - writer_messaged: bool = false, + /// to wake up the termio thread. + termio_messaged: bool = false, /// This is set to true when we've seen a title escape sequence. We use /// this to determine if we need to default the window title. @@ -140,8 +140,8 @@ pub const StreamHandler = struct { } inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { - self.writer.send(msg, self.renderer_state.mutex); - self.writer_messaged = true; + self.termio_mailbox.send(msg, self.renderer_state.mutex); + self.termio_messaged = true; } pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { From 8a5e43f3f1e9cad688138ad3aa6ba7f9521cab8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 10:30:00 -0700 Subject: [PATCH 29/32] termio: update docs --- src/termio.zig | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/termio.zig b/src/termio.zig index 2be1e1dbb..b4943cca9 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -1,6 +1,21 @@ -//! IO implementation and utilities. The IO implementation is responsible -//! for taking the config, spinning up a child process, and handling IO -//! with the terminal. +//! Termio is responsible for "terminal IO." Specifically, this is the +//! reading and writing of bytes for the underlying pty or pty-like device. +//! +//! Termio is constructed of a few components: +//! - Termio - The main shared struct that has common logic across all +//! backends and mailboxes (defined below). +//! - Backend - Responsible for the actual physical IO. For example, one +//! implementation creates a subprocess, allocates and assigns a pty, +//! and sets up a read thread on the pty. +//! - Mailbox - Responsible for storing/dispensing event messages to +//! the backend. This exists separately from backends because termio +//! is built to be both single and multi-threaded. +//! +//! Termio supports (and recommends) multi-threaded operation. Multi-threading +//! enables the read/writes to generally happen on separate threads and +//! almost always improves throughput and latency under heavy IO load. To +//! enable threading, use the Thread struct. This wraps a Termio, requires +//! specific backend/mailbox capabilities, and sets up the necessary threads. const stream_handler = @import("termio/stream_handler.zig"); From 3867e20e72cf753cf8cf92d7dac82f225529c27d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 10:50:17 -0700 Subject: [PATCH 30/32] termio: get rid of manual backend --- src/termio/Termio.zig | 4 +--- src/termio/backend.zig | 23 +++-------------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 457a66361..ef4f78b1f 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -256,9 +256,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .renderer_state = self.renderer_state, .surface_mailbox = self.surface_mailbox, .mailbox = &self.mailbox, - - // Placeholder until setup below - .backend = .{ .manual = {} }, + .backend = undefined, // Backend must replace this on threadEnter }; // Setup our backend diff --git a/src/termio/backend.zig b/src/termio/backend.zig index 75342cbc7..b29df89c6 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -19,17 +19,11 @@ const Pty = @import("../pty.zig").Pty; // enough to satisfy most write requests. It must be a power of 2. const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); -/// The kinds of readers. -pub const Kind = enum { manual, exec }; +/// The kinds of backends. +pub const Kind = enum { exec }; -/// Configuration for the various reader types. +/// Configuration for the various backend types. pub const Config = union(Kind) { - /// Manual means that the termio caller will handle reading input - /// and passing it to the termio implementation. Note that even if you - /// select a different reader, you can always still manually provide input; - /// this config just makes it so that it is ONLY manual input. - manual: void, - /// Exec uses posix exec to run a command with a pty. exec: termio.Exec.Config, }; @@ -37,19 +31,16 @@ pub const Config = union(Kind) { /// Backend implementations. A backend is responsible for owning the pty /// behavior and providing read/write capabilities. pub const Backend = union(Kind) { - manual: void, exec: termio.Exec, pub fn deinit(self: *Backend) void { switch (self.*) { - .manual => {}, .exec => |*exec| exec.deinit(), } } pub fn initTerminal(self: *Backend, t: *terminal.Terminal) void { switch (self.*) { - .manual => {}, .exec => |*exec| exec.initTerminal(t), } } @@ -61,14 +52,12 @@ pub const Backend = union(Kind) { td: *termio.Termio.ThreadData, ) !void { switch (self.*) { - .manual => {}, .exec => |*exec| try exec.threadEnter(alloc, io, td), } } pub fn threadExit(self: *Backend, td: *termio.Termio.ThreadData) void { switch (self.*) { - .manual => {}, .exec => |*exec| exec.threadExit(td), } } @@ -79,7 +68,6 @@ pub const Backend = union(Kind) { screen_size: renderer.ScreenSize, ) !void { switch (self.*) { - .manual => {}, .exec => |*exec| try exec.resize(grid_size, screen_size), } } @@ -92,7 +80,6 @@ pub const Backend = union(Kind) { linefeed: bool, ) !void { switch (self.*) { - .manual => {}, .exec => |*exec| try exec.queueWrite(alloc, td, data, linefeed), } } @@ -105,7 +92,6 @@ pub const Backend = union(Kind) { runtime_ms: u64, ) !void { switch (self.*) { - .manual => {}, .exec => |*exec| try exec.childExitedAbnormally( gpa, t, @@ -118,19 +104,16 @@ pub const Backend = union(Kind) { /// Termio thread data. See termio.ThreadData for docs. pub const ThreadData = union(Kind) { - manual: void, exec: termio.Exec.ThreadData, pub fn deinit(self: *ThreadData, alloc: Allocator) void { switch (self.*) { - .manual => {}, .exec => |*exec| exec.deinit(alloc), } } pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void { switch (self.*) { - .manual => {}, .exec => |*exec| { exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; exec.wait_after_command = config.wait_after_command; From 8cd901450ae7667887372d4d051b1e916f83e520 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 10:53:01 -0700 Subject: [PATCH 31/32] remove unused derived config values --- src/termio/Termio.zig | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index ef4f78b1f..fd7c36956 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -85,8 +85,6 @@ pub const DerivedConfig = struct { foreground: configpkg.Config.Color, background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, - term: []const u8, - grapheme_width_method: configpkg.Config.GraphemeWidthMethod, abnormal_runtime_threshold_ms: u32, wait_after_command: bool, enquiry_response: []const u8, @@ -108,8 +106,6 @@ pub const DerivedConfig = struct { .foreground = config.foreground, .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", - .term = try alloc.dupe(u8, config.term), - .grapheme_width_method = config.@"grapheme-width-method", .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", .wait_after_command = config.@"wait-after-command", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), @@ -142,7 +138,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { // Setup our initial grapheme cluster support if enabled. We use a // switch to ensure we get a compiler error if more cases are added. - switch (opts.config.grapheme_width_method) { + switch (opts.full_config.@"grapheme-width-method") { .unicode => term.modes.set(.grapheme_cluster, true), .legacy => {}, } From 524f24aaf5b21f9e0f8572093664b27a8930fc8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Jul 2024 15:23:39 -0700 Subject: [PATCH 32/32] termio: remove unused data --- src/termio/Termio.zig | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index fd7c36956..9459f9152 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -239,15 +239,8 @@ pub fn deinit(self: *Termio) void { } pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { - const alloc = self.alloc; - - // Wakeup watcher for the writer thread. - var wakeup = try xev.Async.init(); - errdefer wakeup.deinit(); - - // Setup our thread data data.* = .{ - .alloc = alloc, + .alloc = self.alloc, .loop = &thread.loop, .renderer_state = self.renderer_state, .surface_mailbox = self.surface_mailbox, @@ -256,7 +249,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo }; // Setup our backend - try self.backend.threadEnter(alloc, self, data); + try self.backend.threadEnter(self.alloc, self, data); } pub fn threadExit(self: *Termio, data: *ThreadData) void {