Fix clogged up git operations.
This commit is contained in:
1 parent
2e3af19d00
commit
d7452c2262
9 files changed
+315
-51
No files matched your search
+24
-5
@@ -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
@@ -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
@@ -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" {
|
||||
|
||||
Reference in new issue
Block a user