Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/adr/0043-reuse-warmed-tls-on-known-networks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 0043. Reuse warmed TLS state on the networks we already speak

Status: accepted 2026-08-29

## Context

The harness already talks to three networks: provider HTTP/SSE (and WS),
MCP Streamable HTTP, and the Codegraff gateway. Two patterns were wasting
a handshake on every first useful call:

1. MCP Auto's modern `tools/list` probe and the legacy
`notifications/initialized` notify each built a throwaway `std.http.Client`,
so a successful probe's keep-alive died before `tools/call`, and initialized
paid a second TLS for a fire-and-forget 202.
2. Every WSS dial (`ws.WsClient.connect`) rescanned the host CA store from
disk, even though launch already warms the HTTP client's bundle.

ADR 0002 (xAI WS full-resend), 0009/0011/0028 (prompt-cache keys), and 0035
(deferred MCP join) stay untouched: this is transport reuse, not wire shape.

## Decision

- MCP HTTP probe and `notifications/initialized` use `server.transport.http`.
Do not construct a per-call client for those paths.
- WSS TLS uses a process-lifetime CA bundle warmed once (`http_warm.ensureProcessCa`).
A reconnect must not walk the host store again.
- WS→SSE fallback keeps the Agent's prewarmed HTTP pool (`postStream`). Do not
introduce a fresh client on that latch: WS never used the HTTP pool, and a
new TLS would be strictly more expensive.
- MCP Streamable HTTP advertises the std client's default Accept-Encoding
(gzip/deflate) and decompresses Content-Encoding. Do not omit it: a tools/list
catalog is the fat payload on that network, and provider POST already accepts
compression.

## Consequences

A modern MCP connect plus the next list is one TCP accept on keep-alive.
WSS reconnects skip the CA disk walk. MCP catalogs can travel gzip-compressed.
Revisit only if a shared `std.http.Client` is shown unsafe for the concurrent
initialized+list pair, a host CA rotation must be picked up mid-process without
restart, or a server is broken by Accept-Encoding.

## Measured (2026-08-30, this host)

| Path | Before | After | Left on the table |
|---|---|---|---|
| MCP modern connect + next `tools/list` | 2 TCP accepts (throwaway probe client, then the persistent one) | 1 accept, 2 POSTs | nothing — keep-alive is the rest |
| WSS CA disk walk | 1 `rescan` per connect, including every reconnect | 1 per process | launch still walks once for the HTTP client (~5–7 ms, 144 certs / 154 KB here). Cloning that bundle into the process one saves one launch scan and risks a double-free; not worth it |
| MCP `tools/list` catalog (40-tool fixture) | 6110 B raw (`Accept-Encoding` omitted) | 320 B gzip (5% of plaintext) | only if the server ignores gzip — then we still send the header and read identity |
| WS→SSE latch | prewarmed Agent HTTP pool | unchanged | a fresh client would add a TLS handshake |
| xAI / Codex WS turn body | full history | unchanged | ADR 0002: no `previous_response_id` chain |
| MCP OAuth token / login 401 probe | throwaway `std.http.Client`; login probe omits encoding | unchanged | not on the turn path |
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ record only when you need the evidence or the edge cases.
| [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. |
| [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. |
| [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. |
| [0043](0043-reuse-warmed-tls-on-known-networks.md) | Reuse warmed TLS: MCP probe/initialized stay on the persistent HTTP client; WSS CA is scanned once per process; WS→SSE keeps the prewarmed pool; MCP HTTP accepts gzip. |

## When to write one

Expand Down
8 changes: 4 additions & 4 deletions src/agent_stream.zig
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ pub fn postStream(self: *Agent, body: []const u8) ![]u8 {
return postStreamWithClient(self, self.client, body);
}

/// SSE stream using an explicit HTTP client. Normal traffic uses the Agent's
/// shared pool; a WebSocket failure supplies a fresh client so a stale pooled
/// keep-alive cannot poison the WS→SSE handoff and every fallback retry dials
/// from a clean pool.
/// SSE stream using an explicit HTTP client. Normal traffic and the WS→SSE
/// latch both use the Agent's prewarmed pool (agent_ws.postLive); a test can
/// pass another client. A failed SEND still poisons that connection so the
/// next retry dials fresh instead of replaying a dead keep-alive.
pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []const u8) ![]u8 {
const sink = engine_sink.forAgent(self);
sink.emit(self.io, .stream_begin);
Expand Down
36 changes: 34 additions & 2 deletions src/http_warm.zig
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
//! Shared HTTP client CA-bundle warming.
//! Shared CA-bundle warming for HTTP and WSS.

const std = @import("std");
const Io = std.Io;

/// Process-lifetime bundle. Always `page_allocator` so a test GPA cannot
/// free it while a later WSS dial still holds the pointer.
var process_ca: std.crypto.Certificate.Bundle = .empty;
var process_ca_rw: Io.RwLock = .init;
var process_ca_init: Io.Mutex = .init;
var process_ca_ready = std.atomic.Value(bool).init(false);

/// Test seam: how many times the process bundle actually hit the disk.
pub var process_ca_rescans: u32 = 0;

pub fn processCa() *std.crypto.Certificate.Bundle {
return &process_ca;
}

pub fn processCaLock() *Io.RwLock {
return &process_ca_rw;
}

/// Scan the host CA store once. Later WSS reconnects reuse the same bytes.
pub fn ensureProcessCa(io: Io) !void {
if (process_ca_ready.load(.acquire)) return;
process_ca_init.lockUncancelable(io);
defer process_ca_init.unlock(io);
if (process_ca_ready.load(.acquire)) return;
const now = Io.Clock.real.now(io);
process_ca.rescan(std.heap.page_allocator, io, now) catch return error.HandshakeFailed;
process_ca_rescans += 1;
process_ca_ready.store(true, .release);
}

/// Pre-load the shared HTTP client's CA bundle single-threaded so concurrent
/// agents never race Zig's lazy first-connect rescan.
/// agents never race Zig's lazy first-connect rescan. Also warms the process
/// bundle so the first WSS dial does not pay a second disk walk.
pub fn prewarmCaBundle(client: *std.http.Client, gpa: std.mem.Allocator, io: Io) void {
const now = Io.Clock.real.now(io);
client.ca_bundle.rescan(gpa, io, now) catch return;
client.now = now;
ensureProcessCa(io) catch return;
}

/// Warm off the launch critical path. Outbound users wait on
Expand Down
59 changes: 27 additions & 32 deletions src/mcp_http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ fn httpPostUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta,
.redirect_behavior = .unhandled,
.headers = .{
.content_type = .{ .override = "application/json" },
.accept_encoding = .omit,
.user_agent = .{ .override = "codegraff-mcp/1" },
},
.extra_headers = extra,
Expand Down Expand Up @@ -224,28 +223,7 @@ fn httpPostUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta,
}
}

if (response.head.content_length == 0) return null;
const is_sse = if (response.head.content_type) |content_type|
std.ascii.startsWithIgnoreCase(content_type, "text/event-stream")
else
false;
var transfer_buf: [4096]u8 = undefined;
const reader = response.reader(&transfer_buf);
if (is_sse) return readSseResponse(http.client.allocator, reader, expected_id);

const response_buf = try http.client.allocator.alloc(u8, max_http_response);
errdefer http.client.allocator.free(response_buf);
var fixed = Io.Writer.fixed(response_buf);
_ = reader.streamRemaining(&fixed) catch |err| switch (err) {
error.WriteFailed => return error.McpResponseTooLarge,
else => return err,
};
const len = fixed.buffered().len;
if (len == 0) {
http.client.allocator.free(response_buf);
return null;
}
return try http.client.allocator.realloc(response_buf, len);
return readResponseBody(http.client.allocator, &response, expected_id);
}

const HttpPostDone = union(enum) {
Expand Down Expand Up @@ -325,7 +303,6 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr
.redirect_behavior = .unhandled,
.headers = .{
.content_type = .{ .override = "application/json" },
.accept_encoding = .omit,
.user_agent = .{ .override = "codegraff-mcp/1" },
},
.extra_headers = extra,
Expand Down Expand Up @@ -355,28 +332,46 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr
if (req.connection) |connection| connection.closing = true;
}

if (response.head.content_length == 0) return .{ .status = status, .body = null };
return .{ .status = status, .body = try readResponseBody(http.client.allocator, &response, null) };
}

fn decompressWindow(gpa: Allocator, encoding: std.http.ContentEncoding) ![]u8 {
return switch (encoding) {
.identity => &.{},
.zstd => gpa.alloc(u8, std.compress.zstd.default_window_len),
.deflate, .gzip => gpa.alloc(u8, std.compress.flate.max_window_len),
.compress => error.UnsupportedCompressionMethod,
};
}

/// Decode the HTTP body, honoring Content-Encoding (gzip/deflate/zstd).
/// Callers used to omit Accept-Encoding so catalogs crossed the wire raw.
fn readResponseBody(gpa: Allocator, response: *std.http.Client.Response, expected_id: ?i64) !?[]u8 {
if (response.head.content_length == 0) return null;
const is_sse = if (response.head.content_type) |content_type|
std.ascii.startsWithIgnoreCase(content_type, "text/event-stream")
else
false;
const window = try decompressWindow(gpa, response.head.content_encoding);
defer if (window.len > 0) gpa.free(window);
var transfer_buf: [4096]u8 = undefined;
const reader = response.reader(&transfer_buf);
if (is_sse) return .{ .status = status, .body = try readSseResponse(http.client.allocator, reader, null) };
var decompress: std.http.Decompress = undefined;
const reader = response.readerDecompressing(&transfer_buf, &decompress, window);
if (is_sse) return readSseResponse(gpa, reader, expected_id);

const response_buf = try http.client.allocator.alloc(u8, max_http_response);
errdefer http.client.allocator.free(response_buf);
const response_buf = try gpa.alloc(u8, max_http_response);
errdefer gpa.free(response_buf);
var fixed = Io.Writer.fixed(response_buf);
_ = reader.streamRemaining(&fixed) catch |err| switch (err) {
error.WriteFailed => return error.McpResponseTooLarge,
else => return err,
};
const len = fixed.buffered().len;
if (len == 0) {
http.client.allocator.free(response_buf);
return .{ .status = status, .body = null };
gpa.free(response_buf);
return null;
}
return .{ .status = status, .body = try http.client.allocator.realloc(response_buf, len) };
return try gpa.realloc(response_buf, len);
}

const ProbeDone = union(enum) {
Expand Down
32 changes: 12 additions & 20 deletions src/mcp_lifecycle.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//! surfaced rather than hidden behind fallback, matching rust-sdk Auto.

const std = @import("std");
const Io = std.Io;
const Value = std.json.Value;
const Allocator = std.mem.Allocator;

Expand Down Expand Up @@ -59,27 +58,12 @@ const ProbeOut = struct {
id: i64,
};

fn probeMethod(
io: Io,
gpa: Allocator,
url: []const u8,
headers: []const std.http.Header,
oauth_home: ?[]const u8,
method: []const u8,
id: i64,
) ProbeOut {
var arena_state = std.heap.ArenaAllocator.init(gpa);
fn probeMethod(http: *mcp_http.HttpTransport, method: []const u8, id: i64) ProbeOut {
var arena_state = std.heap.ArenaAllocator.init(http.client.allocator);
defer arena_state.deinit();
var transport: mcp_http.HttpTransport = .{
.url = url,
.client = .{ .allocator = gpa, .io = io },
.headers = headers,
.oauth_home = oauth_home,
};
defer transport.client.deinit();
const body = mcp_protocol.buildRequest(arena_state.allocator(), id, method, "{}", true) catch
return .{ .reply = .{ .status = 0, .body = null }, .id = id };
const reply = mcp_http.probe(&transport, body, .{
const reply = mcp_http.probe(http, body, .{
.protocol_version = modern_protocol,
.method = method,
.modern = true,
Expand Down Expand Up @@ -126,7 +110,7 @@ fn connectHttpAttempt(server: *mcp_rpc.Server, a: Allocator, session_alloc: Allo

const id_list = server.next_id;
server.next_id += 1;
const list = probeMethod(io, gpa, http.url, http.headers, http.oauth_home, "tools/list", id_list);
const list = probeMethod(http, "tools/list", id_list);
defer if (list.reply.body) |b| gpa.free(b);

// Any modern request may be first. A real tools/list result is the catalog
Expand Down Expand Up @@ -232,6 +216,14 @@ test "Auto: first launch tries modern tools/list before legacy fallback" {
try std.testing.expect(list_pos < fallback_pos);
}

test "Auto: modern probe reuses the persistent HTTP client" {
const src = @embedFile("mcp_lifecycle.zig");
try std.testing.expect(std.mem.indexOf(u8, src, "fn probeMethod(http: *mcp_http.HttpTransport") != null);
try std.testing.expect(std.mem.indexOf(u8, src, "const list = probeMethod(http,") != null);
const throwaway = "var transport: " ++ "mcp_http.HttpTransport";
try std.testing.expect(std.mem.indexOf(u8, src, throwaway) == null);
}

test {
_ = @import("mcp_cache.zig");
}
34 changes: 6 additions & 28 deletions src/mcp_rpc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -236,43 +236,21 @@ pub fn connectLegacy(server: *Server, a: Allocator, session_alloc: Allocator, bo
return listed;
}

const InitializedJob = struct {
url: []const u8,
headers: []const std.http.Header,
oauth_home: ?[]const u8,
protocol_version: []const u8,
gpa: Allocator,
};

fn httpInitializedTask(io: Io, job: InitializedJob) void {
var transport: mcp_http.HttpTransport = .{
.url = job.url,
.client = .{ .allocator = job.gpa, .io = io },
.headers = job.headers,
.oauth_home = job.oauth_home,
};
defer transport.client.deinit();
fn httpInitializedTask(http: *mcp_http.HttpTransport, protocol_version: []const u8) void {
const body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{}}";
if (mcp_http.post(&transport, body, .{
.protocol_version = job.protocol_version,
if (mcp_http.post(http, body, .{
.protocol_version = protocol_version,
.method = "notifications/initialized",
.modern = false,
}, null)) |maybe| {
if (maybe) |b| job.gpa.free(b);
if (maybe) |b| http.client.allocator.free(b);
} else |_| {}
}

fn kickHttpInitialized(server: *Server) void {
const http = &server.transport.http;
const job = InitializedJob{
.url = http.url,
.headers = http.headers,
.oauth_home = http.oauth_home,
.protocol_version = server.protocol_version,
.gpa = http.client.allocator,
};
server.pending_initialized = http.client.io.concurrent(httpInitializedTask, .{ http.client.io, job }) catch
http.client.io.async(httpInitializedTask, .{ http.client.io, job });
server.pending_initialized = http.client.io.concurrent(httpInitializedTask, .{ http, server.protocol_version }) catch
http.client.io.async(httpInitializedTask, .{ http, server.protocol_version });
}

pub fn finishInitialized(server: *Server) void {
Expand Down
Loading