Skip to content

Commit 9a2edcb

Browse files
hyperpolymathclaude
andcommitted
zig-api: add uapi_gnosis_set_handler hook for edge-plugged routing
Adds a function-pointer hook to the gnosis pool so edge repos can plug in their own path-routing handler instead of gnosis's built-in /render /context /health routes. The canonical single-port + set_handler pattern makes multi- port-per-edge unnecessary. - GnosisRequest / GnosisResponse C-ABI extern structs in gnosis.zig - GnosisHandlerFn type alias for the handler function pointer - GnosisServer.handler field (null = use built-in routes) - serveRequestViaHandler: parses HTTP, builds Request, calls hook, flushes Response - uapi_gnosis_set_handler: one-time pre-start registration; returns UAPI_ERR if called after start - uapi_gnosis_write_response: convenience helper for edge handlers - Four unit tests covering: invalid handle, idle-server acceptance, null revert, write_response fields - Foreign.idr: prim__gnosisSetHandler declaration - gen-header.sh: emit GnosisRequest/Response typedefs and new function declarations - zig_api.h regenerated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 26b6b8c commit 9a2edcb

5 files changed

Lines changed: 404 additions & 21 deletions

File tree

zig-api/ffi/zig/src/gnosis.zig

Lines changed: 296 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ const GnosisServer = struct {
6565
stop_flag: std.atomic.Value(bool),
6666
/// gnosis binary path (null-terminated, heap-owned by pool allocator).
6767
gnosis_bin: [:0]const u8,
68+
/// Optional edge handler hook. When non-null, `uapi_gnosis_start`'s serve
69+
/// loop calls this instead of the built-in gnosis route handlers.
70+
/// Set once before `uapi_gnosis_start`; never changed after start.
71+
handler: ?GnosisHandlerFn,
6872

6973
/// Return a zeroed-out default slot.
7074
fn empty() GnosisServer {
@@ -75,10 +79,63 @@ const GnosisServer = struct {
7579
.thread = null,
7680
.stop_flag = std.atomic.Value(bool).init(false),
7781
.gnosis_bin = "",
82+
.handler = null,
7883
};
7984
}
8085
};
8186

87+
// =============================================================================
88+
// Handler hook types
89+
// =============================================================================
90+
91+
/// C-ABI-stable request context passed to edge handler functions.
92+
///
93+
/// `method` and `path` are null-terminated C strings pointing into the
94+
/// per-connection buffer — valid only for the duration of the handler call.
95+
/// `body_ptr` / `body_len` describe the request body (empty slice for GET).
96+
/// `conn` is an opaque pointer to the underlying `std.net.Server.Connection`;
97+
/// cast to `*std.net.Server.Connection` inside the handler if needed.
98+
/// Prefer the provided helper `uapi_gnosis_write_response` instead of
99+
/// reaching into the connection directly.
100+
pub const GnosisRequest = extern struct {
101+
/// HTTP method string, e.g. "GET", "POST" (null-terminated).
102+
method: [*:0]const u8,
103+
/// Request path, e.g. "/api/v1/render" (null-terminated, query-stripped).
104+
path: [*:0]const u8,
105+
/// Request body bytes. Null pointer when body is empty.
106+
body_ptr: ?[*]const u8,
107+
/// Byte length of `body_ptr` (0 when body is empty).
108+
body_len: u32,
109+
};
110+
111+
/// C-ABI response written by an edge handler.
112+
///
113+
/// The handler fills in all four fields; `uapi_gnosis_start`'s serve loop
114+
/// flushes them to the TCP stream.
115+
/// `body_ptr` must remain valid until `uapi_gnosis_write_response` returns
116+
/// (stack buffers inside the handler are fine).
117+
pub const GnosisResponse = extern struct {
118+
/// HTTP numeric status code, e.g. 200, 404.
119+
status: u16,
120+
_pad: u16,
121+
/// Null-terminated MIME type string, e.g. "application/json".
122+
content_type: [*:0]const u8,
123+
/// Response body bytes. Null pointer for zero-length body.
124+
body_ptr: ?[*]const u8,
125+
/// Byte length of `body_ptr`.
126+
body_len: u32,
127+
};
128+
129+
/// C-ABI function pointer type for edge handler hooks.
130+
///
131+
/// The handler receives a parsed request and an output `GnosisResponse` it
132+
/// must fill before returning. Both pointers are valid for the entire call.
133+
/// The handler MUST NOT store either pointer beyond the call.
134+
pub const GnosisHandlerFn = *const fn (
135+
req: *const GnosisRequest,
136+
resp: *GnosisResponse,
137+
) callconv(.c) void;
138+
82139
// =============================================================================
83140
// Global pool + pool allocator
84141
// =============================================================================
@@ -418,6 +475,8 @@ fn readLine(stream: std.net.Stream, buf: []u8) ![]const u8 {
418475
const ServeThreadArgs = struct {
419476
idx: usize,
420477
gnosis_bin: []const u8,
478+
/// Optional edge handler — null means use built-in gnosis routes.
479+
handler: ?GnosisHandlerFn,
421480
};
422481

423482
fn serveThread(args: ServeThreadArgs) void {
@@ -459,7 +518,13 @@ fn serveThread(args: ServeThreadArgs) void {
459518
// Reset the arena between connections.
460519
_ = arena.reset(.retain_capacity);
461520

462-
serveRequest(&conn, ctx);
521+
if (args.handler) |handler_fn| {
522+
// Edge handler hook: dispatch to the edge's path-routing function
523+
// instead of the built-in gnosis routes.
524+
serveRequestViaHandler(&conn, handler_fn, ctx.allocator);
525+
} else {
526+
serveRequest(&conn, ctx);
527+
}
463528
}
464529

465530
pool_mutex.lock();
@@ -490,6 +555,120 @@ fn stopServerSlot(idx: usize) void {
490555
pool[idx].state = .stopped;
491556
}
492557

558+
// =============================================================================
559+
// Edge handler dispatch
560+
// =============================================================================
561+
562+
/// Parse the HTTP/1.1 request from `conn`, build a `GnosisRequest`, invoke
563+
/// `handler_fn`, then flush the `GnosisResponse` back to the stream.
564+
///
565+
/// Called from `serveThread` when the pool slot has a registered handler.
566+
/// All allocations go through `allocator`; freed when the arena resets between
567+
/// connections.
568+
fn serveRequestViaHandler(
569+
conn: *std.net.Server.Connection,
570+
handler_fn: GnosisHandlerFn,
571+
allocator: std.mem.Allocator,
572+
) void {
573+
// --- Parse request line ---
574+
var request_line_buf: [1024]u8 = undefined;
575+
const request_line = readLine(conn.stream, &request_line_buf) catch {
576+
writeBadRequest(conn, "malformed request line");
577+
return;
578+
};
579+
580+
var parts = std.mem.splitScalar(u8, request_line, ' ');
581+
const method_str = parts.next() orelse {
582+
writeBadRequest(conn, "missing method");
583+
return;
584+
};
585+
const raw_path = parts.next() orelse {
586+
writeBadRequest(conn, "missing path");
587+
return;
588+
};
589+
590+
// Strip query string from path.
591+
const path_str = if (std.mem.indexOfScalar(u8, raw_path, '?')) |qi|
592+
raw_path[0..qi]
593+
else
594+
raw_path;
595+
596+
// --- Drain headers, extract Content-Length ---
597+
var content_length: usize = 0;
598+
var header_buf: [256]u8 = undefined;
599+
while (true) {
600+
const line = readLine(conn.stream, &header_buf) catch break;
601+
if (line.len == 0) break;
602+
if (std.ascii.startsWithIgnoreCase(line, "content-length:")) {
603+
const val = std.mem.trimLeft(u8, line["content-length:".len..], " \t");
604+
content_length = std.fmt.parseInt(usize, val, 10) catch 0;
605+
}
606+
}
607+
608+
// --- Read body ---
609+
var body_owned: ?[]u8 = null;
610+
defer if (body_owned) |b| allocator.free(b);
611+
if (content_length > 0) {
612+
body_owned = readBody(allocator, conn.stream, content_length) catch {
613+
writeInternalError(conn, "failed to read request body");
614+
return;
615+
};
616+
}
617+
const body_bytes: ?[]u8 = body_owned;
618+
619+
// --- Null-terminate method and path for the C-ABI structs ---
620+
// Allocate sentinel-terminated copies so the handler sees valid C strings.
621+
const method_z = allocator.dupeZ(u8, method_str) catch {
622+
writeInternalError(conn, "oom");
623+
return;
624+
};
625+
const path_z = allocator.dupeZ(u8, path_str) catch {
626+
writeInternalError(conn, "oom");
627+
return;
628+
};
629+
630+
// --- Build GnosisRequest ---
631+
const req = GnosisRequest{
632+
.method = method_z,
633+
.path = path_z,
634+
.body_ptr = if (body_bytes) |b| b.ptr else null,
635+
.body_len = @intCast(if (body_bytes) |b| b.len else 0),
636+
};
637+
638+
// --- Invoke handler ---
639+
var resp = GnosisResponse{
640+
.status = 200,
641+
._pad = 0,
642+
.content_type = "application/json",
643+
.body_ptr = null,
644+
.body_len = 0,
645+
};
646+
handler_fn(&req, &resp);
647+
648+
// --- Flush response ---
649+
const body_out: []const u8 = if (resp.body_ptr) |p| p[0..resp.body_len] else "";
650+
const ct_out: []const u8 = std.mem.span(resp.content_type);
651+
writeResponse(conn, resp.status, ct_out, body_out);
652+
}
653+
654+
/// Helper exported for edge handlers: write a response body into a
655+
/// `GnosisResponse` from a Zig slice. Since the edge handler stack-allocates
656+
/// a response buffer, this is a convenience shim — the handler can also fill
657+
/// `resp.*` directly.
658+
pub export fn uapi_gnosis_write_response(
659+
resp: *GnosisResponse,
660+
status: u16,
661+
content_type: [*:0]const u8,
662+
body_ptr: ?[*]const u8,
663+
body_len: u32,
664+
) callconv(.c) void {
665+
resp.status = status;
666+
resp._pad = 0;
667+
resp.content_type = content_type;
668+
resp.body_ptr = body_ptr;
669+
resp.body_len = body_len;
670+
}
671+
493672
// =============================================================================
494673
// Exported C ABI (uapi_gnosis_*)
495674
// =============================================================================
@@ -544,7 +723,11 @@ pub export fn uapi_gnosis_start(handle: u64) callconv(.c) u8 {
544723
pool[idx].thread = std.Thread.spawn(
545724
.{},
546725
serveThread,
547-
.{ServeThreadArgs{ .idx = idx, .gnosis_bin = pool[idx].gnosis_bin }},
726+
.{ServeThreadArgs{
727+
.idx = idx,
728+
.gnosis_bin = pool[idx].gnosis_bin,
729+
.handler = pool[idx].handler,
730+
}},
548731
) catch |err| {
549732
core.setError("gnosis: thread spawn failed: {}", .{err});
550733
return core.Result.process_failed.toU8();
@@ -585,6 +768,43 @@ pub export fn uapi_gnosis_state(handle: u64) callconv(.c) u8 {
585768
return @intFromEnum(pool[idx].state);
586769
}
587770

771+
/// Register an edge handler hook for the server identified by `handle`.
772+
///
773+
/// Semantics:
774+
/// - MUST be called after `uapi_gnosis_create` and BEFORE `uapi_gnosis_start`.
775+
/// - Calling after `uapi_gnosis_start` returns `UAPI_ERR` (no hot-swap).
776+
/// - Once set, `uapi_gnosis_start`'s accept loop calls `handler_fn` for every
777+
/// request instead of the built-in gnosis routes.
778+
/// - The built-in routes are still compiled in; pass a null handler (0) to
779+
/// revert to them.
780+
///
781+
/// Returns UAPI_OK (0) on success, UAPI_ERR (1) on failure.
782+
pub export fn uapi_gnosis_set_handler(
783+
handle: u64,
784+
handler_fn: ?GnosisHandlerFn,
785+
) callconv(.c) u8 {
786+
const idx = idxFromHandle(handle) orelse {
787+
core.setError("gnosis: set_handler: invalid handle {d}", .{handle});
788+
return core.Result.invalid_param.toU8();
789+
};
790+
791+
pool_mutex.lock();
792+
defer pool_mutex.unlock();
793+
794+
if (!pool[idx].active) {
795+
core.setError("gnosis: set_handler: handle {d} is not active", .{handle});
796+
return core.Result.invalid_param.toU8();
797+
}
798+
// Refuse to hot-swap after the server has started.
799+
if (pool[idx].state == .listening or pool[idx].state == .draining) {
800+
core.setError("gnosis: set_handler: server already started — cannot change handler", .{});
801+
return core.Result.err.toU8();
802+
}
803+
804+
pool[idx].handler = handler_fn;
805+
return core.Result.ok.toU8();
806+
}
807+
588808
/// Synchronous health probe.
589809
/// Returns 0 (serving) if the gnosis binary responds, 1 (not_serving) otherwise.
590810
pub export fn uapi_gnosis_health(handle: u64) callconv(.c) u8 {
@@ -641,3 +861,77 @@ test "handle encoding roundtrip" {
641861
// handle > MAX_SERVERS is invalid
642862
try std.testing.expectEqual(@as(?usize, null), idxFromHandle(MAX_SERVERS + 1));
643863
}
864+
865+
// ---------------------------------------------------------------------------
866+
// Handler hook tests
867+
// ---------------------------------------------------------------------------
868+
869+
/// A no-op edge handler: writes a fixed 200 JSON response.
870+
/// Used by the tests below to verify that the hook dispatch path is reachable.
871+
fn testHandler(req: *const GnosisRequest, resp: *GnosisResponse) callconv(.c) void {
872+
// Suppress unused-parameter warning.
873+
_ = req;
874+
resp.status = 200;
875+
resp._pad = 0;
876+
resp.content_type = "application/json";
877+
// Point at a static string — valid for the lifetime of the call.
878+
const body: [*:0]const u8 = "{\"handler\":\"test\"}";
879+
resp.body_ptr = body;
880+
resp.body_len = 18; // length of the string above
881+
}
882+
883+
test "set_handler rejects invalid handle" {
884+
init();
885+
defer teardown();
886+
const rc = uapi_gnosis_set_handler(0, &testHandler);
887+
try std.testing.expectEqual(@as(u8, core.Result.invalid_param.toU8()), rc);
888+
}
889+
890+
test "set_handler accepts idle server and records handler" {
891+
init();
892+
defer teardown();
893+
894+
const handle = uapi_gnosis_create(19876);
895+
try std.testing.expect(handle != 0);
896+
defer uapi_gnosis_destroy(handle);
897+
898+
const rc = uapi_gnosis_set_handler(handle, &testHandler);
899+
try std.testing.expectEqual(@as(u8, core.Result.ok.toU8()), rc);
900+
901+
// Verify the handler was stored.
902+
const idx = idxFromHandle(handle) orelse return error.BadHandle;
903+
try std.testing.expect(pool[idx].handler != null);
904+
}
905+
906+
test "set_handler rejects null handler (revert to built-in)" {
907+
init();
908+
defer teardown();
909+
910+
const handle = uapi_gnosis_create(19877);
911+
try std.testing.expect(handle != 0);
912+
defer uapi_gnosis_destroy(handle);
913+
914+
// Set a real handler, then clear it.
915+
_ = uapi_gnosis_set_handler(handle, &testHandler);
916+
const rc = uapi_gnosis_set_handler(handle, null);
917+
try std.testing.expectEqual(@as(u8, core.Result.ok.toU8()), rc);
918+
919+
const idx = idxFromHandle(handle) orelse return error.BadHandle;
920+
try std.testing.expect(pool[idx].handler == null);
921+
}
922+
923+
test "write_response fills GnosisResponse fields" {
924+
const body = "hello";
925+
const ct: [*:0]const u8 = "text/plain";
926+
var resp = GnosisResponse{
927+
.status = 0,
928+
._pad = 0,
929+
.content_type = "application/json",
930+
.body_ptr = null,
931+
.body_len = 0,
932+
};
933+
uapi_gnosis_write_response(&resp, 201, ct, body, 5);
934+
try std.testing.expectEqual(@as(u16, 201), resp.status);
935+
try std.testing.expectEqual(@as(u32, 5), resp.body_len);
936+
try std.testing.expectEqual(@as(?[*]const u8, body), resp.body_ptr);
937+
}

zig-api/ffi/zig/src/lib.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ comptime {
2929
_ = gnosis.uapi_gnosis_destroy;
3030
_ = gnosis.uapi_gnosis_state;
3131
_ = gnosis.uapi_gnosis_health;
32+
_ = gnosis.uapi_gnosis_set_handler;
33+
_ = gnosis.uapi_gnosis_write_response;
3234
// connector pool
3335
_ = connector.uapi_connector_create;
3436
_ = connector.uapi_connector_health;

0 commit comments

Comments
 (0)