Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Each terminal's program in a cgroup of its own: a transient systemd//! scope, `app-dev.jcollie.harrier-<pid>.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/<pid>/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-<pid>.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/<pid>/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/<pid>/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"));}