milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
@@ -87,10 +87,7 @@ pub fn applyCreate(
|
||||
arena: Allocator,
|
||||
item: model.BlocklistSource,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -109,10 +106,7 @@ pub fn applyUpdate(
|
||||
id: i64,
|
||||
item: model.BlocklistSource,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -124,10 +118,7 @@ pub fn applyUpdate(
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = sources_repo.deleteSource(database, id);
|
||||
@@ -189,32 +180,19 @@ pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.Sour
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"),
|
||||
};
|
||||
const resource = mutations.Resource(.{
|
||||
.Row = sources_repo.SourceRow,
|
||||
.list = sources_repo.listSourceRows,
|
||||
.get = sources_repo.getSource,
|
||||
.remove = applyDelete,
|
||||
.label = "a blocklist",
|
||||
.plural = "blocklists",
|
||||
.envelope = "blocklists",
|
||||
});
|
||||
|
||||
const rows = sources_repo.listSourceRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .blocklists = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a blocklist"),
|
||||
};
|
||||
|
||||
const row = sources_repo.getSource(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a blocklist");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
@@ -251,13 +229,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a blocklist");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
/// `POST /api/blocklists/update`.
|
||||
pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses);
|
||||
|
||||
@@ -61,10 +61,7 @@ pub fn applyUpdate(
|
||||
id: i64,
|
||||
edit: clients_repo.ClientEdit,
|
||||
) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = clients_repo.updateClient(database, id, edit);
|
||||
@@ -75,10 +72,7 @@ pub fn applyUpdate(
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = clients_repo.deleteClient(database, id);
|
||||
@@ -94,10 +88,7 @@ pub fn applyReplacePrefixes(
|
||||
arena: Allocator,
|
||||
items: []const clients_repo.ClientPrefixInput,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
// Canonical duplicates are the same UNIQUE collision the database would
|
||||
// report for identical text, so they answer 409 (ruling 9) before the
|
||||
@@ -152,32 +143,33 @@ fn checkPrefixSet(
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing clients"),
|
||||
};
|
||||
const resource = mutations.Resource(.{
|
||||
.Row = clients_repo.ClientRow,
|
||||
.list = clients_repo.listClientRows,
|
||||
.get = clients_repo.getClient,
|
||||
.remove = applyDelete,
|
||||
.label = "a client",
|
||||
.plural = "clients",
|
||||
.envelope = "clients",
|
||||
});
|
||||
|
||||
const rows = clients_repo.listClientRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing clients");
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{});
|
||||
}
|
||||
/// The prefixes are one list resource with no `/{id}` route: the whole set is
|
||||
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
|
||||
const prefixes_resource = mutations.Resource(.{
|
||||
.Row = clients_repo.ClientPrefixRow,
|
||||
.list = clients_repo.listClientPrefixRows,
|
||||
.get = null,
|
||||
.remove = null,
|
||||
.label = "a client prefix",
|
||||
.plural = "client prefixes",
|
||||
.envelope = "client_prefixes",
|
||||
});
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a client"),
|
||||
};
|
||||
|
||||
const row = clients_repo.getClient(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a client");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
pub const listPrefixes = prefixes_resource.list;
|
||||
|
||||
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(ClientBody, request) catch |err|
|
||||
@@ -199,26 +191,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a client");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"),
|
||||
};
|
||||
|
||||
const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(PrefixesBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
|
||||
+18
-52
@@ -54,10 +54,7 @@ pub fn applyCreate(
|
||||
arena: Allocator,
|
||||
item: model.Group,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
if (try mutations.checkGroupName(arena, item.name)) |problem| {
|
||||
return .{ .fail = .{ .invalid = problem } };
|
||||
}
|
||||
@@ -78,10 +75,7 @@ pub fn applyUpdate(
|
||||
id: i64,
|
||||
item: model.Group,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
if (try mutations.checkGroupName(arena, item.name)) |problem| {
|
||||
return .{ .invalid = problem };
|
||||
}
|
||||
@@ -112,10 +106,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = deleteLocked(database, arena, id);
|
||||
@@ -148,10 +139,7 @@ pub fn applySetSources(
|
||||
id: i64,
|
||||
source_ids: []const i64,
|
||||
) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = groups_repo.setGroupSources(database, id, source_ids);
|
||||
@@ -165,32 +153,19 @@ pub fn applySetSources(
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing groups"),
|
||||
};
|
||||
const resource = mutations.Resource(.{
|
||||
.Row = groups_repo.GroupRow,
|
||||
.list = groups_repo.listGroupRows,
|
||||
.get = groups_repo.getGroup,
|
||||
.remove = applyDelete,
|
||||
.label = "a group",
|
||||
.plural = "groups",
|
||||
.envelope = "groups",
|
||||
});
|
||||
|
||||
const rows = groups_repo.listGroupRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing groups");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .groups = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
|
||||
};
|
||||
|
||||
const row = groups_repo.getGroup(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a group");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
@@ -223,20 +198,11 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a group");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
/// `GET /api/groups/{id}/sources` — the assignment the PUT replaces.
|
||||
pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, "reading a group");
|
||||
const id = request.id.?;
|
||||
|
||||
const row = groups_repo.getGroup(database, request.arena, id) catch |err|
|
||||
|
||||
+31
-91
@@ -94,10 +94,7 @@ pub fn applyCreateRecord(
|
||||
arena: Allocator,
|
||||
item: model.LocalRecord,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -116,10 +113,7 @@ pub fn applyUpdateRecord(
|
||||
id: i64,
|
||||
item: model.LocalRecord,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -131,10 +125,7 @@ pub fn applyUpdateRecord(
|
||||
}
|
||||
|
||||
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
@@ -154,10 +145,7 @@ pub fn applyCreateZone(
|
||||
arena: Allocator,
|
||||
item: model.ForwardZone,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -176,10 +164,7 @@ pub fn applyUpdateZone(
|
||||
id: i64,
|
||||
item: model.ForwardZone,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -191,10 +176,7 @@ pub fn applyUpdateZone(
|
||||
}
|
||||
|
||||
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
@@ -208,35 +190,20 @@ pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id
|
||||
// local records: routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing local records"),
|
||||
};
|
||||
const records_resource = mutations.Resource(.{
|
||||
.Row = local_repo.LocalRecordRow,
|
||||
.list = local_repo.listLocalRecordRows,
|
||||
.get = local_repo.getLocalRecord,
|
||||
.remove = applyDeleteRecord,
|
||||
.label = "a local record",
|
||||
.plural = "local records",
|
||||
.envelope = "local_records",
|
||||
.view = RecordView.from,
|
||||
});
|
||||
|
||||
const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing local records");
|
||||
|
||||
const views = try request.arena.alloc(RecordView, rows.items.len);
|
||||
for (views, rows.items) |*view, row| view.* = .from(row);
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{});
|
||||
}
|
||||
|
||||
pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"),
|
||||
};
|
||||
|
||||
const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a local record");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, RecordView.from(found), &.{});
|
||||
}
|
||||
pub const listRecords = records_resource.list;
|
||||
pub const getRecord = records_resource.get;
|
||||
pub const removeRecord = records_resource.remove;
|
||||
|
||||
pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(RecordBody, request) catch |err|
|
||||
@@ -279,43 +246,23 @@ pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) Hand
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a local record");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward zones: routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"),
|
||||
};
|
||||
const zones_resource = mutations.Resource(.{
|
||||
.Row = local_repo.ForwardZoneRow,
|
||||
.list = local_repo.listForwardZoneRows,
|
||||
.get = local_repo.getForwardZone,
|
||||
.remove = applyDeleteZone,
|
||||
.label = "a forward zone",
|
||||
.plural = "forward zones",
|
||||
.envelope = "forward_zones",
|
||||
});
|
||||
|
||||
const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"),
|
||||
};
|
||||
|
||||
const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
pub const listZones = zones_resource.list;
|
||||
pub const getZone = zones_resource.get;
|
||||
pub const removeZone = zones_resource.remove;
|
||||
|
||||
pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
|
||||
@@ -348,13 +295,6 @@ pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) Handle
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a forward zone");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -101,16 +101,156 @@ pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
|
||||
};
|
||||
}
|
||||
|
||||
/// The config connection, or the 503 a state without one earns.
|
||||
pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } {
|
||||
if (state.config_db) |database| return .{ .database = database };
|
||||
return .{ .fail = .{ .unavailable = "no configuration database" } };
|
||||
/// The 503 a state with no config connection earns. One constant, because every
|
||||
/// caller reports the same missing collaborator in the same words.
|
||||
pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };
|
||||
|
||||
/// The config connection, or `error.NoConfigDb` for the caller to turn into
|
||||
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
|
||||
/// or a written response.
|
||||
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
|
||||
return state.config_db orelse error.NoConfigDb;
|
||||
}
|
||||
|
||||
pub fn nowSeconds(io: std.Io) i64 {
|
||||
return std.Io.Clock.real.now(io).toSeconds();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the identical half of an id-addressed resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `list`, `get` and `remove` handlers every id-addressed resource in this
|
||||
/// directory writes the same way: take the config connection or answer 503, call
|
||||
/// one repository function, and turn what comes back into the response. Nothing
|
||||
/// a resource decides for itself is here — the create and update bodies, the
|
||||
/// constraint texts, and the four reload flavors stay hand-written beside the
|
||||
/// descriptor that names these three.
|
||||
///
|
||||
/// `desc` is an anonymous struct literal rather than a typed struct because
|
||||
/// `anytype` is not legal as a struct *field* type and the members are functions
|
||||
/// of five signatures. Every member is checked below, so a descriptor that is
|
||||
/// missing one or spells one wrong is a compile error that names it.
|
||||
///
|
||||
/// Members:
|
||||
///
|
||||
/// - `Row: type` — what the repository returns for one row.
|
||||
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
|
||||
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
|
||||
/// with no `/{id}` route.
|
||||
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
|
||||
/// `Allocator` before the id for a delete decision that reads rows, or `null`.
|
||||
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
|
||||
/// their object in the log context a 500 carries.
|
||||
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
|
||||
/// - `envelope: []const u8` — the JSON key the list arrives under.
|
||||
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
|
||||
/// row spells the difference here; without it the row is serialised as it is.
|
||||
pub fn Resource(comptime desc: anytype) type {
|
||||
const Desc = @TypeOf(desc);
|
||||
for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
|
||||
if (!@hasField(Desc, name)) {
|
||||
@compileError("resource descriptor has no `" ++ name ++ "`");
|
||||
}
|
||||
}
|
||||
if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
|
||||
const Row = desc.Row;
|
||||
|
||||
expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
|
||||
if (!isNull(@TypeOf(desc.get))) {
|
||||
expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
|
||||
}
|
||||
if (!isNull(@TypeOf(desc.remove))) {
|
||||
const Remove = @TypeOf(desc.remove);
|
||||
if (removeTakesArena(Remove)) {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
|
||||
} else {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
|
||||
}
|
||||
}
|
||||
_ = @as([]const u8, desc.label);
|
||||
_ = @as([]const u8, desc.plural);
|
||||
_ = @as([]const u8, desc.envelope);
|
||||
|
||||
const has_view = @hasField(Desc, "view");
|
||||
if (has_view) {
|
||||
const info = @typeInfo(@TypeOf(desc.view)).@"fn";
|
||||
if (info.params.len != 1 or info.params[0].type.? != Row) {
|
||||
@compileError("resource descriptor `view` must take one " ++ @typeName(Row));
|
||||
}
|
||||
}
|
||||
const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;
|
||||
|
||||
const names: [1][:0]const u8 = .{desc.envelope};
|
||||
const types: [1]type = .{[]const View};
|
||||
const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
|
||||
const Envelope = @Struct(.auto, null, &names, &types, &attrs);
|
||||
|
||||
const list_what = "listing " ++ desc.plural;
|
||||
const get_what = "reading " ++ desc.label;
|
||||
const remove_what = "deleting " ++ desc.label;
|
||||
|
||||
return struct {
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = requireConfigDb(state) catch
|
||||
return respondFailure(request, no_config_db, list_what);
|
||||
|
||||
const rows = desc.list(database, request.arena) catch |err|
|
||||
return respondFailure(request, .{ .internal = err }, list_what);
|
||||
|
||||
const items: []const View = if (has_view) views: {
|
||||
const views = try request.arena.alloc(View, rows.items.len);
|
||||
for (views, rows.items) |*view, row| view.* = desc.view(row);
|
||||
break :views views;
|
||||
} else rows.items;
|
||||
|
||||
var payload: Envelope = undefined;
|
||||
@field(payload, desc.envelope) = items;
|
||||
return http_util.respondJson(request, .ok, payload, &.{});
|
||||
}
|
||||
|
||||
pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
|
||||
pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;
|
||||
|
||||
fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = requireConfigDb(state) catch
|
||||
return respondFailure(request, no_config_db, get_what);
|
||||
|
||||
const row = desc.get(database, request.arena, request.id.?) catch |err|
|
||||
return respondFailure(request, .{ .internal = err }, get_what);
|
||||
const found = row orelse return respondFailure(request, .not_found, "");
|
||||
|
||||
const body: View = if (has_view) desc.view(found) else found;
|
||||
return http_util.respondJson(request, .ok, body, &.{});
|
||||
}
|
||||
|
||||
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
|
||||
desc.remove(state, io, request.arena, request.id.?)
|
||||
else
|
||||
desc.remove(state, io, request.id.?);
|
||||
|
||||
if (failure) |value| return respondFailure(request, value, remove_what);
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn isNull(comptime T: type) bool {
|
||||
return T == @TypeOf(null);
|
||||
}
|
||||
|
||||
fn removeTakesArena(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
|
||||
}
|
||||
|
||||
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
|
||||
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
|
||||
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applying a change to the running server (ruling 12)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -439,6 +579,18 @@ test "the schema the bench opens already holds the default group" {
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
|
||||
}
|
||||
|
||||
test "a state with no configuration database is a 503, not a crash" {
|
||||
var bench: Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));
|
||||
|
||||
var bare: server.WebState = .{ .gpa = testing.allocator };
|
||||
try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
|
||||
try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
|
||||
}
|
||||
|
||||
test "a database error maps to the status its cause deserves" {
|
||||
try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
|
||||
try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
|
||||
|
||||
+16
-47
@@ -60,10 +60,7 @@ pub fn applyCreate(
|
||||
arena: Allocator,
|
||||
item: rules_repo.RuleInput,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
|
||||
return .{ .fail = .{ .invalid = problem } };
|
||||
}
|
||||
@@ -84,10 +81,7 @@ pub fn applyUpdate(
|
||||
id: i64,
|
||||
item: rules_repo.RuleInput,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
|
||||
return .{ .invalid = problem };
|
||||
}
|
||||
@@ -101,10 +95,7 @@ pub fn applyUpdate(
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = rules_repo.deleteRule(database, id);
|
||||
@@ -142,35 +133,20 @@ const RuleView = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing rules"),
|
||||
};
|
||||
const resource = mutations.Resource(.{
|
||||
.Row = rules_repo.RuleRow,
|
||||
.list = rules_repo.listRuleRows,
|
||||
.get = rules_repo.getRule,
|
||||
.remove = applyDelete,
|
||||
.label = "a rule",
|
||||
.plural = "rules",
|
||||
.envelope = "rules",
|
||||
.view = RuleView.from,
|
||||
});
|
||||
|
||||
const rows = rules_repo.listRuleRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing rules");
|
||||
|
||||
const views = try request.arena.alloc(RuleView, rows.items.len);
|
||||
for (views, rows.items) |*view, row| view.* = .from(row);
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .rules = views }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a rule"),
|
||||
};
|
||||
|
||||
const row = rules_repo.getRule(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a rule");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, RuleView.from(found), &.{});
|
||||
}
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
@@ -213,13 +189,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a rule");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -281,10 +281,7 @@ pub fn applyPut(
|
||||
arena: Allocator,
|
||||
patch: Patch,
|
||||
) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
|
||||
// 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
|
||||
@@ -449,10 +446,8 @@ pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"),
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
|
||||
|
||||
// Under the same lock the mutation handlers hold: a PUT rewrites every
|
||||
// settings row in one transaction on this shared connection, and SQLite's
|
||||
|
||||
@@ -44,10 +44,7 @@ pub fn applyCreate(
|
||||
arena: Allocator,
|
||||
item: model.UpstreamServer,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
||||
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -65,10 +62,7 @@ pub fn applyUpdate(
|
||||
id: i64,
|
||||
item: model.UpstreamServer,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -96,10 +90,7 @@ pub fn applyUpdate(
|
||||
/// answers nothing, and `validate.validate` refuses that configuration at
|
||||
/// startup — so allowing it here would only produce a box that will not boot.
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
@@ -142,32 +133,19 @@ fn countEnabledExcept(
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"),
|
||||
};
|
||||
const resource = mutations.Resource(.{
|
||||
.Row = upstreams_repo.UpstreamRow,
|
||||
.list = upstreams_repo.listUpstreamRows,
|
||||
.get = upstreams_repo.getUpstream,
|
||||
.remove = applyDelete,
|
||||
.label = "an upstream",
|
||||
.plural = "upstreams",
|
||||
.envelope = "upstreams",
|
||||
});
|
||||
|
||||
const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .upstreams = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading an upstream"),
|
||||
};
|
||||
|
||||
const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
@@ -206,13 +184,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting an upstream");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
fn toModel(body: Body) model.UpstreamServer {
|
||||
return .{
|
||||
.url = body.url,
|
||||
|
||||
+8
-8
@@ -776,7 +776,7 @@ test "the plain-DNS listener families carry every counter of both listeners" {
|
||||
.send_errors = 5,
|
||||
},
|
||||
.tcp_listener = .{
|
||||
.accepted = 12,
|
||||
.connections = 12,
|
||||
.rejected_at_capacity = 6,
|
||||
.rejected_at_shutdown = 7,
|
||||
.accept_errors = 8,
|
||||
@@ -793,7 +793,7 @@ test "the plain-DNS listener families carry every counter of both listeners" {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accepted_total 12\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
|
||||
@@ -822,13 +822,13 @@ test "one family covers all four listeners, summed" {
|
||||
udp4.stats.dropped_no_slot.store(2, .monotonic);
|
||||
|
||||
var tcp6: tcp_server.TcpServer = undefined;
|
||||
tcp6.stats = .{};
|
||||
tcp6.stats.accepted.store(4, .monotonic);
|
||||
tcp6.core.stats = .{};
|
||||
tcp6.core.stats.connections.store(4, .monotonic);
|
||||
|
||||
var tcp4: tcp_server.TcpServer = undefined;
|
||||
tcp4.stats = .{};
|
||||
tcp4.stats.accepted.store(5, .monotonic);
|
||||
tcp4.stats.idle_timeouts.store(3, .monotonic);
|
||||
tcp4.core.stats = .{};
|
||||
tcp4.core.stats.connections.store(5, .monotonic);
|
||||
tcp4.core.stats.idle_timeouts.store(3, .monotonic);
|
||||
|
||||
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
|
||||
try testing.expectEqual(@as(u64, 17), udp.received);
|
||||
@@ -836,7 +836,7 @@ test "one family covers all four listeners, summed" {
|
||||
try testing.expectEqual(@as(u64, 0), udp.send_errors);
|
||||
|
||||
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
|
||||
try testing.expectEqual(@as(u64, 9), tcp.accepted);
|
||||
try testing.expectEqual(@as(u64, 9), tcp.connections);
|
||||
try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts);
|
||||
|
||||
// No listener at all is a missing family, not a family of zeros.
|
||||
|
||||
+68
-283
@@ -1,20 +1,11 @@
|
||||
//! The admin HTTP listener.
|
||||
//!
|
||||
//! One `std.http.Server` per connection over our own accept loop: a listener
|
||||
//! task in the app's group, an inner `Io.Group` of connection tasks, and a
|
||||
//! keep-alive loop per connection that ends on `error.HttpConnectionClosing`.
|
||||
//! The shape is lib/std/Build/WebServer.zig:152-185; the shutdown split is
|
||||
//! tcp_server.zig's, for the same reason.
|
||||
//!
|
||||
//! Shutdown takes one of two paths:
|
||||
//!
|
||||
//! - `deinit` shuts the listening socket down (which unblocks `accept` with
|
||||
//! `error.SocketNotListening`) and then shuts every live connection down, so
|
||||
//! each one unblocks and finishes its response. `serve` drains them.
|
||||
//! - A canceled `serve` cannot drain: HTTP keep-alive lets a browser hold a
|
||||
//! connection open indefinitely with no request on it, so waiting would let
|
||||
//! one idle tab stall the whole process's shutdown. The connection group is
|
||||
//! canceled instead.
|
||||
//! One `std.http.Server` per connection over the shared `listener.Core` accept
|
||||
//! loop (milestone-18 ruling 1): a listener task in the app's group, an inner
|
||||
//! `Io.Group` of connection tasks, and a keep-alive loop per connection that
|
||||
//! ends on `error.HttpConnectionClosing`. The shape is
|
||||
//! lib/std/Build/WebServer.zig:152-185; the slot pool and the shutdown split
|
||||
//! come from the core, which documents both.
|
||||
//!
|
||||
//! Connection slots are fixed and pre-allocated, and each one owns every buffer
|
||||
//! a request needs, so serving allocates only what a handler asks the
|
||||
@@ -41,6 +32,7 @@ const dns_handler = @import("../server/handler.zig");
|
||||
const doh_server = @import("../server/doh_server.zig");
|
||||
const dot_server = @import("../server/dot_server.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const listener_core = @import("../server/listener.zig");
|
||||
const local_tables_mod = @import("../server/local_tables.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const manager_mod = @import("../filter/manager.zig");
|
||||
@@ -69,10 +61,6 @@ pub const default_max_connections: u16 = 64;
|
||||
/// connections cost 4 MiB rather than 64.
|
||||
const arena_retain_bytes = 64 * 1024;
|
||||
|
||||
/// How long the accept loop waits after an unexpected accept failure, so a
|
||||
/// persistent one cannot turn the loop into a spin.
|
||||
const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
|
||||
|
||||
const over_capacity_body = "{\"error\":\"too many connections\"}";
|
||||
const over_capacity_response = std.fmt.comptimePrint(
|
||||
"HTTP/1.1 503 Service Unavailable\r\n" ++
|
||||
@@ -232,12 +220,10 @@ pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Reques
|
||||
return .ok;
|
||||
}
|
||||
|
||||
/// What the admin listener counts on top of `listener.CoreStats`. Nothing
|
||||
/// exports these: there is no `nxdns_web_*` family, they exist for the
|
||||
/// integration tests and for a future one.
|
||||
pub const Stats = struct {
|
||||
accepted: std.atomic.Value(u64) = .init(0),
|
||||
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
|
||||
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
|
||||
accept_errors: std.atomic.Value(u64) = .init(0),
|
||||
connection_errors: std.atomic.Value(u64) = .init(0),
|
||||
requests: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
@@ -245,41 +231,15 @@ pub const Options = struct {
|
||||
max_connections: u16 = default_max_connections,
|
||||
};
|
||||
|
||||
/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims
|
||||
/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`.
|
||||
const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
/// `.closing` exists so `deinit` never shuts down a descriptor its own task is
|
||||
/// about to close.
|
||||
const ConnState = enum { free, active, closing };
|
||||
|
||||
/// Why the accept loop stopped, which decides what happens to the connections
|
||||
/// still in flight.
|
||||
const Stop = enum { closing, canceled };
|
||||
|
||||
const Claim = union(enum) {
|
||||
slot: usize,
|
||||
at_capacity,
|
||||
shutting_down,
|
||||
};
|
||||
|
||||
pub const Server = struct {
|
||||
core: listener_core.Core(Config),
|
||||
state: *WebState,
|
||||
listener: net.Server,
|
||||
conns: []Conn,
|
||||
mutex: std.Io.Mutex,
|
||||
/// Guarded by `mutex`, set in the same critical section that shuts the live
|
||||
/// connections down.
|
||||
shutdown_begun: bool,
|
||||
stats: Stats,
|
||||
run_state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
/// One slot's fixed cost. The head copies exist because every string in
|
||||
/// `request.head` dies on the first body read (http/Server.zig:594).
|
||||
pub const Conn = struct {
|
||||
recv_buf: [recv_buffer_len]u8,
|
||||
send_buf: [send_buffer_len]u8,
|
||||
/// `request.head` dies on the first body read (http/Server.zig:594). The
|
||||
/// receive and send buffers belong to the core.
|
||||
pub const Payload = struct {
|
||||
target_buf: [http_util.max_target_len]u8,
|
||||
cookie_buf: [http_util.max_cookie_len]u8,
|
||||
accept_encoding_buf: [http_util.max_header_value_len]u8,
|
||||
@@ -288,13 +248,31 @@ pub const Server = struct {
|
||||
/// Per-request working memory, reset between requests on the same
|
||||
/// connection so a keep-alive client cannot grow it without bound.
|
||||
arena: std.heap.ArenaAllocator,
|
||||
stream: net.Stream,
|
||||
peer: net.IpAddress,
|
||||
/// Guarded by `Server.mutex`.
|
||||
conn_state: ConnState,
|
||||
|
||||
fn init(payload: *Payload, gpa: Allocator) void {
|
||||
payload.arena = .init(gpa);
|
||||
}
|
||||
|
||||
fn deinit(payload: *Payload) void {
|
||||
payload.arena.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
|
||||
const Config = struct {
|
||||
pub const Owner = Server;
|
||||
pub const ConnPayload = Payload;
|
||||
pub const serveConn = serveOne;
|
||||
pub const read_buffer_len = recv_buffer_len;
|
||||
pub const write_buffer_len = send_buffer_len;
|
||||
pub const log = std.log.scoped(.web_server);
|
||||
pub const name = "web";
|
||||
pub const refuse = refuseOverCapacity;
|
||||
pub const initPayload = Payload.init;
|
||||
pub const deinitPayload = Payload.deinit;
|
||||
};
|
||||
|
||||
pub const Conn = listener_core.Core(Config).Conn;
|
||||
pub const ListenError = listener_core.Core(Config).ListenError;
|
||||
|
||||
pub fn listen(
|
||||
gpa: Allocator,
|
||||
@@ -303,128 +281,35 @@ pub const Server = struct {
|
||||
state: *WebState,
|
||||
options: Options,
|
||||
) ListenError!Server {
|
||||
std.debug.assert(options.max_connections > 0);
|
||||
|
||||
const conns = try gpa.alloc(Conn, options.max_connections);
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| {
|
||||
conn.conn_state = .free;
|
||||
conn.arena = .init(gpa);
|
||||
}
|
||||
|
||||
const listener = try listen_address.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
.core = try listener_core.Core(Config).listen(gpa, io, listen_address, options.max_connections),
|
||||
.state = state,
|
||||
.listener = listener,
|
||||
.conns = conns,
|
||||
.mutex = .init,
|
||||
.shutdown_begun = false,
|
||||
.stats = .{},
|
||||
.run_state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
||||
pub fn boundAddress(self: *const Server) net.IpAddress {
|
||||
return self.listener.socket.address;
|
||||
return self.core.boundAddress();
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *Server, io: std.Io) void {
|
||||
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
switch (self.acceptLoop(io, &group)) {
|
||||
// `deinit` shut every live connection down before it published
|
||||
// `.closing`, so each one is unblocked and finishing on its own.
|
||||
// Awaiting them means a half-written response still goes out whole.
|
||||
.closing => {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
_ = io.swapCancelProtection(prev);
|
||||
},
|
||||
// Nothing has shut these connections down, and an idle keep-alive
|
||||
// connection has no deadline of its own, so draining could wait
|
||||
// forever. Cancel joins, so the slots are quiet by the time `serve`
|
||||
// returns; the price is the one response that was mid-write.
|
||||
.canceled => group.cancel(io),
|
||||
}
|
||||
|
||||
self.stopped.set(io);
|
||||
self.core.serve(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server, gpa: Allocator, io: std.Io) void {
|
||||
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving;
|
||||
|
||||
// Shutting the listening socket down is the documented way to unblock a
|
||||
// pending `accept`: it fails with `error.SocketNotListening`.
|
||||
const listener: net.Stream = .{ .socket = self.listener.socket };
|
||||
listener.shutdown(io, .both) catch |err| {
|
||||
log.debug("web listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// Ruling 11 of milestone 16, before `beginShutdown`: a live-query task
|
||||
// parked in `Hub.wait` is waiting on an event, not on its socket, so
|
||||
// shutting the connection down does not reach it. Without this the
|
||||
// drain below waits out one heartbeat interval per idle stream.
|
||||
pub fn deinit(self: *Server, io: std.Io) void {
|
||||
// Ruling 11 of milestone 16, before the core shuts the connections
|
||||
// down: a live-query task parked in `Hub.wait` is waiting on an event,
|
||||
// not on its socket, so shutting the connection down does not reach it.
|
||||
// Without this the drain waits out one heartbeat interval per idle
|
||||
// stream.
|
||||
if (self.state.hub) |hub| hub.close(io);
|
||||
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
|
||||
self.listener.deinit(io);
|
||||
for (self.conns) |*conn| conn.arena.deinit();
|
||||
gpa.free(self.conns);
|
||||
self.core.deinit(io);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *Server, io: std.Io, group: *std.Io.Group) Stop {
|
||||
while (self.run_state.load(.acquire) == .serving) {
|
||||
const stream = self.listener.accept(io) catch |err| switch (err) {
|
||||
error.Canceled => return .canceled,
|
||||
error.SocketNotListening => return .closing,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
log.debug("web accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return .canceled;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
const index = switch (self.claim(io, stream)) {
|
||||
.slot => |index| index,
|
||||
.at_capacity => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
refuse(io, stream);
|
||||
continue;
|
||||
},
|
||||
.shutting_down => {
|
||||
bump(&self.stats.rejected_at_shutdown);
|
||||
stream.close(io);
|
||||
return .closing;
|
||||
},
|
||||
};
|
||||
|
||||
group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
self.finish(io, index);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
bump(&self.stats.accepted);
|
||||
}
|
||||
|
||||
// The loop condition failed, which only `deinit` can cause.
|
||||
return .closing;
|
||||
}
|
||||
|
||||
/// Ruling 7: over capacity the client is told so, never silently dropped.
|
||||
///
|
||||
/// The response is written from the accept loop, because refusing must not
|
||||
@@ -437,7 +322,7 @@ pub const Server = struct {
|
||||
/// would mean a blocking read on the accept loop with no bound but the
|
||||
/// client's goodwill, which is a worse failure than a lost error page on a
|
||||
/// server that is already at capacity.
|
||||
fn refuse(io: std.Io, stream: net.Stream) void {
|
||||
fn refuseOverCapacity(io: std.Io, stream: net.Stream) void {
|
||||
var buf: [over_capacity_response.len]u8 = undefined;
|
||||
var writer = stream.writer(io, &buf);
|
||||
writer.interface.writeAll(over_capacity_response) catch {};
|
||||
@@ -445,12 +330,12 @@ pub const Server = struct {
|
||||
stream.close(io);
|
||||
}
|
||||
|
||||
fn serveConn(self: *Server, io: std.Io, index: usize) void {
|
||||
defer self.finish(io, index);
|
||||
|
||||
const conn = &self.conns[index];
|
||||
var reader = conn.stream.reader(io, &conn.recv_buf);
|
||||
var writer = conn.stream.writer(io, &conn.send_buf);
|
||||
/// One connection's keep-alive loop. The core closes the slot when this
|
||||
/// returns.
|
||||
fn serveOne(self: *Server, io: std.Io, index: usize) void {
|
||||
const conn = &self.core.conns[index];
|
||||
var reader = conn.stream.reader(io, &conn.read_buf);
|
||||
var writer = conn.stream.writer(io, &conn.write_buf);
|
||||
var connection: http.Server = .init(&reader.interface, &writer.interface);
|
||||
|
||||
while (connection.reader.state == .ready) {
|
||||
@@ -461,11 +346,11 @@ pub const Server = struct {
|
||||
// worth a counter.
|
||||
error.ReadFailed => return,
|
||||
error.HttpHeadersOversize => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener_core.bump(&self.core.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
error.HttpRequestTruncated, error.HttpHeadersInvalid => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener_core.bump(&self.core.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
};
|
||||
@@ -484,17 +369,17 @@ pub const Server = struct {
|
||||
request.head.content_length = 0;
|
||||
}
|
||||
|
||||
bump(&self.stats.requests);
|
||||
listener_core.bump(&self.stats.requests);
|
||||
// Retained with a limit, not wholesale: a single 1 MiB body would
|
||||
// otherwise keep a megabyte per slot alive for as long as the
|
||||
// browser holds the connection.
|
||||
_ = conn.arena.reset(.{ .retain_with_limit = arena_retain_bytes });
|
||||
_ = conn.payload.arena.reset(.{ .retain_with_limit = arena_retain_bytes });
|
||||
|
||||
self.handleRequest(io, conn, &request) catch |err| switch (err) {
|
||||
// Ruling 28: the peer went away mid-response. Normal.
|
||||
error.WriteFailed => return,
|
||||
error.HttpExpectationFailed, error.OutOfMemory => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener_core.bump(&self.core.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
};
|
||||
@@ -509,26 +394,26 @@ pub const Server = struct {
|
||||
conn: *Conn,
|
||||
request: *http.Server.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const arena = conn.arena.allocator();
|
||||
const arena = conn.payload.arena.allocator();
|
||||
|
||||
const target = request.head.target;
|
||||
if (target.len > conn.target_buf.len) {
|
||||
if (target.len > conn.payload.target_buf.len) {
|
||||
var view = bareRequest(request, conn, arena);
|
||||
return http_util.respondError(&view, .uri_too_long, "target too long");
|
||||
}
|
||||
@memcpy(conn.target_buf[0..target.len], target);
|
||||
const copied = conn.target_buf[0..target.len];
|
||||
@memcpy(conn.payload.target_buf[0..target.len], target);
|
||||
const copied = conn.payload.target_buf[0..target.len];
|
||||
|
||||
const split = std.mem.findScalar(u8, copied, '?') orelse copied.len;
|
||||
const raw_path = copied[0..split];
|
||||
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
|
||||
|
||||
const cookie = copyCookie(request, &conn.cookie_buf);
|
||||
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
|
||||
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
|
||||
const cookie = copyCookie(request, &conn.payload.cookie_buf);
|
||||
const accept_encoding = copyHeader(request, "accept-encoding", &conn.payload.accept_encoding_buf);
|
||||
const if_none_match = copyHeader(request, "if-none-match", &conn.payload.if_none_match_buf);
|
||||
|
||||
const peer = address.NetAddress.fromIp(conn.peer);
|
||||
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf);
|
||||
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
|
||||
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
|
||||
.addr => |addr| addr,
|
||||
.bad_forwarded_for => {
|
||||
@@ -585,58 +470,6 @@ pub const Server = struct {
|
||||
.arena = arena,
|
||||
};
|
||||
}
|
||||
|
||||
fn claim(self: *Server, io: std.Io, stream: net.Stream) Claim {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer, so
|
||||
// losing the lock mid-update would leak a slot for nothing.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const outcome = decideClaim(self.conns, self.shutdown_begun);
|
||||
switch (outcome) {
|
||||
.slot => |index| {
|
||||
self.conns[index].stream = stream;
|
||||
self.conns[index].peer = stream.socket.address;
|
||||
self.conns[index].conn_state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn finish(self: *Server, io: std.Io, index: usize) void {
|
||||
const conn = &self.conns[index];
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
conn.conn_state = .closing;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// The socket is released even when this task is being torn down: the
|
||||
// next cancelable call would otherwise skip the close.
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
conn.stream.close(io);
|
||||
_ = io.swapCancelProtection(prev);
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
conn.conn_state = .free;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Closes the door on new connections and unblocks the live ones under one
|
||||
/// hold of the mutex, so no `claim` can slip between the two.
|
||||
fn beginShutdown(self: *Server, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.shutdown_begun = true;
|
||||
|
||||
for (self.conns) |*conn| {
|
||||
if (conn.conn_state != .active) continue;
|
||||
conn.stream.shutdown(io, .both) catch |err| {
|
||||
log.debug("web connection shutdown failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Copies one header value into `buf`. A value too long for its budget reads as
|
||||
@@ -758,19 +591,6 @@ fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 {
|
||||
return buf[0..len];
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex, so it is testable without a backend.
|
||||
fn decideClaim(conns: []const Server.Conn, shutdown_begun: bool) Claim {
|
||||
if (shutdown_begun) return .shutting_down;
|
||||
for (conns, 0..) |*conn, index| {
|
||||
if (conn.conn_state == .free) return .{ .slot = index };
|
||||
}
|
||||
return .at_capacity;
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
/// The composition root's entry point: bind, serve, release.
|
||||
///
|
||||
/// A bind failure is warned and swallowed. The admin UI failing to come up must
|
||||
@@ -786,7 +606,7 @@ pub fn serve(state: *WebState, io: std.Io) void {
|
||||
log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err });
|
||||
return;
|
||||
};
|
||||
defer server.deinit(state.gpa, io);
|
||||
defer server.deinit(io);
|
||||
|
||||
log.info("web interface listening on {f}", .{server.boundAddress()});
|
||||
server.serve(io);
|
||||
@@ -794,41 +614,6 @@ pub fn serve(state: *WebState, io: std.Io) void {
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn testConns(count: usize) ![]Server.Conn {
|
||||
const conns = try testing.allocator.alloc(Server.Conn, count);
|
||||
for (conns) |*conn| conn.conn_state = .free;
|
||||
return conns;
|
||||
}
|
||||
|
||||
test "the connection pool hands out every slot once, then refuses" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
|
||||
conns[0].conn_state = .active;
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
conns[1].conn_state = .active;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
}
|
||||
|
||||
test "a closing slot is not reused until it is free" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].conn_state = .closing;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
conns[0].conn_state = .free;
|
||||
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "shutdown outranks capacity and does not consume the slot" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "the over-capacity response is a well formed 503" {
|
||||
try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 503 "));
|
||||
const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?;
|
||||
|
||||
@@ -238,6 +238,19 @@ fn get(path: []const u8, buf: []u8) []const u8 {
|
||||
return std.fmt.bufPrint(buf, "GET {s} HTTP/1.1\r\nhost: t\r\n\r\n", .{path}) catch unreachable;
|
||||
}
|
||||
|
||||
/// The listener's counters, read once after `f` finished and before the
|
||||
/// listener is torn down. Plain values, because they are a report of a run that
|
||||
/// is over: the shared core's counters and the web listener's own one land in
|
||||
/// the same struct here.
|
||||
const Counters = struct {
|
||||
connections: u64,
|
||||
rejected_at_capacity: u64,
|
||||
rejected_at_shutdown: u64,
|
||||
accept_errors: u64,
|
||||
connection_errors: u64,
|
||||
requests: u64,
|
||||
};
|
||||
|
||||
/// Starts a listener on 127.0.0.1:0 with `state` and runs `f` against it under
|
||||
/// the budget, then shuts the listener down through the drain path.
|
||||
fn withServer(
|
||||
@@ -247,7 +260,7 @@ fn withServer(
|
||||
max_connections: u16,
|
||||
comptime f: anytype,
|
||||
extra: anytype,
|
||||
) !server.Stats {
|
||||
) !Counters {
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var web = try server.Server.listen(gpa, io, listen_address, state, .{ .max_connections = max_connections });
|
||||
const address = web.boundAddress();
|
||||
@@ -257,16 +270,16 @@ fn withServer(
|
||||
|
||||
const result = bounded(io, f, .{ io, address } ++ extra);
|
||||
|
||||
const stats: server.Stats = .{
|
||||
.accepted = .init(web.stats.accepted.load(.monotonic)),
|
||||
.rejected_at_capacity = .init(web.stats.rejected_at_capacity.load(.monotonic)),
|
||||
.rejected_at_shutdown = .init(web.stats.rejected_at_shutdown.load(.monotonic)),
|
||||
.accept_errors = .init(web.stats.accept_errors.load(.monotonic)),
|
||||
.connection_errors = .init(web.stats.connection_errors.load(.monotonic)),
|
||||
.requests = .init(web.stats.requests.load(.monotonic)),
|
||||
const stats: Counters = .{
|
||||
.connections = web.core.stats.connections.load(.monotonic),
|
||||
.rejected_at_capacity = web.core.stats.rejected_at_capacity.load(.monotonic),
|
||||
.rejected_at_shutdown = web.core.stats.rejected_at_shutdown.load(.monotonic),
|
||||
.accept_errors = web.core.stats.accept_errors.load(.monotonic),
|
||||
.connection_errors = web.core.stats.connection_errors.load(.monotonic),
|
||||
.requests = web.stats.requests.load(.monotonic),
|
||||
};
|
||||
|
||||
web.deinit(gpa, io);
|
||||
web.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
@@ -302,9 +315,9 @@ test "one connection carries two requests" {
|
||||
const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{});
|
||||
|
||||
// One accept for two requests is the whole point of keep-alive.
|
||||
try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 2), stats.requests.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), stats.connections);
|
||||
try testing.expectEqual(@as(u64, 2), stats.requests);
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
|
||||
}
|
||||
|
||||
fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
@@ -352,7 +365,7 @@ test "routing answers 404, 405 with allow, and rejects malformed targets" {
|
||||
|
||||
var state = testState(gpa);
|
||||
const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{});
|
||||
try testing.expectEqual(@as(u64, 5), stats.requests.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 5), stats.requests);
|
||||
}
|
||||
|
||||
fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void {
|
||||
@@ -448,7 +461,7 @@ test "a POST with no content-length and no transfer-encoding is an empty body, n
|
||||
|
||||
var state = testState(gpa);
|
||||
const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{});
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
|
||||
}
|
||||
|
||||
fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
@@ -509,8 +522,8 @@ test "a connection over the cap is told 503, not silently dropped" {
|
||||
|
||||
var state = testState(gpa);
|
||||
const stats = try withServer(gpa, io, &state, 1, refusedOverCapacity, .{});
|
||||
try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), stats.connections);
|
||||
try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity);
|
||||
}
|
||||
|
||||
fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
@@ -642,7 +655,7 @@ test "cancellation returns promptly with an idle keep-alive connection open" {
|
||||
const elapsed = start.durationTo(std.Io.Clock.awake.now(io));
|
||||
|
||||
client.cancel(io);
|
||||
web.deinit(gpa, io);
|
||||
web.deinit(io);
|
||||
|
||||
try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds());
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ const Env = struct {
|
||||
const gpa = self.gpa;
|
||||
const ioh = self.threaded.io();
|
||||
|
||||
self.web.deinit(gpa, ioh);
|
||||
self.web.deinit(ioh);
|
||||
self.group.await(ioh) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user