milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m55s
Gates / test-aarch64 (push) Failing after 3h1m8s
Gates / package (push) Successful in 3m55s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 3h21m29s

This commit is contained in:
2026-08-22 19:54:02 +02:00
parent 648d9b4496
commit 025edbb093
14 changed files with 2099 additions and 188 deletions
+126 -49
View File
@@ -529,8 +529,8 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer bundle.deinit(gpa);
var bundle_lock: std.Io.RwLock = .init;
var upstreams = try Upstreams.build(gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock, &config_load);
defer upstreams.deinit(gpa);
var upstreams = try Upstreams.build(io, gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock, &config_load);
defer upstreams.deinit(io, gpa);
var pool: pool_mod.Pool = .init(
upstreams.active(),
@@ -1146,16 +1146,34 @@ fn maintenanceOnce(
/// The pool's entries and everything they point into.
///
/// `Pool.Entry.client` is a type-erased pointer into `doh` or `dot`, and each
/// client borrows a slice of `doh_buf`/`dot_buf`, so all five allocations live
/// exactly as long as the pool does. One entry is used by one task at a time
/// (`Entry.busy`), which is why the buffers are per client and not shared the
/// way `cli.probeUpstreams` shares them.
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
/// slot of its entry, so that many exchanges can be in flight against it at
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
/// allocation here lives exactly as long as the pool does, and none of them is
/// ever resized. One slot is used by one task at a time, which is why the
/// buffers are per client and not shared the way `cli.probeUpstreams` shares
/// them.
const Upstreams = struct {
entries: []pool_mod.Entry,
used: usize,
/// Sliced per entry into `Entry.slots`, never pointing into the client
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
slot_storage: []pool_mod.Slot,
/// One per enabled upstream, and the reason it is a separate allocation:
/// `Pool.init` sorts entries by value, so a counter living inside an entry
/// would be pointed at by the wrong upstream's clients after the sort.
recovery_counters: []std.atomic.Value(u64),
doh: []doh_client.DohClient,
dot: []dot_client.DotClient,
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
/// upstream leaves the tail of an over-allocated array undefined, and both
/// `deinit` and `build`'s failure paths iterate only the initialized
/// prefix — reading a `DotClient` that was never built, or closing a
/// session that was never opened, is what these two counts prevent.
doh_used: usize,
dot_used: usize,
doh_buf: []u8,
dot_buf: []u8,
@@ -1163,6 +1181,7 @@ const Upstreams = struct {
/// skipped, because one bad row in a table of four must not take DNS down.
/// No usable row at all is a configuration fault.
fn build(
io: std.Io,
gpa: Allocator,
servers: []const model.UpstreamServer,
http: *std.http.Client,
@@ -1177,24 +1196,30 @@ const Upstreams = struct {
if (enabled == 0) return error.NoUsableUpstreams;
const chunk = tls.Client.min_buffer_len;
const slots = pool_mod.slots_per_entry;
const leaf_clients = enabled * slots;
var self: Upstreams = .{
.entries = try gpa.alloc(pool_mod.Entry, enabled),
.used = 0,
.slot_storage = &.{},
.recovery_counters = &.{},
.doh = &.{},
.dot = &.{},
.doh_used = 0,
.dot_used = 0,
.doh_buf = &.{},
.dot_buf = &.{},
};
errdefer self.deinit(gpa);
errdefer self.deinit(io, gpa);
self.doh = try gpa.alloc(doh_client.DohClient, enabled);
self.dot = try gpa.alloc(dot_client.DotClient, enabled);
self.doh_buf = try gpa.alloc(u8, enabled * (doh_request_buf_len + doh_transfer_buf_len));
self.dot_buf = try gpa.alloc(u8, enabled * 4 * chunk);
var doh_count: usize = 0;
var dot_count: usize = 0;
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
for (self.recovery_counters) |*counter| counter.* = .init(0);
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
for (servers) |server| {
if (!server.enabled) continue;
@@ -1208,46 +1233,27 @@ const Upstreams = struct {
continue;
};
const client: transport.Client = switch (endpoint.scheme) {
.doh => doh: {
const base = doh_count * (doh_request_buf_len + doh_transfer_buf_len);
const slot = &self.doh[doh_count];
slot.* = doh_client.DohClient.init(
http,
endpoint,
self.doh_buf[base..][0..doh_request_buf_len],
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
) catch {
log.warn(
"upstream {f} is not a usable DoH url; skipped",
.{safe_url.redactQuoted(server.url)},
);
noteUpstream(config_load, server.url, "not a usable DoH url; skipped");
continue;
};
doh_count += 1;
break :doh slot.client();
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
switch (endpoint.scheme) {
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
log.warn(
"upstream {f} is not a usable DoH url; skipped",
.{safe_url.redactQuoted(server.url)},
);
noteUpstream(config_load, server.url, "not a usable DoH url; skipped");
continue;
},
.dot => dot: {
const base = dot_count * 4 * chunk;
const slot = &self.dot[dot_count];
slot.* = dot_client.DotClient.init(endpoint, server.tls_name, gpa, bundle, bundle_lock, .{
.tls_read = self.dot_buf[base..][0..chunk],
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
});
dot_count += 1;
break :dot slot.client();
},
};
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
}
self.entries[self.used] = .{
.endpoint = endpoint,
.client = client,
.slots = entry_slots,
.priority = server.priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = entry_slots.len },
.reuse_recoveries = &self.recovery_counters[self.used],
};
self.used += 1;
}
@@ -1256,17 +1262,88 @@ const Upstreams = struct {
return self;
}
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
/// connection pool already serves concurrent requests, and a `DohClient`'s
/// only mutable state is the two buffers this gives each slot its own of.
///
/// False means the url is not a usable DoH url, which `DohClient.init`
/// decides from the url alone — so it fails on the first slot or on none.
/// `doh_used` still advances per client rather than per entry: it means
/// "initialized", and a skipped entry's clients are simply never reached.
fn wireDoh(
self: *Upstreams,
http: *std.http.Client,
endpoint: transport.Endpoint,
slots: []pool_mod.Slot,
) bool {
for (slots) |*slot| {
const index = self.doh_used;
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
self.doh[index] = doh_client.DohClient.init(
http,
endpoint,
self.doh_buf[base..][0..doh_request_buf_len],
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
) catch return false;
self.doh_used = index + 1;
slot.* = .{ .client = self.doh[index].client() };
}
return true;
}
/// One `DotClient` per slot, each with its own four TLS buffers and all
/// sharing the trust store. Every client of one entry reports its stale-reuse
/// recoveries through that entry's counter.
fn wireDot(
self: *Upstreams,
gpa: Allocator,
endpoint: transport.Endpoint,
tls_name: []const u8,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
slots: []pool_mod.Slot,
) void {
const chunk = tls.Client.min_buffer_len;
const recoveries = &self.recovery_counters[self.used];
for (slots) |*slot| {
const index = self.dot_used;
const base = index * 4 * chunk;
self.dot[index] = dot_client.DotClient.init(
endpoint,
tls_name,
gpa,
bundle,
bundle_lock,
recoveries,
.{
.tls_read = self.dot_buf[base..][0..chunk],
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
},
);
self.dot_used = index + 1;
slot.* = .{ .client = self.dot[index].client() };
}
}
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
/// never filled, which is what keeps `deinit` able to free the whole block.
fn active(self: *Upstreams) []pool_mod.Entry {
return self.entries[0..self.used];
}
fn deinit(self: *Upstreams, gpa: Allocator) void {
/// Connections first, memory second: a `DotClient` holds a socket its
/// buffers belong to, so nothing it points at may be freed before it is
/// closed.
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
for (self.dot[0..self.dot_used]) |*client| client.close(io);
gpa.free(self.dot_buf);
gpa.free(self.doh_buf);
gpa.free(self.dot);
gpa.free(self.doh);
gpa.free(self.recovery_counters);
gpa.free(self.slot_storage);
gpa.free(self.entries);
self.* = undefined;
}