// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Each terminal's program in a cgroup of its own: a transient systemd //! scope, `app-dev.jcollie.harrier-.scope`, that the user's service //! manager makes for it. Then the program and everything it starts are //! accounted for, limited and, under memory pressure, killed apart from //! harrier and from each other — a runaway build in one tab is not the //! whole terminal. //! //! The program's process is forked, and waits before it execs while the //! user manager is asked, over the session bus, for a scope holding it //! (`StartTransientUnit`), and until `/proc//cgroup` shows it has been //! moved there; then it is let go. So the program, and anything it forks, //! starts in the scope rather than being moved there while it runs. A //! scope that cannot be had — no systemd, no session bus, the manager //! refusing — leaves the program in harrier's own cgroup, with a warning, //! unless the configuration requires one. //! //! The name follows systemd's convention for applications' scopes, which //! desktops read to tell what a process belongs to. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const linux = std.os.linux; const dbus = @import("dbus_service"); const mdbus = @import("mdbus"); const Manager = @import("dbus_interfaces").Manager; const Scopes = @This(); const log = std.log.scoped(.cgroup); pub const app_id = "dev.jcollie.harrier"; gpa: Allocator, io: Io, env: *const std.process.Environ.Map, /// The session bus, connected the first time a scope is asked for; null /// until then, or for good once that has failed. connection: ?mdbus.Connection = null, /// Connecting failed, and is not tried again. unavailable: bool = false, /// What a scope is made with, from the configuration. pub const Limits = struct { /// `MemoryHigh`, in bytes: past it, the scope's processes are throttled /// and reclaimed from hard. memory_high: ?u64 = null, /// `TasksMax`: how many processes and threads the scope may have. tasks_max: ?u64 = null, }; /// How long the program waits to be moved before it is let go anyway. const move_timeout_ms = 500; pub fn init(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) Scopes { return .{ .gpa = gpa, .io = io, .env = env }; } pub fn deinit(s: *Scopes) void { if (s.connection) |*c| c.deinit(); } pub const Error = error{ /// No session bus, or no user manager on it. NoSystemd, /// The manager would not make the scope. Refused, /// The manager made it, but the process was not in it in time. Timeout, } || Allocator.Error; /// Puts process `pid` in a scope of its own, and returns once it is there. pub fn place(s: *Scopes, pid: linux.pid_t, description: []const u8, limits: Limits) Error!void { const conn = try s.bus(); var arena_state: std.heap.ArenaAllocator = .init(s.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var name_buf: [96]u8 = undefined; const name = scopeName(&name_buf, pid); const pids = [_]u32{@intCast(pid)}; const pids_slice: []const u32 = &pids; const collect: []const u8 = "inactive-or-failed"; const oom: []const u8 = "kill"; var properties: std.ArrayList(struct { []const u8, dbus.Variant }) = .empty; try properties.append(arena, .{ "Description", .of("s", &description) }); try properties.append(arena, .{ "PIDs", .of("au", &pids_slice) }); // Gone once empty, even if it failed, so a later program with the same // ID gets a scope of its own name. try properties.append(arena, .{ "CollectMode", .of("s", &collect) }); // systemd-oomd kills the scope, not harrier, under memory pressure. try properties.append(arena, .{ "ManagedOOMMemoryPressure", .of("s", &oom) }); if (limits.memory_high) |*m| try properties.append(arena, .{ "MemoryHigh", .of("t", m) }); if (limits.tasks_max) |*t| try properties.append(arena, .{ "TasksMax", .of("t", t) }); var client: Manager.Client = .{ .connection = conn, .destination = "org.freedesktop.systemd1", .path = "/org/freedesktop/systemd1", .timeout_ms = 2_000, }; _ = client.call(Manager.StartTransientUnit, arena, .{ .name = name, .mode = "fail", .properties = properties.items, .aux = &.{}, }) catch |err| { if (client.failure) |f| log.warn("a scope for process {d}: {s}: {s}", .{ pid, f.name, f.message }); return switch (err) { error.OutOfMemory => error.OutOfMemory, else => error.Refused, }; }; // The job runs after the call returns; the move is done once the // kernel says so. const started = Io.Clock.awake.now(s.io); while (true) { if (inScope(pid, name)) return; if (started.durationTo(Io.Clock.awake.now(s.io)).toMilliseconds() > move_timeout_ms) return error.Timeout; _ = linux.nanosleep(&.{ .sec = 0, .nsec = std.time.ns_per_ms }, null); } } fn bus(s: *Scopes) Error!*mdbus.Connection { if (s.connection) |*c| return c; if (s.unavailable) return error.NoSystemd; errdefer s.unavailable = true; var addr_buf: [std.fs.max_path_bytes + 16]u8 = undefined; const address = sessionAddress(&addr_buf, s.env) orelse return error.NoSystemd; s.connection = mdbus.Connection.connectAddress(s.io, s.gpa, address) catch return error.NoSystemd; return &s.connection.?; } /// `app-dev.jcollie.harrier-.scope`. pub fn scopeName(buf: []u8, pid: linux.pid_t) []const u8 { return std.fmt.bufPrint(buf, "app-" ++ app_id ++ "-{d}.scope", .{pid}) catch unreachable; } /// Whether process `pid`'s cgroup, by `/proc//cgroup`, is the scope /// `name`. fn inScope(pid: linux.pid_t, name: []const u8) bool { var path_buf: [32]u8 = undefined; const path = std.fmt.bufPrintSentinel(&path_buf, "/proc/{d}/cgroup", .{pid}, 0) catch return false; const fd = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0); if (linux.errno(fd) != .SUCCESS) return false; defer _ = linux.close(@intCast(fd)); var buf: [1024]u8 = undefined; const n = linux.read(@intCast(fd), &buf, buf.len); if (linux.errno(n) != .SUCCESS) return false; return cgroupIs(buf[0..n], name); } /// Whether the unified hierarchy's line of a `/proc//cgroup` ends in /// the cgroup `name`. fn cgroupIs(text: []const u8, name: []const u8) bool { var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { if (!std.mem.startsWith(u8, line, "0::")) continue; const path = line[3..]; const last = if (std.mem.findScalarLast(u8, path, '/')) |i| path[i + 1 ..] else path; return std.mem.eql(u8, last, name); } return false; } fn sessionAddress(buf: []u8, env: *const std.process.Environ.Map) ?[]const u8 { if (env.get("DBUS_SESSION_BUS_ADDRESS")) |a| return a; const runtime = env.get("XDG_RUNTIME_DIR") orelse return null; return std.fmt.bufPrint(buf, "unix:path={s}/bus", .{runtime}) catch null; } const testing = std.testing; test "a scope is named for the application and the process" { var buf: [96]u8 = undefined; try testing.expectEqualStrings("app-dev.jcollie.harrier-4242.scope", scopeName(&buf, 4242)); } test "the unified hierarchy's cgroup is the one that counts" { const moved = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-dev.jcollie.harrier-4242.scope\n"; try testing.expect(cgroupIs(moved, "app-dev.jcollie.harrier-4242.scope")); const not_yet = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-dev.jcollie.harrier.service\n"; try testing.expect(!cgroupIs(not_yet, "app-dev.jcollie.harrier-4242.scope")); // A hybrid system's older hierarchies come first, and do not count. const hybrid = "12:pids:/app-dev.jcollie.harrier-4242.scope\n0::/elsewhere\n"; try testing.expect(!cgroupIs(hybrid, "app-dev.jcollie.harrier-4242.scope")); }