Merge pull request #1828 from ghostty-org/cgroup

Linux: Launch each terminal surface into its own cgroup
This commit is contained in:
Mitchell Hashimoto
2024-06-06 09:28:38 -07:00
committed by GitHub
10 changed files with 533 additions and 8 deletions

View File

@ -404,6 +404,16 @@ pub fn init(
.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
termio.Options.linux_cgroup_default,
});
errdefer io.deinit();

View File

@ -23,6 +23,7 @@ const CoreSurface = @import("../../Surface.zig");
const build_options = @import("build_options");
const cgroup = @import("cgroup.zig");
const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
@ -43,6 +44,9 @@ config: Config,
app: *c.GtkApplication,
ctx: *c.GMainContext,
/// True if the app was launched with single instance mode.
single_instance: bool,
/// The "none" cursor. We use one that is shared across the entire app.
cursor_none: ?*c.GdkCursor,
@ -61,6 +65,11 @@ running: bool = true,
/// Xkb state (X11 only). Will be null on Wayland.
x11_xkb: ?x11.Xkb = null,
/// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful.
transient_cgroup_base: ?[]const u8 = null,
pub fn init(core_app: *CoreApp, opts: Options) !App {
_ = opts;
@ -263,6 +272,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.ctx = ctx,
.cursor_none = cursor_none,
.x11_xkb = x11_xkb,
.single_instance = single_instance,
// If we are NOT the primary instance, then we never want to run.
// This means that another instance of the GTK app is running and
// our "activate" call above will open a window.
@ -280,6 +290,7 @@ pub fn terminate(self: *App) void {
if (self.cursor_none) |cursor| c.g_object_unref(cursor);
if (self.menu) |menu| c.g_object_unref(menu);
if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path);
self.config.deinit();
}
@ -373,9 +384,42 @@ pub fn wakeup(self: App) void {
/// Run the event loop. This doesn't return until the app exits.
pub fn run(self: *App) !void {
// Running will be false when we're not the primary instance and should
// exit (GTK single instance mode). If we're not running, we're done
// right away.
if (!self.running) return;
// If we're not remote, then we also setup our actions and menus.
// If we are running, then we proceed to setup our app.
// Setup our cgroup configurations for our surfaces.
if (switch (self.config.@"linux-cgroup") {
.never => false,
.always => true,
.@"single-instance" => self.single_instance,
}) cgroup: {
const path = cgroup.init(self) catch |err| {
// If we can't initialize cgroups then that's okay. We
// want to continue to run so we just won't isolate surfaces.
// NOTE(mitchellh): do we want a config to force it?
log.warn(
"failed to initialize cgroups, terminals will not be isolated err={}",
.{err},
);
// If we have hard fail enabled then we exit now.
if (self.config.@"linux-cgroup-hard-fail") {
log.err("linux-cgroup-hard-fail enabled, exiting", .{});
return error.CgroupInitFailed;
}
break :cgroup;
};
log.info("cgroup isolation enabled base={s}", .{path});
self.transient_cgroup_base = path;
} else log.debug("cgroup isoation disabled config={}", .{self.config.@"linux-cgroup"});
// Setup our menu items
self.initActions();
self.initMenu();

View File

@ -11,6 +11,7 @@ const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const terminal = @import("../../terminal/main.zig");
const CoreSurface = @import("../../Surface.zig");
const internal_os = @import("../../os/main.zig");
const App = @import("App.zig");
const Split = @import("Split.zig");
@ -255,6 +256,10 @@ im_commit_buffered: bool = false,
im_buf: [128]u8 = undefined,
im_len: u7 = 0,
/// The surface-specific cgroup path. See App.transient_cgroup_path for
/// details on what this is.
cgroup_path: ?[]const u8 = null,
pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface {
var surface = try alloc.create(Surface);
errdefer alloc.destroy(surface);
@ -342,6 +347,36 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
break :font_size parent.font_size;
};
// If the parent has a transient cgroup, then we're creating cgroups
// for each surface if we can. We need to create a child cgroup.
const cgroup_path: ?[]const u8 = cgroup: {
const base = app.transient_cgroup_base orelse break :cgroup null;
// For the unique group name we use the self pointer. This may
// not be a good idea for security reasons but not sure yet. We
// may want to change this to something else eventually to be safe.
var buf: [256]u8 = undefined;
const name = std.fmt.bufPrint(
&buf,
"surfaces/{X}.service",
.{@intFromPtr(self)},
) catch unreachable;
// Create the cgroup. If it fails, no big deal... just ignore.
internal_os.cgroup.create(base, name, null) catch |err| {
log.err("failed to create surface cgroup err={}", .{err});
break :cgroup null;
};
// Success, save the cgroup path.
break :cgroup std.fmt.allocPrint(
app.core_app.alloc,
"{s}/{s}",
.{ base, name },
) catch null;
};
errdefer if (cgroup_path) |path| app.core_app.alloc.free(path);
// Build our result
self.* = .{
.app = app,
@ -354,6 +389,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.im_context = im_context,
.cgroup_path = cgroup_path,
};
errdefer self.* = undefined;
@ -442,9 +478,10 @@ pub fn deinit(self: *Surface) void {
self.core_surface.deinit();
self.core_surface = undefined;
if (self.cgroup_path) |path| self.app.core_app.alloc.free(path);
// Free all our GTK stuff
c.g_object_unref(self.im_context);
if (self.cursor) |cursor| c.g_object_unref(cursor);
}
@ -463,6 +500,11 @@ fn render(self: *Surface) !void {
try self.core_surface.renderer.drawFrame(self);
}
/// Called by core surface to get the cgroup.
pub fn cgroup(self: *const Surface) ?[]const u8 {
return self.cgroup_path;
}
/// Queue the inspector to render if we have one.
pub fn queueInspectorRender(self: *Surface) void {
if (self.inspector) |v| v.queueRender();

183
src/apprt/gtk/cgroup.zig Normal file
View File

@ -0,0 +1,183 @@
/// Contains all the logic for putting the Ghostty process and
/// each individual surface into its own cgroup.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const c = @import("c.zig");
const App = @import("App.zig");
const internal_os = @import("../../os/main.zig");
const log = std.log.scoped(.gtk_systemd_cgroup);
/// Initialize the cgroup for the app. This will create our
/// transient scope, initialize the cgroups we use for the app,
/// configure them, and return the cgroup path for the app.
pub fn init(app: *App) ![]const u8 {
const pid = std.os.linux.getpid();
const alloc = app.core_app.alloc;
const connection = c.g_application_get_dbus_connection(@ptrCast(app.app)) orelse
return error.DbusConnectionRequired;
// Get our initial cgroup. We need this so we can compare
// and detect when we've switched to our transient group.
const original = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
defer alloc.free(original);
// Create our transient scope. If this succeeds then the unit
// was created, but we may not have moved into it yet, so we need
// to do a dumb busy loop to wait for the move to complete.
try createScope(connection);
const transient = transient: while (true) {
const current = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
if (!std.mem.eql(u8, original, current)) break :transient current;
alloc.free(current);
std.time.sleep(25 * std.time.ns_per_ms);
};
errdefer alloc.free(transient);
log.info("transient scope created cgroup={s}", .{transient});
// Create the app cgroup and put ourselves in it. This is
// required because controllers can't be configured while a
// process is in a cgroup.
try internal_os.cgroup.create(transient, "app", pid);
// Create a cgroup that will contain all our surfaces. We will
// enable the controllers and configure resource limits for surfaces
// only on this cgroup so that it doesn't affect our main app.
try internal_os.cgroup.create(transient, "surfaces", null);
const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient});
defer alloc.free(surfaces);
// Enable all of our cgroup controllers. If these fail then
// we just log. We can't reasonably undo what we've done above
// so we log the warning and still return the transient group.
// I don't know a scenario where this fails yet.
try enableControllers(alloc, transient);
try enableControllers(alloc, surfaces);
// Configure the "high" memory limit. This limit is used instead
// of "max" because it's a soft limit that can be exceeded and
// can be monitored by things like systemd-oomd to kill if needed,
// versus an instant hard kill.
if (app.config.@"linux-cgroup-memory-limit") |limit| {
try internal_os.cgroup.configureMemoryLimit(surfaces, .{
.high = limit,
});
}
return transient;
}
/// Enable all the cgroup controllers for the given cgroup.
fn enableControllers(alloc: Allocator, cgroup: []const u8) !void {
const raw = try internal_os.cgroup.controllers(alloc, cgroup);
defer alloc.free(raw);
// Build our string builder for enabling all controllers
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
// Controllers are space-separated
var it = std.mem.splitScalar(u8, raw, ' ');
while (it.next()) |controller| {
try builder.append('+');
try builder.appendSlice(controller);
if (it.rest().len > 0) try builder.append(' ');
}
// Enable them all
try internal_os.cgroup.configureControllers(
cgroup,
builder.items,
);
}
/// Create a transient systemd scope unit for the current process.
///
/// On success this will return the name of the transient scope
/// cgroup prefix, allocated with the given allocator.
fn createScope(connection: *c.GDBusConnection) !void {
// Our pid that we will move into the cgroup
const pid: c.guint32 = @intCast(std.os.linux.getpid());
// The unit name needs to be unique. We use the pid for this.
var name_buf: [256]u8 = undefined;
const name = std.fmt.bufPrintZ(
&name_buf,
"app-ghostty-transient-{}.scope",
.{pid},
) catch unreachable;
// Initialize our builder to build up our parameters
var builder: c.GVariantBuilder = undefined;
c.g_variant_builder_init(&builder, c.G_VARIANT_TYPE("(ssa(sv)a(sa(sv)))"));
c.g_variant_builder_add(&builder, "s", name.ptr);
c.g_variant_builder_add(&builder, "s", "fail");
{
// Properties
c.g_variant_builder_open(&builder, c.G_VARIANT_TYPE("a(sv)"));
defer c.g_variant_builder_close(&builder);
// https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html
c.g_variant_builder_add(
&builder,
"(sv)",
"ManagedOOMMemoryPressure",
c.g_variant_new_string("kill"),
);
// Delegate
c.g_variant_builder_add(
&builder,
"(sv)",
"Delegate",
c.g_variant_new_boolean(1),
);
// Pid to move into the unit
c.g_variant_builder_add(
&builder,
"(sv)",
"PIDs",
c.g_variant_new_fixed_array(
c.G_VARIANT_TYPE("u"),
&pid,
1,
@sizeOf(c.guint32),
),
);
}
{
// Aux
c.g_variant_builder_open(&builder, c.G_VARIANT_TYPE("a(sa(sv))"));
defer c.g_variant_builder_close(&builder);
}
var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e);
_ = c.g_dbus_connection_call_sync(
connection,
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",
"StartTransientUnit",
c.g_variant_builder_end(&builder),
c.G_VARIANT_TYPE("(o)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
&err,
) orelse {
if (err) |e| log.err(
"creating transient cgroup scope failed code={} err={s}",
.{ e.code, e.message },
);
return error.DbusCallFailed;
};
}

View File

@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions();
fn comptimeGenerateFishCompletions() []const u8 {
comptime {
@setEvalBranchQuota(13000);
@setEvalBranchQuota(15000);
var counter = std.io.countingWriter(std.io.null_writer);
try writeFishCompletions(&counter.writer());

View File

@ -988,6 +988,56 @@ keybind: Keybinds = .{},
/// This does not work with GLFW builds.
@"macos-option-as-alt": OptionAsAlt = .false,
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
///
/// This makes it so that resource management can be done on a per-surface
/// granularity. For example, if a shell program is using too much memory,
/// only that shell will be killed by the oom monitor instead of the entire
/// Ghostty process. Similarly, if a shell program is using too much CPU,
/// only that surface will be CPU-throttled.
///
/// This will cause startup times to be slower (a hundred milliseconds or so),
/// so the default value is "single-instance." In single-instance mode, only
/// one instance of Ghostty is running (see gtk-single-instance) so the startup
/// time is a one-time cost. Additionally, single instance Ghostty is much
/// more likely to have many windows, tabs, etc. so cgroup isolation is a
/// big benefit.
///
/// This feature requires systemd. If systemd is unavailable, cgroup
/// initialization will fail. By default, this will not prevent Ghostty
/// from working (see linux-cgroup-hard-fail).
///
/// Valid values are:
///
/// * `never` - Never use cgroups.
/// * `always` - Always use cgroups.
/// * `single-instance` - Enable cgroups only for Ghostty instances launched
/// as single-instance applications (see gtk-single-instance).
///
@"linux-cgroup": LinuxCgroup = .@"single-instance",
/// Memory limit for any individual terminal process (tab, split, window,
/// etc.) in bytes. If this is unset then no memory limit will be set.
///
/// Note that this sets the "memory.high" configuration for the memory
/// controller, which is a soft limit. You should configure something like
/// systemd-oom to handle killing processes that have too much memory
/// pressure.
@"linux-cgroup-memory-limit": ?u64 = null,
/// If this is false, then any cgroup initialization (for linux-cgroup)
/// will be allowed to fail and the failure is ignored. This is useful if
/// you view cgroup isolation as a "nice to have" and not a critical resource
/// management feature, because Ghostty startup will not fail if cgroup APIs
/// fail.
///
/// If this is true, then any cgroup initialization failure will cause
/// Ghostty to exit or new surfaces to not be created.
///
/// Note: this currently only affects cgroup initialization. Subprocesses
/// must always be able to move themselves into an isolated cgroup.
@"linux-cgroup-hard-fail": bool = false,
/// If true, the Ghostty GTK application will run in single-instance mode:
/// each new `ghostty` process launched will result in a new window if there
/// is already a running process.
@ -3495,3 +3545,10 @@ pub const GraphemeWidthMethod = enum {
legacy,
unicode,
};
/// See linux-cgroup
pub const LinuxCgroup = enum {
never,
always,
@"single-instance",
};

149
src/os/cgroup.zig Normal file
View File

@ -0,0 +1,149 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
/// Returns the path to the cgroup for the given pid.
pub fn current(alloc: Allocator, pid: std.os.linux.pid_t) !?[]const u8 {
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
// line. The first line will look something like this:
// 0::/user.slice/user-1000.slice/session-1.scope
// The cgroup path is the third field.
const path = try std.fmt.bufPrint(&buf, "/proc/{}/cgroup", .{pid});
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
// Read it all into memory -- we don't expect this file to ever be that large.
var buf_reader = std.io.bufferedReader(file.reader());
const contents = try buf_reader.reader().readAllAlloc(
alloc,
1 * 1024 * 1024, // 1MB
);
defer alloc.free(contents);
// Find the last ':'
const idx = std.mem.lastIndexOfScalar(u8, contents, ':') orelse return null;
const result = std.mem.trimRight(u8, contents[idx + 1 ..], " \r\n");
return try alloc.dupe(u8, result);
}
/// Create a new cgroup. This will not move any process into it unless move is
/// set. If move is set, the given pid will be moved into the created cgroup.
pub fn create(
cgroup: []const u8,
child: []const u8,
move: ?std.os.linux.pid_t,
) !void {
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}/{s}", .{ cgroup, child });
try std.fs.cwd().makePath(path);
// If we have a PID to move into the cgroup immediately, do it.
if (move) |pid| {
const pid_path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/{s}/cgroup.procs",
.{ cgroup, child },
);
const file = try std.fs.cwd().openFile(pid_path, .{ .mode = .write_only });
defer file.close();
try file.writer().print("{}", .{pid});
}
}
/// Move the given PID into the given cgroup.
pub fn moveInto(
cgroup: []const u8,
pid: std.os.linux.pid_t,
) !void {
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}/cgroup.procs", .{cgroup});
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
defer file.close();
try file.writer().print("{}", .{pid});
}
/// Returns all available cgroup controllers for the given cgroup.
/// The cgroup should have a '/'-prefix.
///
/// The returned list of is the raw space-separated list of
/// controllers from the /sys/fs directory. This avoids some extra
/// work since creating an iterator over this is easy and much cheaper
/// than allocating a bunch of copies for an array.
pub fn controllers(alloc: Allocator, cgroup: []const u8) ![]const u8 {
assert(cgroup[0] == '/');
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
// Read the available controllers. These will be space separated.
const path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/cgroup.controllers",
.{cgroup},
);
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
// Read it all into memory -- we don't expect this file to ever
// be that large.
var buf_reader = std.io.bufferedReader(file.reader());
const contents = try buf_reader.reader().readAllAlloc(
alloc,
1 * 1024 * 1024, // 1MB
);
defer alloc.free(contents);
// Return our raw list of controllers
const result = std.mem.trimRight(u8, contents, " \r\n");
return try alloc.dupe(u8, result);
}
/// Configure the set of controllers in the cgroup. The "v" should
/// be in a valid format for "cgroup.subtree_control"
pub fn configureControllers(
cgroup: []const u8,
v: []const u8,
) !void {
assert(cgroup[0] == '/');
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
// Read the available controllers. These will be space separated.
const path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/cgroup.subtree_control",
.{cgroup},
);
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
defer file.close();
// Write
try file.writer().writeAll(v);
}
pub const MemoryLimit = union(enum) {
/// memory.high
high: usize,
};
/// Configure the memory limit for the given cgroup. Use the various
/// fields in MemoryLimit to configure a specific type of limit.
pub fn configureMemoryLimit(cgroup: []const u8, limit: MemoryLimit) !void {
assert(cgroup[0] == '/');
const filename, const size = switch (limit) {
.high => |v| .{ "memory.high", v },
};
// Open our file
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
const path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/{s}",
.{ cgroup, filename },
);
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
defer file.close();
// Write our limit in bytes
try file.writer().print("{}", .{size});
}

View File

@ -13,6 +13,7 @@ pub usingnamespace @import("open.zig");
pub usingnamespace @import("pipe.zig");
pub usingnamespace @import("resourcesdir.zig");
pub const TempDir = @import("TempDir.zig");
pub const cgroup = @import("cgroup.zig");
pub const passwd = @import("passwd.zig");
pub const xdg = @import("xdg.zig");
pub const windows = @import("windows.zig");

View File

@ -179,7 +179,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec {
term.width_px = subprocess.screen_size.width;
term.height_px = subprocess.screen_size.height;
return Exec{
return .{
.alloc = alloc,
.terminal = term,
.subprocess = subprocess,
@ -897,6 +897,7 @@ const Subprocess = struct {
pty: ?Pty = null,
command: ?Command = null,
flatpak_command: ?FlatpakHostCommand = null,
linux_cgroup: termio.Options.LinuxCgroup = termio.Options.linux_cgroup_default,
/// Initialize the subprocess. This will NOT start it, this only sets
/// up the internal state necessary to start it later.
@ -1193,6 +1194,15 @@ const Subprocess = struct {
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: termio.Options.LinuxCgroup = cgroup: {
const default = termio.Options.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);
@ -1203,6 +1213,7 @@ const Subprocess = struct {
.args = args,
.grid_size = opts.grid_size,
.screen_size = padded_size,
.linux_cgroup = linux_cgroup,
};
}
@ -1296,18 +1307,23 @@ const Subprocess = struct {
.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 p = cmd.getData(Pty) orelse unreachable;
p.childPreExec() catch |err|
log.err("error initializing child: {}", .{err});
const sp = cmd.getData(Subprocess) orelse unreachable;
sp.childPreExec() catch |err| log.err(
"error initializing child: {}",
.{err},
);
}
}).callback,
.data = &self.pty.?,
.data = self,
};
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) {
@ -1323,6 +1339,21 @@ const Subprocess = struct {
};
}
/// 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();
// If we have a cgroup set, then we want to move into that cgroup.
if (comptime builtin.os.tag == .linux) {
if (self.linux_cgroup) |cgroup| {
try internal_os.cgroup.moveInto(cgroup, 0);
}
}
}
/// Called to notify that we exited externally so we can unset our
/// running state.
pub fn externalExit(self: *Subprocess) void {

View File

@ -1,5 +1,6 @@
//! The options that are used to configure a terminal IO implementation.
const builtin = @import("builtin");
const xev = @import("xev");
const apprt = @import("../apprt.zig");
const renderer = @import("../renderer.zig");
@ -41,3 +42,10 @@ 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: LinuxCgroup = linux_cgroup_default,
pub const LinuxCgroup = if (builtin.os.tag == .linux) ?[]const u8 else void;
pub const linux_cgroup_default = if (LinuxCgroup == void) {} else null;