Skip to content

Commit aa2edb8

Browse files
authored
fix(src): prevent boo ui freeze from daemon write deadlock (#89)
The session daemon wrote to clients with blocking writes while boo ui did un-timed control round-trips, so a large startup repaint (helix/nvim) could deadlock the daemon and every ui. Make daemon client writes non-blocking (queue, then flush on POLLOUT, with a bounded cap) and time-bound the ui's control polls as a safety net. Fixes #85
1 parent 10f1138 commit aa2edb8

5 files changed

Lines changed: 342 additions & 26 deletions

File tree

‎src/client.zig‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,15 @@ pub const ControlResult = struct {
364364
text: []u8,
365365
};
366366

367-
/// Send a single control command (-X) and wait for the reply.
367+
/// Send a single control command (-X) and wait for the reply. When
368+
/// `timeout_ms` is non-null, give up with `error.Timeout` if no reply
369+
/// arrives in that window, so a caller (notably `boo ui`) cannot hang
370+
/// forever on a daemon that has stopped answering.
368371
pub fn control(
369372
alloc: std.mem.Allocator,
370373
socket_path: []const u8,
371374
argv: []const []const u8,
375+
timeout_ms: ?u32,
372376
) !ControlResult {
373377
const sock = try connect(alloc, socket_path);
374378
defer posix.close(sock);
@@ -380,7 +384,20 @@ pub fn control(
380384
var decoder: protocol.Decoder = .init(alloc);
381385
defer decoder.deinit();
382386
var buf: [4096]u8 = undefined;
387+
const deadline: ?i64 = if (timeout_ms) |ms|
388+
std.time.milliTimestamp() + ms
389+
else
390+
null;
383391
while (true) {
392+
if (deadline) |dl| {
393+
const now = std.time.milliTimestamp();
394+
if (now >= dl) return error.Timeout;
395+
var fds = [_]posix.pollfd{
396+
.{ .fd = sock, .events = posix.POLL.IN, .revents = 0 },
397+
};
398+
const ready = posix.poll(&fds, @intCast(dl - now)) catch return error.Timeout;
399+
if (ready == 0) return error.Timeout;
400+
}
384401
const n = posix.read(sock, &buf) catch 0;
385402
if (n == 0) return error.ConnectionLost;
386403
try decoder.feed(buf[0..n]);
@@ -431,3 +448,34 @@ test "ReleaseScan: non-release CSI and plain bytes never trigger" {
431448
try std.testing.expect(!scan.feed("dddd"));
432449
try std.testing.expect(!scan.feed("\x1b[A\x1b[100;1:2u"));
433450
}
451+
452+
test "control times out when the daemon never answers" {
453+
const alloc = std.testing.allocator;
454+
455+
var name_buf: [64]u8 = undefined;
456+
const path = try std.fmt.bufPrint(
457+
&name_buf,
458+
"/tmp/boo-control-timeout-{x}.sock",
459+
.{std.crypto.random.int(u32)},
460+
);
461+
std.fs.cwd().deleteFile(path) catch {};
462+
463+
// A listener that never accepts: connect() still succeeds via the
464+
// backlog and the command write is buffered, so the reply read is
465+
// what blocks. Without the timeout this call would hang forever
466+
// (the boo ls / boo ui freeze); with it, it must give up.
467+
const lfd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
468+
defer {
469+
posix.close(lfd);
470+
std.fs.cwd().deleteFile(path) catch {};
471+
}
472+
var addr: posix.sockaddr.un = .{ .family = posix.AF.UNIX, .path = undefined };
473+
@memset(&addr.path, 0);
474+
@memcpy(addr.path[0..path.len], path);
475+
try posix.bind(lfd, @ptrCast(&addr), @sizeOf(posix.sockaddr.un));
476+
try posix.listen(lfd, 1);
477+
478+
const start = std.time.milliTimestamp();
479+
try std.testing.expectError(error.Timeout, control(alloc, path, &.{"info"}, 150));
480+
try std.testing.expect(std.time.milliTimestamp() - start >= 100);
481+
}

0 commit comments

Comments
 (0)