From bb9638890237e4d4faf1a2d14507367408135caa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 15 Jul 2025 15:36:43 -0700 Subject: [PATCH] apprt/gtk-ng: cgroup base setup --- src/apprt/gtk-ng/cgroup.zig | 213 +++++++++++++++++++++++++ src/apprt/gtk-ng/class/application.zig | 81 ++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/apprt/gtk-ng/cgroup.zig diff --git a/src/apprt/gtk-ng/cgroup.zig b/src/apprt/gtk-ng/cgroup.zig new file mode 100644 index 000000000..23c4d545e --- /dev/null +++ b/src/apprt/gtk-ng/cgroup.zig @@ -0,0 +1,213 @@ +/// 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 gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const App = @import("App.zig"); +const internal_os = @import("../../os/main.zig"); + +const log = std.log.scoped(.gtk_systemd_cgroup); + +pub const Options = struct { + memory_high: ?u64 = null, + pids_max: ?u64 = null, +}; + +/// 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. +/// +/// Returns the path of the current cgroup for the app, which is +/// allocated with the given allocator. +pub fn init( + alloc: Allocator, + dbus: *gio.DBusConnection, + opts: Options, +) ![]const u8 { + const pid = std.os.linux.getpid(); + + // 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(dbus, pid); + 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 (opts.memory_high) |limit| { + try internal_os.cgroup.configureLimit(surfaces, .{ + .memory_high = limit, + }); + } + + // Configure the "max" pids limit. This is a hard limit and cannot be + // exceeded. + if (opts.pids_max) |limit| { + try internal_os.cgroup.configureLimit(surfaces, .{ + .pids_max = 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 and +/// move our process into it. +fn createScope( + dbus: *gio.DBusConnection, + pid_: std.os.linux.pid_t, +) !void { + const pid: u32 = @intCast(pid_); + + // 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; + + const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))"); + defer glib.free(builder_type); + + // Initialize our builder to build up our parameters + var builder: glib.VariantBuilder = undefined; + builder.init(builder_type); + + builder.add("s", name.ptr); + builder.add("s", "fail"); + + { + // Properties + const properties_type = glib.VariantType.new("a(sv)"); + defer glib.free(properties_type); + + builder.open(properties_type); + defer builder.close(); + + // https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html + const pressure_value = glib.Variant.newString("kill"); + + builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value); + + // Delegate + const delegate_value = glib.Variant.newBoolean(1); + builder.add("(sv)", "Delegate", delegate_value); + + // Pid to move into the unit + const pids_value_type = glib.VariantType.new("u"); + defer glib.free(pids_value_type); + + const pids_value = glib.Variant.newFixedArray(pids_value_type, &pid, 1, @sizeOf(u32)); + + builder.add("(sv)", "PIDs", pids_value); + } + + { + // Aux + const aux_type = glib.VariantType.new("a(sa(sv))"); + defer glib.free(aux_type); + + builder.open(aux_type); + defer builder.close(); + } + + var err: ?*glib.Error = null; + defer if (err) |e| e.free(); + + const reply_type = glib.VariantType.new("(o)"); + defer glib.free(reply_type); + + const value = builder.end(); + + const reply = dbus.callSync( + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "StartTransientUnit", + value, + reply_type, + .{}, + -1, + null, + &err, + ) orelse { + if (err) |e| log.err( + "creating transient cgroup scope failed code={} err={s}", + .{ + e.f_code, + if (e.f_message) |msg| msg else "(no message)", + }, + ); + return error.DbusCallFailed; + }; + defer reply.unref(); +} diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 4d2ab42e4..2c807a8f9 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -8,6 +8,7 @@ const gobject = @import("gobject"); const build_config = @import("../../../build_config.zig"); const apprt = @import("../../../apprt.zig"); +const cgroup = @import("../cgroup.zig"); const CoreApp = @import("../../../App.zig"); const configpkg = @import("../../../config.zig"); const Config = configpkg.Config; @@ -50,6 +51,11 @@ pub const GhosttyApplication = extern struct { /// The configuration for the application. config: *Config, + /// 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, + /// This is set to false internally when the event loop /// should exit and the application should quit. This must /// only be set by the main loop thread. @@ -131,6 +137,7 @@ pub const GhosttyApplication = extern struct { const priv = self.private(); priv.config.deinit(); alloc.destroy(priv.config); + if (priv.transient_cgroup_base) |base| alloc.free(base); } /// Run the application. This is a replacement for `gio.Application.run` @@ -253,12 +260,20 @@ pub const GhosttyApplication = extern struct { // Setup our style manager (light/dark mode) self.startupStyleManager(); + // Setup our cgroup for the application. + self.startupCgroup() catch { + log.warn("TODO", .{}); + }; + gio.Application.virtual_methods.startup.call( Class.parent, self.as(Parent), ); } + /// Setup the style manager on startup. The primary task here is to + /// setup our initial light/dark mode based on the configuration and + /// setup listeners for changes to the style manager. fn startupStyleManager(self: *GhosttyApplication) void { const priv = self.private(); const config = priv.config; @@ -288,6 +303,72 @@ pub const GhosttyApplication = extern struct { ); } + const CgroupError = error{ + DbusConnectionFailed, + CgroupInitFailed, + }; + + /// Setup our cgroup for the application, if enabled. + /// + /// The setup for cgroups involves creating the cgroup for our + /// application, moving ourselves into it, and storing the base path + /// so that created surfaces can also have their own cgroups. + fn startupCgroup(self: *GhosttyApplication) CgroupError!void { + const priv = self.private(); + const config = priv.config; + + // If cgroup isolation isn't enabled then we don't do this. + if (!switch (config.@"linux-cgroup") { + .never => false, + .always => true, + .@"single-instance" => single: { + const flags = self.as(gio.Application).getFlags(); + break :single !flags.non_unique; + }, + }) { + log.info( + "cgroup isolation disabled via config={}", + .{config.@"linux-cgroup"}, + ); + return; + } + + // We need a dbus connection to do anything else + const dbus = self.as(gio.Application).getDbusConnection() orelse { + if (config.@"linux-cgroup-hard-fail") { + log.err("dbus connection required for cgroup isolation, exiting", .{}); + return error.DbusConnectionFailed; + } + + return; + }; + + const alloc = priv.core_app.alloc; + const path = cgroup.init(alloc, dbus, .{ + .memory_high = config.@"linux-cgroup-memory-limit", + .pids_max = config.@"linux-cgroup-processes-limit", + }) 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 (config.@"linux-cgroup-hard-fail") { + log.err("linux-cgroup-hard-fail enabled, exiting", .{}); + return error.CgroupInitFailed; + } + + return; + }; + + log.info("cgroup isolation enabled base={s}", .{path}); + priv.transient_cgroup_base = path; + } + fn activate(self: *GhosttyApplication) callconv(.C) void { // This is called when the application is activated, but we // don't need to do anything here since we handle activation