milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+66 -14
View File
@@ -19,6 +19,7 @@
//! the next start runs — before a single row is written, so a settings PUT
//! cannot leave a configuration the server would refuse to boot from.
const builtin = @import("builtin");
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -283,6 +284,25 @@ pub fn applyPut(
.fail => |failure| return .{ .fail = failure },
};
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
// handler does, and its input is the parsed patch alone — nothing under the
// lock. Hashing inside the lock stalled every settings read and every other
// mutation for its duration. The login path already hashes unlocked
// (auth.zig), and `LiveHash`'s generation check closes the install race.
const password = newPassword(patch);
var new_hash: []const u8 = "";
if (password) |plain| {
if (plain.len > auth.max_password_len) {
return .{ .fail = .{ .invalid = "web.password is too long" } };
}
const buf = try arena.alloc(u8, hash_buf_len);
new_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
else => return .{ .fail = .{ .internal = error.Unexpected } },
};
}
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -300,21 +320,10 @@ pub fn applyPut(
) } };
}
// The password never becomes a row. It is hashed here and the hash is what
// the merged configuration — and therefore the settings table — carries.
const password = newPassword(patch);
// The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries.
const previous_hash = cfg.web.password_hash;
if (password) |plain| {
if (plain.len > auth.max_password_len) {
return .{ .fail = .{ .invalid = "web.password is too long" } };
}
const buf = try arena.alloc(u8, hash_buf_len);
cfg.web.password_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
else => return .{ .fail = .{ .internal = error.Unexpected } },
};
}
if (password != null) cfg.web.password_hash = new_hash;
cfg.web.password = "";
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
@@ -374,6 +383,7 @@ fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
/// password set through the API and one set through a config import produce the
/// same kind of hash.
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 {
hash_stall.park(io);
return std.crypto.pwhash.argon2.strHash(password, .{
.allocator = gpa,
.params = .owasp_2id,
@@ -390,6 +400,48 @@ fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]
};
}
/// Holds a hash still so a test can prove another request runs beside it. The
/// hash finishing on its own would prove nothing: before ruling 18 a settings
/// GET also completed, it merely waited out the hash first. The storage exists
/// in a test build only, and `park` reduces to nothing everywhere else — the
/// rotation seam's shape (logging.zig).
const hash_stall = if (builtin.is_test) struct {
var armed: bool = false;
var parked: std.Io.Event = .unset;
var release: std.Io.Event = .unset;
fn park(io: std.Io) void {
if (!armed) return;
parked.set(io);
release.waitUncancelable(io);
}
} else struct {
fn park(io: std.Io) void {
_ = io;
}
};
/// The seam's controls, for the test that proves a settings read runs beside a
/// hash in flight. Present in a test build only.
pub const hash_stall_control = if (builtin.is_test) struct {
pub fn arm() void {
hash_stall.parked = .unset;
hash_stall.release = .unset;
hash_stall.armed = true;
}
/// Returns once a hash is parked on the seam.
pub fn waitParked(io: std.Io) void {
hash_stall.parked.waitUncancelable(io);
}
/// Lets the parked hash finish and disarms the seam for the next test.
pub fn release(io: std.Io) void {
hash_stall.armed = false;
hash_stall.release.set(io);
}
} else struct {};
// ---------------------------------------------------------------------------
// routes
// ---------------------------------------------------------------------------