termio: move subprocess out to its own file

This commit is contained in:
Mitchell Hashimoto
2024-07-13 15:17:36 -07:00
parent 2e62e3354b
commit b3c2479f87
4 changed files with 604 additions and 3542 deletions

View File

@ -6,6 +6,7 @@ const stream_handler = @import("termio/stream_handler.zig");
pub usingnamespace @import("termio/message.zig"); pub usingnamespace @import("termio/message.zig");
pub const reader = @import("termio/reader.zig"); pub const reader = @import("termio/reader.zig");
pub const Exec = @import("termio/Exec.zig");
pub const Options = @import("termio/Options.zig"); pub const Options = @import("termio/Options.zig");
pub const Termio = @import("termio/Termio.zig"); pub const Termio = @import("termio/Termio.zig");
pub const Thread = @import("termio/Thread.zig"); pub const Thread = @import("termio/Thread.zig");

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ const termio = @import("../termio.zig");
const Command = @import("../Command.zig"); const Command = @import("../Command.zig");
const Pty = @import("../pty.zig").Pty; const Pty = @import("../pty.zig").Pty;
const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool;
const StreamHandler = @import("stream_handler.zig").StreamHandler;
const terminal = @import("../terminal/main.zig"); const terminal = @import("../terminal/main.zig");
const terminfo = @import("../terminfo/main.zig"); const terminfo = @import("../terminfo/main.zig");
const xev = @import("xev"); const xev = @import("xev");
@ -27,8 +28,6 @@ const windows = internal_os.windows;
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
const shell_integration = @import("shell_integration.zig"); const shell_integration = @import("shell_integration.zig");
const StreamHandler = @import("stream_handler.zig").StreamHandler;
const log = std.log.scoped(.io_exec); const log = std.log.scoped(.io_exec);
const c = @cImport({ const c = @cImport({
@ -46,7 +45,7 @@ const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw;
alloc: Allocator, alloc: Allocator,
/// This is the pty fd created for the subcommand. /// This is the pty fd created for the subcommand.
subprocess: Subprocess, subprocess: termio.Exec,
/// The derived configuration for this termio implementation. /// The derived configuration for this termio implementation.
config: DerivedConfig, config: DerivedConfig,
@ -169,7 +168,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Termio {
// Set our default cursor style // Set our default cursor style
term.screen.cursor.cursor_style = opts.config.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(); errdefer subprocess.deinit();
// If we have an initial pwd requested by the subprocess, then we // If we have an initial pwd requested by the subprocess, then we
@ -942,635 +941,6 @@ fn ttyWrite(
return .disarm; 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: /// The read thread sits in a loop doing the following pseudo code:
/// ///
/// while (true) { blocking_read(); exit_if_eof(); process(); } /// while (true) { blocking_read(); exit_if_eof(); process(); }

View File

@ -1,10 +1,18 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const posix = std.posix;
const xev = @import("xev"); const xev = @import("xev");
const build_config = @import("../build_config.zig");
const configpkg = @import("../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 termio = @import("../termio.zig");
const Command = @import("../Command.zig"); const Command = @import("../Command.zig");
const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; 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 // 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. // enough to satisfy most write requests. It must be a power of 2.
@ -22,7 +30,7 @@ pub const Config = union(enum) {
manual: void, manual: void,
/// Exec uses posix exec to run a command with a pty. /// Exec uses posix exec to run a command with a pty.
exec: Exec, exec: Config.Exec,
pub const Exec = struct { pub const Exec = struct {
command: ?[]const u8 = null, command: ?[]const u8 = null,
@ -36,7 +44,7 @@ pub const Config = union(enum) {
/// Termio thread data. See termio.ThreadData for docs. /// Termio thread data. See termio.ThreadData for docs.
pub const ThreadData = union(Kind) { pub const ThreadData = union(Kind) {
manual: void, manual: void,
exec: Exec, exec: ThreadData.Exec,
pub const Exec = struct { pub const Exec = struct {
/// Process start time and boolean of whether its already exited. /// Process start time and boolean of whether its already exited.