//! `zit daemon` over a real socket: the binary is spawned, a client connects
//! through `std.Io.net.UnixAddress`, and every assertion is on the bytes the
//! daemon wrote back or on the store as the CLI then reads it.
//!
//! Sockets are named relative to the temporary directory (the daemon's cwd)
//! and, from this process, relative to its own cwd: the absolute path of a
//! test directory can be longer than `sockaddr_un` holds.
//!
//! Imported by the e2e root, whose step builds the `zit` binary this spawns.
const std = @import("std");
const repeat = @import("zit").repeat;
const zit = @import("zit");
const build_options = @import("build_options");
const protocol = zit.daemon_protocol;
const net = std.Io.net;
const zit_exe: []const u8 = build_options.zit_exe;
const epoch0: i64 = 1_800_000_000;
const Fx = struct {
gpa: std.mem.Allocator,
io: std.Io,
tmp: std.testing.TmpDir,
arena_state: std.heap.ArenaAllocator,
fn init(fx: *Fx) void {
fx.* = .{
.gpa = std.testing.allocator,
.io = std.testing.io,
.tmp = std.testing.tmpDir(.{}),
.arena_state = .init(std.testing.allocator),
};
}
fn deinit(fx: *Fx) void {
fx.arena_state.deinit();
fx.tmp.cleanup();
}
fn a(fx: *Fx) std.mem.Allocator {
return fx.arena_state.allocator();
}
/// `name` (relative to the temporary directory) as this process names it.
fn here(fx: *Fx, name: []const u8) []const u8 {
return std.fmt.allocPrint(fx.a(), ".zig-cache/tmp/{s}/{s}", .{ fx.tmp.sub_path, name }) catch @panic("oom");
}
fn makeStore(fx: *Fx, name: []const u8) !void {
try fx.tmp.dir.createDir(fx.io, name, .default_dir);
var dir = try fx.tmp.dir.openDir(fx.io, name, .{});
defer dir.close(fx.io);
const res = try runZit(fx, dir, &.{ "init", "-q" }, null);
try expectExit(res, 0);
}
fn storeBytes(fx: *Fx, store: []const u8) ![]u8 {
const path = try std.fmt.allocPrint(fx.a(), "{s}/.zit", .{store});
return fx.tmp.dir.readFileAlloc(fx.io, path, fx.a(), .limited(1 << 30));
}
fn storeSize(fx: *Fx, store: []const u8) !u64 {
const path = try std.fmt.allocPrint(fx.a(), "{s}/.zit", .{store});
return (try fx.tmp.dir.statFile(fx.io, path, .{})).size;
}
/// Copy only the store file, so the copy shares no cache with the source.
fn copyStore(fx: *Fx, from: []const u8, to: []const u8) !void {
try fx.tmp.dir.createDir(fx.io, to, .default_dir);
const bytes = try fx.storeBytes(from);
const path = try std.fmt.allocPrint(fx.a(), "{s}/.zit", .{to});
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = path, .data = bytes });
}
/// `zit log -R STORE trunk`: the check-ins on trunk, newest first.
fn trunkLog(fx: *Fx, store: []const u8) ![]const []const u8 {
const res = try runZit(fx, fx.tmp.dir, &.{ "log", "-R", store, "trunk" }, null);
try expectExit(res, 0);
var out: std.ArrayList([]const u8) = .empty;
var lines = std.mem.tokenizeScalar(u8, res.stdout, '\n');
while (lines.next()) |line| if (std.mem.startsWith(u8, line, "checkin ")) try out.append(fx.a(), line["checkin ".len..]);
return out.items;
}
};
fn runZit(fx: *Fx, dir: std.Io.Dir, tail: []const []const u8, stdin_file: ?[]const u8) !std.process.RunResult {
var env = std.process.Environ.Map.init(fx.gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
const exe = try absExe(fx);
var cmd: std.ArrayList(u8) = .empty;
try cmd.print(fx.a(), "exec '{s}'", .{exe});
for (tail) |t| try cmd.print(fx.a(), " '{s}'", .{t});
if (stdin_file) |f| try cmd.print(fx.a(), " < '{s}'", .{f}) else try cmd.appendSlice(fx.a(), " < /dev/null");
const res = try std.process.run(fx.gpa, fx.io, .{
.argv = &.{ "/bin/sh", "-c", cmd.items },
.cwd = .{ .dir = dir },
.environ_map = &env,
});
// Kept on the arena so a caller need not free them.
const stdout = try fx.a().dupe(u8, res.stdout);
const stderr = try fx.a().dupe(u8, res.stderr);
fx.gpa.free(res.stdout);
fx.gpa.free(res.stderr);
return .{ .term = res.term, .stdout = stdout, .stderr = stderr };
}
fn absExe(fx: *Fx) ![]const u8 {
const cwd_abs = try std.process.currentPathAlloc(fx.io, fx.a());
return std.fs.path.resolve(fx.a(), &.{ cwd_abs, zit_exe });
}
fn expectExit(res: std.process.RunResult, want: u8) !void {
switch (res.term) {
.exited => |code| if (code == want) return,
else => {},
}
std.debug.print("\nzit: expected exit {d}, got {any}:\n{s}{s}\n", .{ want, res.term, res.stdout, res.stderr });
return error.TestUnexpectedResult;
}
const StartOptions = struct {
extra: []const []const u8 = &.{},
env: []const [2][]const u8 = &.{},
stdin_pipe: bool = false,
};
/// A running `zit daemon`, ready once its socket path came back on stdout.
const Daemon = struct {
child: std.process.Child,
fn start(fx: *Fx, store: []const u8, socket: []const u8, opts: StartOptions) !Daemon {
var env = std.process.Environ.Map.init(fx.gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
for (opts.env) |pair| try env.put(pair[0], pair[1]);
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(fx.a(), &.{ try absExe(fx), "daemon", "-R", store, "--socket", socket });
try argv.appendSlice(fx.a(), opts.extra);
// Kept beside the store, so a failure can show what the daemon said.
const err_file = try fx.tmp.dir.createFile(fx.io, "daemon.err", .{ .truncate = false });
defer err_file.close(fx.io);
var child = try std.process.spawn(fx.io, .{
.argv = argv.items,
.cwd = .{ .dir = fx.tmp.dir },
.environ_map = &env,
.stdin = if (opts.stdin_pipe) .pipe else .ignore,
.stdout = .pipe,
.stderr = .{ .file = err_file },
});
errdefer child.kill(fx.io);
var buf: [512]u8 = undefined;
var r = child.stdout.?.readerStreaming(fx.io, &buf);
const line = r.interface.takeDelimiterInclusive('\n') catch return error.DaemonNeverListened;
try std.testing.expectEqualStrings(socket, line[0 .. line.len - 1]);
return .{ .child = child };
}
/// SIGTERM, then the exit status.
fn stop(d: *Daemon, io: std.Io) !std.process.Child.Term {
try std.posix.kill(d.child.id.?, .TERM);
return d.child.wait(io);
}
fn expectExited(fx: *Fx, term: std.process.Child.Term, code: u8) !void {
switch (term) {
.exited => |c| if (c == code) return,
else => {},
}
const said = fx.tmp.dir.readFileAlloc(fx.io, "daemon.err", fx.a(), .limited(1 << 20)) catch "";
std.debug.print("\ndaemon: expected exit {d}, got {any}; it said:\n{s}\n", .{ code, term, said });
return error.TestUnexpectedResult;
}
};
const Client = struct {
stream: net.Stream,
read_buf: []u8,
reader: net.Stream.Reader,
write_buf: [1 << 16]u8,
writer: net.Stream.Writer,
closed: bool,
fn open(fx: *Fx, socket: []const u8) !*Client {
const addr = try net.UnixAddress.init(fx.here(socket));
const c = try fx.a().create(Client);
c.stream = try addr.connect(fx.io);
c.read_buf = try fx.a().alloc(u8, 4 << 20);
c.reader = c.stream.reader(fx.io, c.read_buf);
c.writer = c.stream.writer(fx.io, &c.write_buf);
c.closed = false;
return c;
}
fn close(c: *Client, io: std.Io) void {
if (c.closed) return;
c.stream.close(io);
c.closed = true;
}
fn send(c: *Client, req: protocol.Request, trailers: []const []const u8) !void {
try c.queue(req, trailers);
try c.writer.interface.flush();
}
/// Written but not flushed, for pipelining several requests at once.
fn queue(c: *Client, req: protocol.Request, trailers: []const []const u8) !void {
try protocol.writeRequest(&c.writer.interface, req);
for (trailers) |t| try c.writer.interface.writeAll(t);
}
fn reply(c: *Client, arena: std.mem.Allocator) !std.json.Value {
const line = try c.reader.interface.takeDelimiterInclusive('\n');
return std.json.parseFromSliceLeaky(std.json.Value, arena, try arena.dupe(u8, line), .{});
}
fn take(c: *Client, arena: std.mem.Allocator, n: usize) ![]u8 {
const bytes = try arena.alloc(u8, n);
try c.reader.interface.readSliceAll(bytes);
return bytes;
}
fn call(c: *Client, arena: std.mem.Allocator, req: protocol.Request, trailers: []const []const u8) !std.json.Value {
try c.send(req, trailers);
const v = try c.reply(arena);
try std.testing.expectEqual(req.id.?, v.object.get("id").?.integer);
return v;
}
};
fn get(v: std.json.Value, key: []const u8) std.json.Value {
return v.object.get(key) orelse std.debug.panic("reply has no '{s}'", .{key});
}
fn str(v: std.json.Value, key: []const u8) []const u8 {
return get(v, key).string;
}
fn expectOk(v: std.json.Value) !void {
if (get(v, "ok").bool) return;
std.debug.print("\nrefused: {s}: {s}\n", .{ str(get(v, "error"), "code"), str(get(v, "error"), "message") });
return error.TestUnexpectedResult;
}
fn expectError(v: std.json.Value, code: []const u8, exit: i64) !void {
try std.testing.expect(!get(v, "ok").bool);
try std.testing.expectEqualStrings(code, str(get(v, "error"), "code"));
try std.testing.expectEqual(exit, get(get(v, "error"), "exit").integer);
}
fn put(name: []const u8, bytes: []const u8) protocol.Edit {
return .{ .put = .{ .name = name, .size = bytes.len } };
}
fn commit(key: []const u8, parent: ?[]const u8, comment: []const u8, sec: i64, edits: []const protocol.Edit) protocol.Commit {
return .{ .key = key, .parent = parent, .user = "e", .comment = comment, .time = .{ .epoch = epoch0 + sec, .ms = 7 }, .edits = edits };
}
fn sha3Hex(bytes: []const u8) [64]u8 {
var d: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &d, .{});
return std.fmt.bytesToHex(d, .lower);
}
test "daemon: hello, commit, a resend, pipelining, refusals, get, and the socket's guards" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
var running = true;
defer if (running) d.child.kill(fx.io);
// Socket 0600 in a 0700 directory.
const sock_st = try fx.tmp.dir.statFile(fx.io, "s/d.sock", .{});
try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), sock_st.permissions.toMode() & 0o777);
const dir_st = try fx.tmp.dir.statFile(fx.io, "s", .{});
try std.testing.expectEqual(@as(std.posix.mode_t, 0o700), dir_st.permissions.toMode() & 0o777);
// A second daemon on the same store is refused, naming the first.
const second = try runZit(&fx, fx.tmp.dir, &.{ "daemon", "-R", "store", "--socket", "s/other.sock" }, null);
try expectExit(second, 1);
const holder = try std.fmt.allocPrint(a, "another daemon (process {d})", .{d.child.id.?});
try std.testing.expect(std.mem.indexOf(u8, second.stderr, holder) != null);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const hello = try c.call(a, .{ .id = 1, .op = .{ .hello = protocol.version } }, &.{});
try expectOk(hello);
try std.testing.expectEqual(protocol.version, get(hello, "protocol").integer);
try std.testing.expect(std.mem.endsWith(u8, str(hello, "store"), "store"));
try std.testing.expectEqual(@as(usize, 0), get(get(hello, "tip"), "branches").array.items.len);
// A commit, then the same commit again on the same parent.
const seed = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "alpha\n")}) } }, &.{"alpha\n"});
try expectOk(seed);
try std.testing.expectEqualStrings("recorded", str(seed, "outcome"));
try std.testing.expectEqualStrings("seed", str(seed, "key"));
try std.testing.expect(get(seed, "parent") == .null);
try std.testing.expectEqualStrings("trunk", str(seed, "branch"));
const p0 = str(seed, "hash");
const edits1 = [_]protocol.Edit{put("b.txt", "beta\n")};
const first = try c.call(a, .{ .id = 3, .op = .{ .commit = commit("k1", p0, "one", 1, &edits1) } }, &.{"beta\n"});
try expectOk(first);
try std.testing.expectEqualStrings("recorded", str(first, "outcome"));
try std.testing.expectEqualStrings(p0, str(first, "parent"));
try std.testing.expect(!get(first, "forked").bool);
const size_before = try fx.storeSize("store");
const again = try c.call(a, .{ .id = 4, .op = .{ .commit = commit("k1", p0, "one", 1, &edits1) } }, &.{"beta\n"});
try expectOk(again);
try std.testing.expectEqualStrings("existing", str(again, "outcome"));
try std.testing.expectEqualStrings(str(first, "hash"), str(again, "hash"));
try std.testing.expect(!get(again, "forked").bool);
try std.testing.expectEqual(size_before, try fx.storeSize("store"));
// Pipelined: three commits written before any reply is read, answered
// in order, each on the last.
var tip = try a.dupe(u8, str(first, "hash"));
{
const names = [_][]const u8{ "p1.txt", "p2.txt", "p3.txt" };
for (names, 0..) |n, i| try c.queue(.{ .id = @intCast(10 + i), .op = .{ .commit = commit(n, null, n, @intCast(10 + i), &.{put(n, n)}) } }, &.{n});
try c.writer.interface.flush();
for (0..names.len) |i| {
const v = try c.reply(a);
try std.testing.expectEqual(@as(i64, @intCast(10 + i)), get(v, "id").integer);
try expectOk(v);
try std.testing.expectEqualStrings(names[i], str(v, "key"));
try std.testing.expectEqualStrings(tip, str(v, "parent"));
tip = try a.dupe(u8, str(v, "hash"));
}
}
// The puts' hash is checked before anything is committed.
{
const bad = protocol.Edit{ .put = .{ .name = "h.txt", .size = 4, .hash = repeat("0", 64) } };
const v = try c.call(a, .{ .id = 20, .op = .{ .commit = commit("h", null, "h", 20, &.{bad}) } }, &.{"oops"});
try expectError(v, "hash_mismatch", 2);
const good_hash = sha3Hex("good");
const good = protocol.Edit{ .put = .{ .name = "h.txt", .size = 4, .hash = &good_hash } };
const ok = try c.call(a, .{ .id = 21, .op = .{ .commit = commit("h", null, "h", 21, &.{good}) } }, &.{"good"});
try expectOk(ok);
tip = try a.dupe(u8, str(ok, "hash"));
}
// The core's refusals come back by name.
{
const v = try c.call(a, .{ .id = 22, .op = .{ .commit = commit("x", null, "x", 22, &.{.{ .delete = "never.txt" }}) } }, &.{});
try expectError(v, "path_conflict", 1);
const stale = try c.call(a, .{ .id = 23, .op = .{ .commit = commit("x", p0, "x", 23, &.{put("z.txt", "z")}) } }, &.{"z"});
try expectError(stale, "not_tip", 1);
}
// get, one and several.
{
const alpha = sha3Hex("alpha\n");
const one = try c.call(a, .{ .id = 30, .op = .{ .get = .{ .one = &alpha } } }, &.{});
try expectOk(one);
try std.testing.expectEqual(@as(i64, 6), get(one, "size").integer);
try std.testing.expectEqualStrings("alpha\n", try c.take(a, 6));
const beta = sha3Hex("beta\n");
const nowhere = repeat("e", 64);
const many = try c.call(a, .{ .id = 31, .op = .{ .get = .{ .many = &.{ &beta, nowhere, &alpha } } } }, &.{});
try expectOk(many);
const items = get(many, "items").array.items;
try std.testing.expectEqual(@as(i64, 5), get(items[0], "size").integer);
try std.testing.expectEqualStrings("not_found", str(get(items[1], "error"), "code"));
try std.testing.expectEqual(@as(i64, 6), get(items[2], "size").integer);
try std.testing.expectEqualStrings("beta\nalpha\n", try c.take(a, 11));
const malformed = try c.call(a, .{ .id = 32, .op = .{ .get = .{ .one = "ABC" } } }, &.{});
try expectError(malformed, "bad_request", 2);
}
// A refused request leaves the connection usable; status still answers.
{
try c.writer.interface.writeAll("{\"id\":40,\"op\":\"rebase\"}\n");
try c.writer.interface.flush();
try expectError(try c.reply(a), "bad_request", 2);
const st = try c.call(a, .{ .id = 41, .op = .status }, &.{});
try expectOk(st);
const branches = get(st, "branches").array.items;
try std.testing.expectEqual(@as(usize, 1), branches.len);
try std.testing.expectEqualStrings("trunk", str(branches[0], "name"));
try std.testing.expectEqualStrings(tip, str(branches[0], "tip"));
}
// Unreadable framing closes the connection after the refusal.
{
try c.writer.interface.writeAll("not json\n");
try c.writer.interface.flush();
const v = try c.reply(a);
try expectError(v, "bad_request", 2);
try std.testing.expect(get(v, "id") == .null);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
}
running = false;
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
// Stopped: the socket is gone and the lock released.
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "s/d.sock", .{}));
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "store/.zit.daemon", .{}));
}
test "daemon: a put past the file ceiling is refused too_large and its bytes skipped" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_MAX_FILE_BYTES", "16" }} });
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const big = repeat("x", 17);
const v = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("big", null, "big", 0, &.{put("big.bin", big)}) } }, &.{big});
try expectError(v, "too_large", 1);
try std.testing.expect(std.mem.indexOf(u8, str(get(v, "error"), "message"), "16-byte limit") != null);
// The skipped bytes did not run into the next request.
const ok = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("small", null, "small", 1, &.{put("s.txt", repeat("x", 16))}) } }, &.{repeat("x", 16)});
try expectOk(ok);
}
test "daemon: a batch records exactly what the same commits sent one at a time do" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("one");
{
var d = try Daemon.start(&fx, "one", "s/one.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/one.sock");
defer c.close(fx.io);
try expectOk(try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("a.txt", "a\n"), put("b.txt", "b\n"), put("d/c.txt", "c\n") }) } }, &.{ "a\n", "b\n", "c\n" }));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
try fx.copyStore("one", "many");
// A put, a delete, a rename, an edit that changes nothing, a refusal,
// and a commit after the refusal.
const commits = [_]protocol.Commit{
commit("c1", null, "edit a", 1, &.{put("a.txt", "a2\n")}),
commit("c2", null, "drop b", 2, &.{.{ .delete = "b.txt" }}),
commit("c3", null, "move c", 3, &.{.{ .rename = .{ .old = "d/c.txt", .new = "e/c.txt" } }}),
commit("c4", null, "same", 4, &.{put("a.txt", "a2\n")}),
commit("c5", null, "refused", 5, &.{.{ .delete = "b.txt" }}),
commit("c6", null, "add f", 6, &.{ put("f.txt", "f\n"), .{ .delete = "a.txt" } }),
};
const trailers = [_][]const []const u8{ &.{"a2\n"}, &.{}, &.{}, &.{"a2\n"}, &.{}, &.{"f\n"} };
var singles: std.ArrayList(std.json.Value) = .empty;
{
var d = try Daemon.start(&fx, "one", "s/one.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/one.sock");
defer c.close(fx.io);
for (commits, trailers, 0..) |cm, t, i| try singles.append(a, try c.call(a, .{ .id = @intCast(i), .op = .{ .commit = cm } }, t));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
var batch: std.json.Value = undefined;
{
var d = try Daemon.start(&fx, "many", "s/many.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/many.sock");
defer c.close(fx.io);
var all: std.ArrayList([]const u8) = .empty;
for (trailers) |t| try all.appendSlice(a, t);
batch = try c.call(a, .{ .id = 99, .op = .{ .batch = &commits } }, all.items);
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
try expectOk(batch);
const results = get(batch, "results").array.items;
try std.testing.expectEqual(commits.len, results.len);
for (singles.items, results, commits) |single, item, cm| {
try std.testing.expectEqualStrings(cm.key.?, str(item, "key"));
try std.testing.expectEqual(get(single, "ok").bool, get(item, "ok").bool);
if (get(single, "ok").bool) {
for ([_][]const u8{ "outcome", "hash", "parent", "branch" }) |k| try std.testing.expectEqualStrings(str(single, k), str(item, k));
} else {
try std.testing.expectEqualStrings(str(get(single, "error"), "code"), str(get(item, "error"), "code"));
}
}
try std.testing.expectEqualStrings("unchanged", str(results[3], "outcome"));
try std.testing.expectEqualStrings("path_conflict", str(get(results[4], "error"), "code"));
try std.testing.expect(get(results[5], "ok").bool);
try std.testing.expectEqualSlices(u8, try fx.storeBytes("one"), try fx.storeBytes("many"));
}
test "daemon: a rename and a put of its new name are one rename with content, as commit -R records it" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
const old_bytes = "alpha\nbeta\n";
const new_bytes = "alpha\nbeta\ngamma\n";
const seed_hash = blk: {
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const v = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("a.md", old_bytes), put("c.md", "c\n"), put("d.md", "d\n") }) } }, &.{ old_bytes, "c\n", "d\n" });
try expectOk(v);
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
break :blk try a.dupe(u8, str(v, "hash"));
};
try fx.copyStore("store", "cli");
try fx.copyStore("store", "batch");
const move = commit("mv", null, "move", 1, &.{ .{ .rename = .{ .old = "a.md", .new = "b.md" } }, put("b.md", new_bytes) });
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
var running = true;
defer if (running) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const moved = try c.call(a, .{ .id = 2, .op = .{ .commit = move } }, &.{new_bytes});
try expectOk(moved);
try std.testing.expectEqualStrings("recorded", str(moved, "outcome"));
const hash = str(moved, "hash");
// The CLI's rename-with-content, at the same time, is the same check-in.
{
var time_buf: [24]u8 = undefined;
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "stdin.in", .data = new_bytes });
const res = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "cli", "-m", "move", "--user", "e", "--rename", "a.md", "b.md", "--stdin", "b.md", "--date-override", protocol.formatTime(move.time, &time_buf) }, "stdin.in");
try expectExit(res, 0);
try std.testing.expectEqualStrings(hash, std.mem.trimEnd(u8, res.stdout, "\n"));
}
// And so is the same commit in a batch.
{
var bd = try Daemon.start(&fx, "batch", "s/b.sock", .{});
defer bd.child.kill(fx.io);
var bc = try Client.open(&fx, "s/b.sock");
defer bc.close(fx.io);
const v = try bc.call(a, .{ .id = 3, .op = .{ .batch = &.{move} } }, &.{new_bytes});
try expectOk(v);
const item = get(v, "results").array.items[0];
try expectOk(item);
try std.testing.expectEqualStrings(hash, str(item, "hash"));
bc.close(fx.io);
try Daemon.expectExited(&fx, try bd.stop(fx.io), 0);
}
// log and annotate follow the rename back to the seed.
{
const res = try runZit(&fx, fx.tmp.dir, &.{ "log", hash, "b.md", "-R", "store" }, null);
try expectExit(res, 0);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, seed_hash[0..10]) != null);
const ann = try runZit(&fx, fx.tmp.dir, &.{ "annotate", "b.md", hash, "-R", "store" }, null);
try expectExit(ann, 0);
try std.testing.expect(std.mem.indexOf(u8, ann.stdout, seed_hash[0..12]) != null);
try std.testing.expect(std.mem.indexOf(u8, ann.stdout, hash[0..12]) != null);
}
// Every other repeat of a path is refused, and the store is unchanged.
const refusals = [_]struct { edits: []const protocol.Edit, trailers: []const []const u8 }{
.{ .edits = &.{ put("c.md", "1\n"), put("c.md", "2\n") }, .trailers = &.{ "1\n", "2\n" } },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, put("c.md", "1\n") }, .trailers = &.{"1\n"} },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, .{ .rename = .{ .old = "d.md", .new = "x.md" } } }, .trailers = &.{} },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, .{ .rename = .{ .old = "d.md", .new = "x.md" } }, put("x.md", "1\n") }, .trailers = &.{"1\n"} },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, put("x.md", "1\n"), put("x.md", "2\n") }, .trailers = &.{ "1\n", "2\n" } },
};
const before = try fx.storeSize("store");
for (refusals, 0..) |r, i| {
const v = try c.call(a, .{ .id = @intCast(10 + i), .op = .{ .commit = commit("dup", null, "dup", @intCast(10 + i), r.edits) } }, r.trailers);
try expectError(v, "invalid_request", 1);
try std.testing.expectEqual(before, try fx.storeSize("store"));
}
c.close(fx.io);
running = false;
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
try std.testing.expectEqual(before, try fx.storeSize("store"));
}
test "daemon: feed from zero, nothing new, a CLI commit beside it, forks, purged content, and a pack" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("a.txt", "a1\n"), put("b.txt", "b\n") }) } }, &.{ "a1\n", "b\n" });
try expectOk(seed);
const p0 = try a.dupe(u8, str(seed, "hash"));
const second = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("two", p0, "two", 1, &.{ put("a.txt", "a2\n"), .{ .rename = .{ .old = "b.txt", .new = "c.txt" } } }) } }, &.{"a2\n"});
try expectOk(second);
const p1 = try a.dupe(u8, str(second, "hash"));
const all = try c.call(a, .{ .id = 3, .op = .{ .feed = "0" } }, &.{});
try expectOk(all);
const changes = get(all, "changes").array.items;
try std.testing.expectEqual(@as(usize, 2), changes.len);
try std.testing.expectEqualStrings(p0, str(changes[0], "checkin"));
try std.testing.expect(get(changes[0], "parent") == .null);
try std.testing.expectEqualStrings("trunk", str(changes[0], "branch"));
try std.testing.expectEqualStrings("e", str(changes[0], "user"));
try std.testing.expectEqualStrings("seed", str(changes[0], "comment"));
try std.testing.expectEqualStrings("2027-01-15T08:00:00.007Z", str(changes[0], "time"));
try std.testing.expectEqual(@as(usize, 2), get(changes[0], "files").array.items.len);
try std.testing.expectEqualStrings("added", str(get(changes[0], "files").array.items[0], "state"));
try std.testing.expectEqualStrings(p1, str(changes[1], "checkin"));
try std.testing.expectEqualStrings(p0, str(changes[1], "parent"));
{
var seen: usize = 0;
for (get(changes[1], "files").array.items) |row| {
const name = str(row, "name");
if (std.mem.eql(u8, name, "a.txt")) {
try std.testing.expectEqualStrings("modified", str(row, "state"));
try std.testing.expectEqualStrings(&sha3Hex("a2\n"), str(row, "uuid"));
} else if (std.mem.eql(u8, name, "b.txt")) {
try std.testing.expectEqualStrings("removed", str(row, "state"));
try std.testing.expect(get(row, "uuid") == .null);
} else {
try std.testing.expectEqualStrings("c.txt", name);
try std.testing.expectEqualStrings("modified", str(row, "state"));
try std.testing.expectEqualStrings("b.txt", str(row, "prior"));
}
seen += 1;
}
try std.testing.expectEqual(@as(usize, 3), seen);
}
try std.testing.expectEqual(@as(usize, 0), get(all, "forks").array.items.len);
const cursor1 = try a.dupe(u8, str(all, "cursor"));
// Nothing new: the same cursor and empty lists.
const idle = try c.call(a, .{ .id = 4, .op = .{ .feed = cursor1 } }, &.{});
try expectOk(idle);
try std.testing.expectEqualStrings(cursor1, str(idle, "cursor"));
try std.testing.expectEqual(@as(usize, 0), get(idle, "changes").array.items.len);
// A CLI commit lands while the daemon runs; the next feed has it, and the
// daemon's own next commit builds on it.
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "cli.txt", .data = "from the cli\n" });
const cli = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "store", "--stdin", "cli.txt", "-m", "cli", "--user", "u" }, "cli.txt");
try expectExit(cli, 0);
const cli_hash = std.mem.trimEnd(u8, cli.stdout, "\n");
const after = try c.call(a, .{ .id = 5, .op = .{ .feed = cursor1 } }, &.{});
try expectOk(after);
try std.testing.expectEqual(@as(usize, 1), get(after, "changes").array.items.len);
try std.testing.expectEqualStrings(cli_hash, str(get(after, "changes").array.items[0], "checkin"));
try std.testing.expectEqualStrings("u", str(get(after, "changes").array.items[0], "user"));
const cursor2 = try a.dupe(u8, str(after, "cursor"));
const on_cli = try c.call(a, .{ .id = 6, .op = .{ .commit = commit("on", null, "on cli", 3, &.{put("d.txt", "d\n")}) } }, &.{"d\n"});
try expectOk(on_cli);
try std.testing.expectEqualStrings(cli_hash, str(on_cli, "parent"));
// A deliberate fork: the feed names the branch's two leaves.
const fork = try c.call(a, .{ .id = 7, .op = .{ .commit = blk: {
var cm = commit("fork", p1, "fork", 4, &.{put("f.txt", "f\n")});
cm.allow_fork = true;
break :blk cm;
} } }, &.{"f\n"});
try expectOk(fork);
try std.testing.expect(get(fork, "forked").bool);
// Its resend is the same check-in, still reported as the fork it made.
{
const size = try fx.storeSize("store");
var cm = commit("fork", p1, "fork", 4, &.{put("f.txt", "f\n")});
cm.allow_fork = true;
const again = try c.call(a, .{ .id = 70, .op = .{ .commit = cm } }, &.{"f\n"});
try expectOk(again);
try std.testing.expectEqualStrings("existing", str(again, "outcome"));
try std.testing.expectEqualStrings(str(fork, "hash"), str(again, "hash"));
try std.testing.expectEqualStrings(p1, str(again, "parent"));
try std.testing.expect(get(again, "forked").bool);
try std.testing.expectEqual(size, try fx.storeSize("store"));
}
const forked = try c.call(a, .{ .id = 8, .op = .{ .feed = cursor2 } }, &.{});
try expectOk(forked);
try std.testing.expectEqual(@as(usize, 2), get(forked, "changes").array.items.len);
const forks = get(forked, "forks").array.items;
try std.testing.expectEqual(@as(usize, 1), forks.len);
try std.testing.expectEqualStrings("trunk", str(forks[0], "branch"));
try std.testing.expectEqual(@as(usize, 2), get(forks[0], "leaves").array.items.len);
const cursor3 = try a.dupe(u8, str(forked, "cursor"));
// The first a.txt is held by no open leaf: purged, it reads as absent
// by policy, in either form of get.
const a1 = sha3Hex("a1\n");
const purged = try runZit(&fx, fx.tmp.dir, &.{ "purge", "artifacts", "-R", "store", "--force", &a1 }, null);
try expectExit(purged, 0);
const gone = try c.call(a, .{ .id = 9, .op = .{ .get = .{ .one = &a1 } } }, &.{});
try expectError(gone, "content_absent", 8);
const a2 = sha3Hex("a2\n");
const mixed = try c.call(a, .{ .id = 10, .op = .{ .get = .{ .many = &.{ &a1, &a2 } } } }, &.{});
try expectOk(mixed);
try std.testing.expectEqualStrings("content_absent", str(get(get(mixed, "items").array.items[0], "error"), "code"));
try std.testing.expectEqual(@as(i64, 8), get(get(get(mixed, "items").array.items[0], "error"), "exit").integer);
try std.testing.expectEqualStrings("a2\n", try c.take(a, 3));
// A pack that reclaims a deferred shun relocates frames: the old cursor
// is refused, and the feed starts again from zero.
const shunned = sha3Hex("f\n");
const shun = try runZit(&fx, fx.tmp.dir, &.{ "shun", "-R", "store", "--defer", "--force", &shunned }, null);
try expectExit(shun, 0);
const pack = try runZit(&fx, fx.tmp.dir, &.{ "pack", "-R", "store" }, null);
try expectExit(pack, 0);
const dead = try c.call(a, .{ .id = 11, .op = .{ .feed = cursor3 } }, &.{});
try expectError(dead, "cursor_invalid", 4);
const restart = try c.call(a, .{ .id = 12, .op = .{ .feed = "0" } }, &.{});
try expectOk(restart);
try std.testing.expectEqual(@as(usize, 5), get(restart, "changes").array.items.len);
// The daemon commits on the rewritten store as on any other.
const after_pack = try c.call(a, .{ .id = 13, .op = .{ .commit = commit("post", null, "post", 5, &.{put("g.txt", "g\n")}) } }, &.{"g\n"});
try expectOk(after_pack);
try std.testing.expectEqualStrings("recorded", str(after_pack, "outcome"));
}
test "daemon: --exit-on-stdin-close stops when the parent's pipe closes" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .extra = &.{"--exit-on-stdin-close"}, .stdin_pipe = true });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
try expectOk(try c.call(fx.a(), .{ .id = 1, .op = .status }, &.{}));
c.close(fx.io);
d.child.stdin.?.close(fx.io);
d.child.stdin = null;
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 0);
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "s/d.sock", .{}));
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "store/.zit.daemon", .{}));
}
test "daemon: a crash on either side of the fsync leaves one check-in for the resend" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
for ([_][]const u8{ "after_append", "after_sync" }, 0..) |point, i| {
const store = try std.fmt.allocPrint(a, "store{d}", .{i});
try fx.makeStore(store);
var p0: []const u8 = undefined;
{
var d = try Daemon.start(&fx, store, "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "a\n")}) } }, &.{"a\n"});
try expectOk(seed);
p0 = try a.dupe(u8, str(seed, "hash"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
const edits = [_]protocol.Edit{put("b.txt", "b\n")};
{
var d = try Daemon.start(&fx, store, "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_FAULT", point }} });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try c.send(.{ .id = 2, .op = .{ .commit = commit("b", p0, "b", 1, &edits) } }, &.{"b\n"});
// The process stops before it answers.
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 70);
}
{
// The dead daemon's socket and lock are reclaimed by the next.
var d = try Daemon.start(&fx, store, "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const resent = try c.call(a, .{ .id = 3, .op = .{ .commit = commit("b", p0, "b", 1, &edits) } }, &.{"b\n"});
try expectOk(resent);
const outcome = str(resent, "outcome");
try std.testing.expect(std.mem.eql(u8, outcome, "recorded") or std.mem.eql(u8, outcome, "existing"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
const log = try fx.trunkLog(store);
try std.testing.expectEqual(@as(usize, 2), log.len);
try std.testing.expectEqualStrings(str(resent, "hash"), log[0]);
try std.testing.expectEqualStrings(p0, log[1]);
}
}
}
/// What a resend case does between the crash and the resend.
const Between = enum { nothing, unrelated_cli_commit, cli_commit_same_path };
test "daemon: every resend after a crash is answered by the check-in the first send made" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var p_tip: []const u8 = undefined;
{
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{
put("a.txt", "a\n"), put("b.txt", "b\n"), put("c.txt", "c\n"), put("d.txt", "d\n"),
put("e.txt", "e\n"), put("f.txt", "f\n"), put("g.txt", "g\n"), put("h.txt", "h\n"),
}) } }, &.{ "a\n", "b\n", "c\n", "d\n", "e\n", "f\n", "g\n", "h\n" });
try expectOk(seed);
p_tip = try a.dupe(u8, str(seed, "hash"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
const Case = struct { edits: []const protocol.Edit, trailers: []const []const u8, explicit: bool, between: Between = .nothing };
const cases = [_]Case{
.{ .edits = &.{put("a.txt", "a2\n")}, .trailers = &.{"a2\n"}, .explicit = false },
.{ .edits = &.{put("a.txt", "a3\n")}, .trailers = &.{"a3\n"}, .explicit = true },
.{ .edits = &.{.{ .delete = "b.txt" }}, .trailers = &.{}, .explicit = false },
.{ .edits = &.{.{ .delete = "c.txt" }}, .trailers = &.{}, .explicit = true },
.{ .edits = &.{.{ .rename = .{ .old = "d.txt", .new = "d2.txt" } }}, .trailers = &.{}, .explicit = false },
.{ .edits = &.{.{ .rename = .{ .old = "e.txt", .new = "e2.txt" } }}, .trailers = &.{}, .explicit = true },
.{ .edits = &.{ .{ .delete = "f.txt" }, .{ .rename = .{ .old = "g.txt", .new = "g2.txt" } }, put("n1.txt", "n1\n") }, .trailers = &.{"n1\n"}, .explicit = false },
.{ .edits = &.{ .{ .delete = "g2.txt" }, .{ .rename = .{ .old = "h.txt", .new = "h2.txt" } }, put("n2.txt", "n2\n") }, .trailers = &.{"n2\n"}, .explicit = true },
.{ .edits = &.{put("n3.txt", "n3\n")}, .trailers = &.{"n3\n"}, .explicit = false, .between = .unrelated_cli_commit },
.{ .edits = &.{put("n4.txt", "n4\n")}, .trailers = &.{"n4\n"}, .explicit = false, .between = .cli_commit_same_path },
};
for (cases, 0..) |case, i| {
const key = try std.fmt.allocPrint(a, "case{d}", .{i});
const cm = commit(key, if (case.explicit) p_tip else null, key, @intCast(10 + i), case.edits);
{
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_FAULT", "after_sync" }} });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try c.send(.{ .id = 2, .op = .{ .commit = cm } }, case.trailers);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 70);
}
// The crash came after the fsync: the check-in is the store's tip.
const made = (try fx.trunkLog("store"))[0];
try std.testing.expect(!std.mem.eql(u8, made, p_tip));
switch (case.between) {
.nothing => {},
.unrelated_cli_commit, .cli_commit_same_path => {
const path = if (case.between == .unrelated_cli_commit) "other.txt" else "n4.txt";
const body = try std.fmt.allocPrint(a, "cli {d}\n", .{i});
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "cli.txt", .data = body });
const cli = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "store", "--stdin", path, "-m", "cli", "--user", "u" }, "cli.txt");
try expectExit(cli, 0);
},
}
const size = try fx.storeSize("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const resent = try c.call(a, .{ .id = 3, .op = .{ .commit = cm } }, case.trailers);
try expectOk(resent);
try std.testing.expectEqualStrings("existing", str(resent, "outcome"));
try std.testing.expectEqualStrings(made, str(resent, "hash"));
try std.testing.expectEqualStrings(p_tip, str(resent, "parent"));
try std.testing.expect(!get(resent, "forked").bool);
try std.testing.expectEqual(size, try fx.storeSize("store"));
// The tip by ancestry, not by time: a CLI commit carries today's
// clock, older than these requests' times.
const st = try c.call(a, .{ .id = 4, .op = .status }, &.{});
p_tip = try a.dupe(u8, str(get(st, "branches").array.items[0], "tip"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
}
test "daemon: the socket path is taken over only from a dead daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
try fx.makeStore("one");
try fx.makeStore("two");
// A regular file at the path is the user's, not a stale socket.
try fx.tmp.dir.createDir(fx.io, "s", .fromMode(0o700));
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "s/d.sock", .data = "mine\n" });
const refused = try runZit(&fx, fx.tmp.dir, &.{ "daemon", "-R", "one", "--socket", "s/d.sock" }, null);
try expectExit(refused, 1);
try std.testing.expect(std.mem.indexOf(u8, refused.stderr, "is not a socket") != null);
try std.testing.expectEqualStrings("mine\n", try fx.tmp.dir.readFileAlloc(fx.io, "s/d.sock", fx.a(), .limited(64)));
try fx.tmp.dir.deleteFile(fx.io, "s/d.sock");
// A live daemon of another store keeps its socket.
var d = try Daemon.start(&fx, "one", "s/d.sock", .{});
defer d.child.kill(fx.io);
const second = try runZit(&fx, fx.tmp.dir, &.{ "daemon", "-R", "two", "--socket", "s/d.sock" }, null);
try expectExit(second, 1);
try std.testing.expect(std.mem.indexOf(u8, second.stderr, "already answers") != null);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const hello = try c.call(fx.a(), .{ .id = 1, .op = .{ .hello = protocol.version } }, &.{});
try std.testing.expect(std.mem.endsWith(u8, str(hello, "store"), "one"));
}
test "daemon: a failed fsync is answered as a failure and stops the daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_FAULT", "sync_fails" }} });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const v = try c.call(fx.a(), .{ .id = 1, .op = .{ .commit = commit("k", null, "k", 0, &.{put("a.txt", "a\n")}) } }, &.{"a\n"});
try expectError(v, "storage", 1);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 1);
const said = try fx.tmp.dir.readFileAlloc(fx.io, "daemon.err", fx.a(), .limited(1 << 20));
try std.testing.expect(std.mem.indexOf(u8, said, "fsync failed") != null);
}
test "daemon: oversized requests, stalled trailers and overlong headers end their connection, not the daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{
.{ "ZIT_DAEMON_MAX_REQUEST_BYTES", "10" },
.{ "ZIT_DAEMON_STALL_MS", "200" },
} });
defer d.child.kill(fx.io);
{
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const v = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("big", null, "big", 0, &.{ put("a", "123456"), put("b", "123456") }) } }, &.{ "123456", "123456" });
try expectError(v, "too_large", 1);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
}
{
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try c.send(.{ .id = 2, .op = .{ .commit = commit("slow", null, "slow", 1, &.{put("a", "12345678")}) } }, &.{"123"});
// The rest never comes: the daemon closes the connection.
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
}
{
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const chunk = try a.alloc(u8, 64 * 1024);
@memset(chunk, ' ');
// Past 1 MiB with no newline; the daemon may close before it all goes.
for (0..17) |_| c.writer.interface.writeAll(chunk) catch break;
c.writer.interface.flush() catch {};
const v = try c.reply(a);
try expectError(v, "header_too_large", 2);
try std.testing.expect(get(v, "id") == .null);
}
// An idle connection is never a stalled one, and the daemon still serves.
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try expectOk(try c.call(a, .{ .id = 3, .op = .status }, &.{}));
try fx.io.sleep(.fromMilliseconds(600), .awake);
try expectOk(try c.call(a, .{ .id = 4, .op = .status }, &.{}));
const said = try fx.tmp.dir.readFileAlloc(fx.io, "daemon.err", a, .limited(1 << 20));
try std.testing.expect(std.mem.indexOf(u8, said, "closed a connection whose request sent nothing") != null);
}
test "daemon: a second client is served once the first leaves, and --idle-exit stops an unused daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .extra = &.{ "--idle-exit", "1" } });
defer if (d.child.id != null) d.child.kill(fx.io);
var first = try Client.open(&fx, "s/d.sock");
defer first.close(fx.io);
try expectOk(try first.call(a, .{ .id = 1, .op = .status }, &.{}));
var second = try Client.open(&fx, "s/d.sock");
defer second.close(fx.io);
try second.send(.{ .id = 2, .op = .status }, &.{});
// The first keeps its connection past the idle deadline: an open
// connection is not idle, and the first is still answered.
try fx.io.sleep(.fromMilliseconds(1500), .awake);
try expectOk(try first.call(a, .{ .id = 3, .op = .status }, &.{}));
first.close(fx.io);
const queued = try second.reply(a);
try std.testing.expectEqual(@as(i64, 2), get(queued, "id").integer);
try expectOk(queued);
second.close(fx.io);
// Nobody connected: the daemon goes on its own, cleanly.
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 0);
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "s/d.sock", .{}));
}