Skip to content

Commit 9618889

Browse files
committed
feat(http): outbound HTTP capability gateway with enforced deadlines
Zig 0.15's std.http.Client.fetch has no timeout and owns its socket, so a hung VeriSimDB/Steam/Groove endpoint blocked the calling thread forever and the STEAM_API_TIMEOUT_MS / GROOVE_TIMEOUT_MS constants were inert. Following the estate's http-capability-gateway direction ("HTTP verbs/routes become declared capabilities, not accidents" — one policy-driven mediation layer), this applies the same idea to GSA's OUTBOUND calls. New src/interface/ffi/src/http_capability.zig is the ONLY caller of fetch in the FFI layer; every call site declares an OutboundCapability (verb, host_allow, deadline_ms, purpose/narrative, label, trust — names mirror the gateway DSL) and call() enforces a host allow-list (default-deny) and a hard wall-clock deadline. Deadline mechanism (fetch cannot be cancelled): a worker thread runs the opaque fetch; the caller waits on std.Thread.Semaphore.timedWait(deadline). This bounds the caller uniformly for plain HTTP and TLS (Steam) alike, since it sits above fetch. Ownership is a 2-count refcount on a shared RequestBox backed by c_allocator (malloc — thread-safe, process-lifetime), so a worker that outlives a timed-out caller never touches a transient allocator; the last releaser frees, with no write-after-free and no leak. A bounded MAX_OUTSTANDING worker cap fails fast (Backpressure) rather than piling up blocked threads — the client-side echo of the gateway's circuit breaker. - verisimdb/steam/groove clients: all four fetch sites collapse to one call(); their http.Client fields are removed (the worker owns a fresh client). The three timeout constants (+ new VERISIMDB_TIMEOUT_MS) are now load-bearing. A deadline hit maps to probe_timeout; other failures keep their codes. No new GsaResult variant, so no Types.idr/ABI regen. - MockHTTP added to the harness (respond_ok / never_respond / slow_respond); its teardown closes held sockets so abandoned workers unblock and drain (http_capability.waitQuiescent) before the leak check. - 5 behavioral tests: hung endpoint returns within the deadline (not forever), happy-path body, slow-respond passes/fails by deadline, default-deny without a socket, host-allow matching. 167 tests green across six suites + 104 fuzz. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TaWWedv6VQqeZaPvc94keN
1 parent 309accc commit 9618889

7 files changed

Lines changed: 653 additions & 99 deletions

File tree

src/interface/ffi/src/groove_client.zig

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const std = @import("std");
2323
const http = std.http;
2424
const Allocator = std.mem.Allocator;
2525
const main = @import("main.zig");
26+
const http_capability = @import("http_capability.zig");
2627

2728
// ═══════════════════════════════════════════════════════════════════════════════
2829
// Constants
@@ -40,10 +41,8 @@ const GROOVE_TTS_PATH: []const u8 = "/.well-known/groove/tts";
4041
/// Maximum number of Groove targets we track simultaneously.
4142
const MAX_TARGETS: usize = 8;
4243

43-
/// Intended per-request deadline for Groove probes (milliseconds). NOT yet
44-
/// enforced — Zig 0.15.2's std.http.Client has no timeout knob, so a hung
45-
/// endpoint blocks the caller. Wiring it (raw-socket client or watchdog thread)
46-
/// is a tracked follow-up; kept as the contract to honour.
44+
/// Per-request deadline for Groove calls (ms) — enforced via the outbound HTTP
45+
/// capability gateway (http_capability.call).
4746
const GROOVE_TIMEOUT_MS: u32 = 3000;
4847

4948
// ═══════════════════════════════════════════════════════════════════════════════
@@ -177,30 +176,24 @@ fn grooveGet(
177176
) ![]const u8 {
178177
var url_buf: [512]u8 = undefined;
179178
const url_str = std.fmt.bufPrint(&url_buf, "http://{s}:{d}{s}", .{ host, port, path }) catch return error.URLTooLong;
180-
181-
var client = http.Client{ .allocator = allocator };
182-
defer client.deinit();
183-
184-
var alloc_writer = std.Io.Writer.Allocating.init(allocator);
185-
errdefer alloc_writer.deinit();
186-
187-
const result = try client.fetch(.{
188-
.location = .{ .url = url_str },
189-
.method = .GET,
179+
var auth_buf: [320]u8 = undefined;
180+
const authority = std.fmt.bufPrint(&auth_buf, "{s}:{d}", .{ host, port }) catch return error.URLTooLong;
181+
182+
const resp = try http_capability.call(allocator, .{
183+
.verb = .GET,
184+
.url = url_str,
185+
.host_allow = &.{authority},
186+
.deadline_ms = GROOVE_TIMEOUT_MS,
187+
.purpose = "groove voice/text discovery",
188+
.label = "groove",
189+
.trust = .internal,
190190
.extra_headers = &.{
191191
.{ .name = "Accept", .value = "application/json" },
192192
.{ .name = "User-Agent", .value = "GSA-Groove/0.1.0" },
193193
},
194-
.response_writer = &alloc_writer.writer,
194+
.accept = &.{.ok},
195195
});
196-
197-
if (result.status != .ok) {
198-
alloc_writer.deinit();
199-
return error.HTTPError;
200-
}
201-
202-
var list = alloc_writer.toArrayList();
203-
return list.toOwnedSlice(allocator);
196+
return resp.body;
204197
}
205198

206199
/// Perform an HTTP POST against a Groove endpoint with a JSON body.
@@ -214,32 +207,26 @@ fn groovePost(
214207
) ![]const u8 {
215208
var url_buf: [512]u8 = undefined;
216209
const url_str = std.fmt.bufPrint(&url_buf, "http://{s}:{d}{s}", .{ host, port, path }) catch return error.URLTooLong;
217-
218-
var client = http.Client{ .allocator = allocator };
219-
defer client.deinit();
220-
221-
var alloc_writer = std.Io.Writer.Allocating.init(allocator);
222-
errdefer alloc_writer.deinit();
223-
224-
const result = try client.fetch(.{
225-
.location = .{ .url = url_str },
226-
.method = .POST,
227-
.payload = body,
210+
var auth_buf: [320]u8 = undefined;
211+
const authority = std.fmt.bufPrint(&auth_buf, "{s}:{d}", .{ host, port }) catch return error.URLTooLong;
212+
213+
const resp = try http_capability.call(allocator, .{
214+
.verb = .POST,
215+
.url = url_str,
216+
.host_allow = &.{authority},
217+
.deadline_ms = GROOVE_TIMEOUT_MS,
218+
.purpose = "groove voice/text alert",
219+
.label = "groove",
220+
.trust = .internal,
221+
.body = body,
228222
.extra_headers = &.{
229223
.{ .name = "Content-Type", .value = "application/json" },
230224
.{ .name = "Accept", .value = "application/json" },
231225
.{ .name = "User-Agent", .value = "GSA-Groove/0.1.0" },
232226
},
233-
.response_writer = &alloc_writer.writer,
227+
.accept = &.{ .ok, .created, .accepted },
234228
});
235-
236-
if (result.status != .ok and result.status != .created and result.status != .accepted) {
237-
alloc_writer.deinit();
238-
return error.HTTPError;
239-
}
240-
241-
var list = alloc_writer.toArrayList();
242-
return list.toOwnedSlice(allocator);
229+
return resp.body;
243230
}
244231

245232
// ═══════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)