milestone 15: make a green run mean a real pass
CI / test (push) Failing after 1m12s
CI / test-aarch64 (push) Failing after 2m27s
CI / frontend (push) Successful in 1m28s
CI / cross (push) Failing after 27s
CI / docker (push) Failing after 24s

This commit is contained in:
2026-08-07 01:28:13 +02:00
parent f2898479d4
commit 9f8a5cd753
16 changed files with 1070 additions and 31 deletions
+87 -4
View File
@@ -26,6 +26,7 @@
//! and a log line emitted from the sink would recurse.
const std = @import("std");
const builtin = @import("builtin");
const model = @import("../config/model.zig");
/// Two upstream failures with the same key inside this window produce one line.
@@ -585,9 +586,25 @@ fn rotateStepsLocked() RotateError!void {
try renameLocked(dir, p, first);
}
const RotateFault = enum { none, fail_delete, fail_rename };
/// The two rotation steps fail only when the filesystem does, which no unit
/// test can arrange on demand, so the failure paths are driven through this
/// seam instead. The storage exists in a test build only, and
/// `rotateFaultTripped` reduces to `false` everywhere else.
const rotate_fault_seam = if (builtin.is_test) struct {
var fault: RotateFault = .none;
} else struct {};
fn rotateFaultTripped(comptime which: RotateFault) bool {
if (!builtin.is_test) return false;
return rotate_fault_seam.fault == which;
}
/// A generation that does not exist yet is not a failure: the first rotations
/// of a fresh log directory find nothing to delete.
fn deleteLocked(dir: std.Io.Dir, p: []const u8) RotateError!void {
if (rotateFaultTripped(.fail_delete)) return error.RotateFailed;
dir.deleteFile(state.io, p) catch |err| switch (err) {
error.FileNotFound => {},
else => return error.RotateFailed,
@@ -595,6 +612,7 @@ fn deleteLocked(dir: std.Io.Dir, p: []const u8) RotateError!void {
}
fn renameLocked(dir: std.Io.Dir, from: []const u8, to: []const u8) RotateError!void {
if (rotateFaultTripped(.fail_rename)) return error.RotateFailed;
dir.rename(from, dir, to, state.io) catch |err| switch (err) {
error.FileNotFound => {},
else => return error.RotateFailed,
@@ -605,10 +623,9 @@ fn renameLocked(dir: std.Io.Dir, from: []const u8, to: []const u8) RotateError!v
// Tests
//
// The pure pieces only. `logFn` is never exercised: installing a sink under the
// test runner would eat the harness's own output. File behaviour is S8's, and
// the rotation failure paths (`rotateLocked` returning false, the pending
// rotation that keeps the oversized file closed) stay uncovered: they need a
// filesystem that fails a delete or a rename on demand.
// test runner would eat the harness's own output. File behaviour is S8's. The
// rotation failure paths need a filesystem that fails a delete or a rename on
// demand, which `rotate_fault_seam` supplies.
// ---------------------------------------------------------------------------
const testing = std.testing;
@@ -908,6 +925,72 @@ test "a failed open counts exactly one sink error" {
try testing.expectEqual(@as(u64, 0), state.stats.lines_written);
}
/// The shared body of the two rotation-failure tests: they differ only in which
/// step is made to fail and in how many `max_files` it takes to reach it.
///
/// `state.io` is normally installed by `install`, which no test calls, so a real
/// one is put in place for the duration: `rotateLocked` swaps cancel protection
/// on it before the first step runs, and the `fail_rename` case deletes the
/// oldest generation for real before it reaches the rename. That delete is why
/// the path points into a fresh tmp directory rather than at any real log.
fn expectRotationFailureCounted(fault: RotateFault, max_files: u8) !void {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [128]u8 = undefined;
const p = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
const saved_stats = state.stats;
const saved_path_len = state.path_len;
const saved_max_files = state.max_files;
const saved_file = state.file;
const saved_pending = state.rotate_pending;
const saved_io = state.io;
defer {
rotate_fault_seam.fault = .none;
state.stats = saved_stats;
state.path_len = saved_path_len;
state.max_files = saved_max_files;
state.file = saved_file;
state.rotate_pending = saved_pending;
state.io = saved_io;
std.debug.unlockStderr();
}
state.stats = .{};
state.io = threaded.io();
state.path_len = p.len;
@memcpy(state.path_buf[0..p.len], p);
state.max_files = max_files;
state.file = null;
state.rotate_pending = true;
rotate_fault_seam.fault = fault;
try testing.expect(!prepareFileLocked(64));
try testing.expectEqual(@as(u64, 1), state.stats.sink_errors);
try testing.expectEqual(@as(u64, 0), state.stats.rotations);
// The oversized file stays closed and the rotation stays owed, so the next
// line retries the rotation instead of appending past `max_bytes`.
try testing.expect(state.rotate_pending);
try testing.expectEqual(@as(?std.Io.File, null), state.file);
}
test "a failed rotation delete counts exactly one sink error" {
// `max_files` below 2 keeps no generations, so the whole rotation is the one
// delete of the live path.
try expectRotationFailureCounted(.fail_delete, 1);
}
test "a failed rotation rename counts exactly one sink error" {
// `max_files` of 2 keeps generation 1, so the steps are one delete of the
// oldest generation followed by the rename of the live path onto it.
try expectRotationFailureCounted(.fail_rename, 2);
}
test "rotatedName appends the generation" {
var buf: [max_rotated_path_bytes]u8 = undefined;
try testing.expectEqualStrings(