diff --git a/src/Surface.zig b/src/Surface.zig index 39c9b460e..2673b9722 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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(); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 35201ea8a..7575c547e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -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(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 794e2eaf4..d696d8b38 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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(); diff --git a/src/apprt/gtk/cgroup.zig b/src/apprt/gtk/cgroup.zig new file mode 100644 index 000000000..b936d83a1 --- /dev/null +++ b/src/apprt/gtk/cgroup.zig @@ -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; + }; +} diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b72360e7d..3a006a7b2 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -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()); diff --git a/src/config/Config.zig b/src/config/Config.zig index c17d1e42d..fdaa1d0c4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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", +}; diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig new file mode 100644 index 000000000..aee772954 --- /dev/null +++ b/src/os/cgroup.zig @@ -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//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}); +} diff --git a/src/os/main.zig b/src/os/main.zig index 1782601e0..cee592027 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -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"); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1287600eb..d6342deb3 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -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 { diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 1fd9d034a..079828f38 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -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;