Fix clogged up git operations.

This commit is contained in:
Greyson Parrelli committed 2026-08-27 13:32:47 -04:00
1 parent 2e3af19d00
commit d7452c2262
9 files changed
+315 -51

No files matched your search

+24 -5
View File
@@ -6,6 +6,7 @@
//! else — process management, rendering, and the GTK4 UI — lives here.
const std = @import("std");
const builtin = @import("builtin");
const adw = @import("adw");
const gio = @import("gio");
@@ -21,10 +22,28 @@ pub const std_options: std.Options = .{
.log_level = .info,
};
var gpa: std.heap.DebugAllocator(.{}) = .init;
/// The process allocator, which everything in the app shares: the GTK main
/// loop, every terminal's parser, and every review connection thread.
///
/// `DebugAllocator` only in a debug build, and this is not a matter of taste.
/// It is thread-safe by way of one mutex around every allocation and every
/// free, and its backing allocator is the page allocator — so a large
/// allocation is an `mmap` and its release an `munmap`, both taken under that
/// single process-wide lock. A review pane's poll allocates a whole `git diff`
/// and drops it a moment later, several times a second across a handful of open
/// reviews, and each one of those was stalling the main loop and every other
/// request behind the same lock. `c_allocator` has a per-thread cache and no
/// global lock; libc is already linked for GTK.
var debug_gpa: std.heap.DebugAllocator(.{}) = .init;
fn gpa() std.mem.Allocator {
return if (builtin.mode == .Debug) debug_gpa.allocator() else std.heap.c_allocator;
}
pub fn main() u8 {
defer _ = gpa.deinit();
defer if (builtin.mode == .Debug) {
_ = debug_gpa.deinit();
};
// Before the allocator's own teardown, since the settings arena comes out
// of it. A no-op if the app never got as far as activating.
@@ -51,7 +70,7 @@ pub fn main() u8 {
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
// The file both of the next two read from: the scheme, and the tabs the
// window opens itself with.
Settings.init(gpa.allocator());
Settings.init(gpa());
// Before the window, so that the first frame is drawn in the scheme the
// user chose rather than repainted into it a moment later.
@@ -65,9 +84,9 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
// created and every terminal is handed the endpoint of the tab it opens in.
// Started unconditionally rather than on the first review pane, so an agent
// running in a tab has a `PLAYPEN_REVIEW_URL` from the moment it starts.
review.init(gpa.allocator());
review.init(gpa());
const window = Window.create(gpa.allocator(), app) catch |err| {
const window = Window.create(gpa(), app) catch |err| {
std.log.err("failed to create window: {s}", .{@errorName(err)});
return;
};
+111 -11
View File
@@ -69,6 +69,76 @@ const sse_heartbeat_ms = 15_000;
/// reconnect anyway.
const sse_backlog = 32;
/// How many git invocations may be in flight before a poll gives way.
///
/// Every review pane polls for a moved diff on a timer, and every one of those
/// polls is a `git diff` over a whole work tree. With a review open in seven
/// tabs that is a git process starting somewhere in this app most of the time,
/// on top of whatever the terminal beside it is already doing to the same
/// repository — which is usually a build. Unbounded, they land on the machine
/// all at once and the request someone is actually waiting on finishes last.
///
/// So polls yield and nothing else does. This is the threshold they yield at;
/// `GitLoad` is the rule itself.
const git_busy_at = 4;
/// What git is doing right now, and who for.
///
/// The distinction this exists to make is between a git invocation somebody is
/// waiting on — a diff being opened, a **Refresh** being clicked, an agent
/// fetching the patch it is about to review — and one that is a timer firing in
/// a page that will not look any different if it never gets an answer.
///
/// The first kind never waits. Not for a slot, not behind a queue, not for
/// anything: a person who clicks refresh has told you exactly what they want and
/// making them wait for a poll to finish first is indefensible. It runs
/// immediately and every time.
///
/// The second kind yields to the first. A poll runs only when nothing is being
/// waited on and the app is not already busy with `git_busy_at` of them, and
/// otherwise is turned away at once — which costs nothing, because the page
/// ignores a failed poll and asks again on its next tick. So a click does not
/// merely jump the queue; it clears the field of polls for as long as it runs.
const GitLoad = struct {
mutex: std.Io.Mutex = .init,
/// Invocations with somebody on the other end of them.
waited_on: u32 = 0,
/// Every invocation in flight, polls included.
total: u32 = 0,
/// Start work someone is waiting on. Never refused, never delayed.
fn enter(self: *GitLoad, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.waited_on += 1;
self.total += 1;
}
fn leave(self: *GitLoad, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.waited_on -= 1;
self.total -= 1;
}
/// Start a poll, if this is a moment for one. False means it is not.
fn enterPoll(self: *GitLoad, io: std.Io) bool {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.waited_on > 0 or self.total >= git_busy_at) return false;
self.total += 1;
return true;
}
fn leavePoll(self: *GitLoad, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.total -= 1;
}
};
/// Cap on a request body. Comment bodies are prose; anything larger is a mistake
/// or an attack, and either way there is nothing here worth the memory.
const max_body = 4 * 1024 * 1024;
@@ -149,6 +219,9 @@ mutex: std.Io.Mutex = .init,
tabs: std.ArrayListUnmanaged(*Tab) = .empty,
clients: std.ArrayListUnmanaged(*Client) = .empty,
/// Who git is working for at the moment. See `GitLoad`.
git_load: GitLoad = .{},
listener: ?std.Io.net.Server = null,
port: u16 = 0,
@@ -648,7 +721,11 @@ fn handleRepo(
tab: Resolved,
) !void {
const repo = tab.repo orelse return writeJson(a, request, .ok, .{ .open = false });
const info = git.info(repo, a, self.io);
const info = blk: {
self.git_load.enter(self.io);
defer self.git_load.leave(self.io);
break :blk git.info(repo, a, self.io);
};
const store = tab.store.?;
return writeJson(a, request, .ok, .{
@@ -741,7 +818,11 @@ fn handleDiff(
.ignore_whitespace = queryFlag(a, query, "ignoreWhitespace") catch false,
};
const payload = git.diff(repo, a, self.io, ctx, opts) catch |err| return writeError(
self.git_load.enter(self.io);
const produced = git.diff(repo, a, self.io, ctx, opts);
self.git_load.leave(self.io);
const payload = produced catch |err| return writeError(
a,
request,
if (err == error.BadCommit) .bad_request else .bad_gateway,
@@ -774,7 +855,28 @@ fn handleRevision(
.ignore_whitespace = queryFlag(a, query, "ignoreWhitespace") catch false,
};
const rev = git.revision(repo, a, self.io, ctx, opts) catch |err| return writeError(
// Best-effort, unlike every other git route here: this is a timer firing in
// a page that will look no different if the answer never comes, and the
// refresh somebody just clicked in the next tab wants git more. A poll that
// arrives at a bad moment says so and is asked again on the next tick — see
// `GitLoad`, and `POLL_MS` in the page for what it does with a refusal.
if (!self.git_load.enterPoll(self.io)) return writeError(
a,
request,
.service_unavailable,
"git is busy with something someone is waiting on — poll again shortly",
);
defer self.git_load.leavePoll(self.io);
// The branch rides along with the digest so the page can notice that the
// work tree moved to another branch — which the digest alone cannot always
// say. The digest hashes HEAD's *sha*, so branching off the commit you are
// already on (`git switch -c`) leaves it identical while the name in the
// review's header is now wrong. Both come out of one `rev-parse`, which is
// also the sha the digest wants — see `git.head`.
const at = git.head(repo, a, self.io);
const rev = git.revision(repo, a, self.io, ctx, opts, at.sha) catch |err| return writeError(
a,
request,
if (err == error.BadCommit) .bad_request else .bad_gateway,
@@ -783,15 +885,9 @@ fn handleRevision(
else => "git could not produce that diff — check the base ref",
},
);
// The branch rides along with the digest so the page can notice that the
// work tree moved to another branch — which the digest alone cannot always
// say. `digest` hashes HEAD's *sha*, so branching off the commit you are
// already on (`git switch -c`) leaves it identical while the name in the
// review's header is now wrong. One `rev-parse` next to the `git diff` this
// endpoint already runs is not a cost worth avoiding.
return writeJson(a, request, .ok, .{
.revision = rev,
.branch = git.currentBranch(repo, a, self.io),
.branch = at.branch,
});
}
@@ -812,7 +908,11 @@ fn handleFile(
// A miss is ordinary — a newly added file has no base version — so this is a
// 404 with no detail rather than something the UI has to explain.
const content = git.fileAt(repo, a, self.io, ref, path) catch return notFound(request);
self.git_load.enter(self.io);
const read = git.fileAt(repo, a, self.io, ref, path);
self.git_load.leave(self.io);
const content = read catch return notFound(request);
return request.respond(content, .{
.extra_headers = &.{.{ .name = "content-type", .value = "text/plain; charset=utf-8" }},
});
+64 -8
View File
@@ -25,6 +25,14 @@ const model = @import("model.zig");
/// forever.
const timeout_s = 60;
/// The same, for the poll a review pane runs on a timer.
///
/// Much shorter, because a poll is disposable and holds one of the app's git
/// slots while it runs: a wedged one would otherwise keep a slot from the diff
/// somebody is waiting to open for a whole minute, and the answer it eventually
/// came back with would be a minute stale anyway.
const poll_timeout_s = 15;
/// Cap on what one git invocation may print. A patch is the big one: the
/// oversize guard below is what normally keeps it in hand, and this is the
/// backstop for the cases the guard cannot see coming.
@@ -71,6 +79,37 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Error!Repo {
}
/// The short name of the checked-out branch, or "HEAD" when detached.
/// What HEAD is, both ways, from a single `rev-parse`.
///
/// The poll wants the sha (it goes into the digest) and the branch name (the
/// page notices a `git switch` with it) on every tick, and asking for them
/// separately was two process spawns where git will answer both from one — a
/// difference worth having when this runs once per review pane per tick.
///
/// A repository with no commits has no HEAD to resolve, which is not an error
/// here: the sha comes back empty — the digest is then the patch alone, exactly
/// as `digest` has always handled it — and the name falls back to `HEAD`, which
/// is what `currentBranch` answers for the same case.
pub const Head = struct {
sha: []const u8,
branch: []const u8,
};
pub fn head(repo: Repo, gpa: std.mem.Allocator, io: std.Io) Head {
const out = runFor(gpa, io, repo.path, &.{
"rev-parse", "HEAD", "--abbrev-ref", "HEAD",
}, poll_timeout_s) catch return .{ .sha = "", .branch = "HEAD" };
// Two lines, in the order the arguments were given: the sha, then the name.
var it = std.mem.splitScalar(u8, trim(out), '\n');
const sha = trim(it.next() orelse "");
const branch = trim(it.next() orelse "");
return .{
.sha = sha,
.branch = if (branch.len == 0) "HEAD" else branch,
};
}
pub fn currentBranch(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const u8 {
const out = run(gpa, io, repo.path, &.{ "rev-parse", "--abbrev-ref", "HEAD" }) catch
return "HEAD";
@@ -360,13 +399,14 @@ pub fn revision(
io: std.Io,
ctx: model.DiffContext,
opts: Options,
head_sha: []const u8,
) Error![]const u8 {
try validateCommit(ctx.commit);
const patch = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
const patch = try runFor(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
"--no-color",
"--find-renames",
}));
return digest(repo, gpa, io, patch);
}), poll_timeout_s);
return digestOf(gpa, head_sha, patch);
}
/// The digest itself, given a patch already in hand: the patch, hashed, plus
@@ -387,9 +427,15 @@ fn digest(
) Error![]const u8 {
// Best-effort: a repository with no commits has no HEAD to resolve, and the
// patch on its own is still a usable digest.
const head = run(gpa, io, repo.path, &.{ "rev-parse", "HEAD" }) catch "";
const at = run(gpa, io, repo.path, &.{ "rev-parse", "HEAD" }) catch "";
return digestOf(gpa, trim(at), patch);
}
/// The hash itself, for a caller that has already resolved HEAD — which the
/// poll has, because it needs the branch name from the same `rev-parse`.
fn digestOf(gpa: std.mem.Allocator, head_sha: []const u8, patch: []const u8) Error![]const u8 {
var hasher = std.hash.Wyhash.init(0);
hasher.update(trim(head));
hasher.update(head_sha);
hasher.update(patch);
return std.fmt.allocPrint(gpa, "{x}", .{hasher.final()});
}
@@ -660,6 +706,16 @@ fn run(
io: std.Io,
dir: []const u8,
args: []const []const u8,
) Error![]const u8 {
return runFor(gpa, io, dir, args, timeout_s);
}
fn runFor(
gpa: std.mem.Allocator,
io: std.Io,
dir: []const u8,
args: []const []const u8,
seconds: u32,
) Error![]const u8 {
var argv: std.ArrayListUnmanaged([]const u8) = .empty;
try argv.ensureTotalCapacity(gpa, args.len + 1);
@@ -671,7 +727,7 @@ fn run(
.cwd = .{ .path = dir },
.stdout_limit = .limited(max_output),
.stderr_limit = .limited(64 * 1024),
.timeout = .{ .duration = .{ .raw = .fromSeconds(timeout_s), .clock = .awake } },
.timeout = .{ .duration = .{ .raw = .fromSeconds(seconds), .clock = .awake } },
}) catch |err| {
std.log.warn("review: git {s}: {s}", .{ args[0], @errorName(err) });
return error.GitFailed;
@@ -850,8 +906,8 @@ test "diffArgs picks the right git subcommand for each selection" {
}), one);
// An empty base is HEAD, which is what a review opens on.
const head = try diffArgs(a, .{ .uncommitted = true }, .{}, &.{});
try std.testing.expectEqualDeep(@as([]const []const u8, &.{ "diff", "HEAD" }), head);
const bare = try diffArgs(a, .{ .uncommitted = true }, .{}, &.{});
try std.testing.expectEqualDeep(@as([]const []const u8, &.{ "diff", "HEAD" }), bare);
}
test "oversized counts lines, and files for the binary case" {