lib/http/src/client/storage.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const model = @import("response.zig");
4
5 const Header = model.Header;
6 const Scratch = model.Scratch;
7
8 pub const StorageExhaustion = error{ClientResponseCapacityExceeded};
9
10 /// Storage provides reusable response slots cut from memory sized by caller
11 /// limits. Sizing with zero storage bytes skips allocation entirely, while
12 /// non-zero requirements allocate a single contiguous block up front. That
13 /// allocation is partitioned into three sequential regions: all header entries
14 /// for all slots, followed by all head bytes, and finally all body bytes. Each
15 /// slot draws its corresponding slices from each of these three regions.
16 ///
17 /// Calling `activate` ends the initialization phase, after which `response`
18 /// hands out a slot's scratch slices. Handing out a slot invokes no allocator.
19 /// Specifying an index at or beyond the configured slot count returns
20 /// `error.ClientResponseCapacityExceeded`.
21 ///
22 /// A response parsed into a slot borrows that slot directly. The backing
23 /// storage must remain alive and unchanged while the response is in use, with
24 /// slot reuse for another parse and calling `deinit` serving as explicit
25 /// invalidation points.
26 pub const Storage = struct {
27 phase: alloc_phase.capacity.Phase,
28 capacity: model.Capacity,
29 bytes: []align(@alignOf(Header)) u8,
30
31 pub const Limits: type = model.Limits;
32 pub const Capacity: type = model.Capacity;
33 pub const Exhaustion: type = StorageExhaustion;
34 pub const InitError = std.mem.Allocator.Error || error{CapacityOverflow};
35
36 pub const claim: alloc_phase.capacity.Declaration = .{
37 .source = .{
38 .id = "http.client_response_storage",
39 .kind = .phase_static,
40 .limit_source = .caller,
41 .storage = .{
42 .covered = &.{
43 .{
44 .id = "fixed_parsed_client_response_header_entries_for_eve_451da7e2df60",
45 .lifetime = .steady,
46 .detail = "fixed parsed client-response header entries for every response slot",
47 },
48 .{
49 .id = "fixed_response_head_bytes_retaining_borrowed_header_27c2e730fc7e",
50 .lifetime = .steady,
51 .detail = "fixed response-head bytes retaining borrowed header names and values",
52 },
53 .{
54 .id = "fixed_buffered_or_decoded_response_body_bytes",
55 .lifetime = .steady,
56 .detail = "fixed buffered or decoded response-body bytes",
57 },
58 },
59 .excluded = &.{
60 "request serialization, URL targets, TLS state, certificate bundles, sockets, and kernel queues",
61 "streaming response header accumulation, chunk fragmentation, and handler-owned output",
62 },
63 },
64 .capacity = .{
65 .inputs = &.{
66 alloc_phase.capacity.bindInput(Limits, "response_count", "response_count"),
67 alloc_phase.capacity.bindInput(Limits, "header_count_per_response", "header_count_per_response"),
68 alloc_phase.capacity.bindInput(Limits, "head_bytes_per_response", "head_bytes_per_response"),
69 alloc_phase.capacity.bindInput(Limits, "body_bytes_per_response", "body_bytes_per_response"),
70 },
71 .type_selectors = &.{
72 alloc_phase.capacity.bindType(Header, "header"),
73 },
74 .nodes = &.{
75 .{ .input = 0 },
76 .{ .input = 1 },
77 .{ .product = .{ .left = 0, .right = 1 } },
78 .{ .constant = 1 },
79 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 0 } } },
80 .{ .product = .{ .left = 2, .right = 4 } },
81 .{ .input = 2 },
82 .{ .product = .{ .left = 0, .right = 6 } },
83 .{ .input = 3 },
84 .{ .product = .{ .left = 0, .right = 8 } },
85 .{ .add = .{ .left = 5, .right = 7 } },
86 .{ .add = .{ .left = 10, .right = 9 } },
87 },
88 .assertions = &.{.{
89 .scope = .closure_total,
90 .measure = .retained,
91 .relation = .exact,
92 .expression = 11,
93 }},
94 },
95 .overload = .{
96 .kind = .terminal,
97 .detail = "direct parse rejects atomically; a network response exceeding its slot terminates that one-shot connection without publishing a response",
98 },
99 .risks = .{
100 .transitive = .{
101 .status = .witnessed,
102 .detail = "response parsing and chunk decoding allocate no storage after activation",
103 },
104 .foreign = .{
105 .status = .excluded,
106 .detail = "network reads may discover remote overflow after bounded scratch mutation but never publish an over-capacity response",
107 },
108 },
109 .obligations = &.{
110 .{ .key = "http_client_response_capacity", .role = .capacity_model },
111 .{ .key = "http_client_response_oom_retry", .role = .custom },
112 .{ .key = "http_client_response_partition", .role = .custom },
113 .{ .key = "http_client_response_sealed", .role = .transitive_risk },
114 .{ .key = "http_client_response_atomic", .role = .overload },
115 .{ .key = "http_client_response_boundary", .role = .custom },
116 .{ .key = "http_client_response_network_overload", .role = .overload },
117 .{ .key = "http_client_response_network_foreign_risk", .role = .foreign_risk },
118 },
119 },
120 .bindings = .{
121 .owner = @This(),
122 .seal = .{
123 .family = alloc_phase.capacity.selector(@This().activate),
124 .premise = .{
125 .class = .checked_semantic_fact,
126 .authority = .checker,
127 },
128 },
129 .teardown = .{
130 .family = alloc_phase.capacity.selector(@This().deinit),
131 .premise = .{
132 .class = .checked_semantic_fact,
133 .authority = .checker,
134 },
135 },
136 },
137 };
138
139 pub fn init(allocator: std.mem.Allocator, limits: Limits) InitError!Storage {
140 const capacity = try Capacity.derive(limits);
141 const bytes = if (capacity.storage_bytes == 0)
142 @as([]align(@alignOf(Header)) u8, &.{})
143 else
144 try allocator.alignedAlloc(u8, .of(Header), capacity.storage_bytes);
145 return .{
146 .phase = .initialization,
147 .capacity = capacity,
148 .bytes = bytes,
149 };
150 }
151
152 pub fn activate(self: *Storage) void {
153 std.debug.assert(self.phase == .initialization);
154 std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
155 self.phase = .steady;
156 }
157
158 pub fn response(self: *Storage, index: usize) Exhaustion!Scratch {
159 std.debug.assert(self.phase == .steady);
160 if (index >= self.capacity.response_count) {
161 return error.ClientResponseCapacityExceeded;
162 }
163 const all_headers = std.mem.bytesAsSlice(
164 Header,
165 self.bytes[0..self.capacity.header_bytes],
166 );
167 const header_start = index * self.capacity.header_count_per_response;
168 const head_start = self.capacity.header_bytes +
169 index * self.capacity.head_bytes_per_response;
170 const body_region_start = self.capacity.header_bytes + self.capacity.head_bytes;
171 const body_start = body_region_start + index * self.capacity.body_bytes_per_response;
172 return .{
173 .headers = all_headers[header_start..][0..self.capacity.header_count_per_response],
174 .head = self.bytes[head_start..][0..self.capacity.head_bytes_per_response],
175 .body = self.bytes[body_start..][0..self.capacity.body_bytes_per_response],
176 };
177 }
178
179 pub fn deinit(self: *Storage, allocator: std.mem.Allocator) void {
180 std.debug.assert(self.phase != .teardown);
181 std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
182 self.phase = .teardown;
183 if (self.bytes.len != 0) allocator.free(self.bytes);
184 self.bytes = &.{};
185 }
186 };
187
188 comptime {
189 alloc_phase.capacity.requireAllocatorRejectingOwnerShape(Storage);
190 }
191
192 fn checkStorageInitFailures(allocator: std.mem.Allocator) !void {
193 var storage = try Storage.init(allocator, .{
194 .response_count = 3,
195 .header_count_per_response = 7,
196 .head_bytes_per_response = 127,
197 .body_bytes_per_response = 257,
198 });
199 storage.deinit(allocator);
200 }
201
202 test "Client response storage retries after every allocation failure" {
203 comptime {
204 @stardustClaim(
205 @import("alloc_phase").capacity.witness(Storage, "http_client_response_oom_retry"),
206 null,
207 null,
208 null,
209 null,
210 null,
211 null,
212 );
213 }
214
215 try std.testing.checkAllAllocationFailures(
216 std.testing.allocator,
217 checkStorageInitFailures,
218 .{},
219 );
220 }
221
222 test "Client response storage partitions reusable slots" {
223 comptime {
224 @stardustClaim(
225 @import("alloc_phase").capacity.witness(Storage, "http_client_response_partition"),
226 null,
227 null,
228 null,
229 null,
230 null,
231 null,
232 );
233 }
234
235 var storage = try Storage.init(std.testing.allocator, .{
236 .response_count = 2,
237 .header_count_per_response = 2,
238 .head_bytes_per_response = 3,
239 .body_bytes_per_response = 5,
240 });
241 defer storage.deinit(std.testing.allocator);
242 storage.activate();
243
244 const first = try storage.response(0);
245 const second = try storage.response(1);
246 try std.testing.expect(first.headers.ptr + first.headers.len == second.headers.ptr);
247 try std.testing.expect(first.head.ptr + first.head.len == second.head.ptr);
248 try std.testing.expect(first.body.ptr + first.body.len == second.body.ptr);
249 try std.testing.expect(
250 @intFromPtr(first.head.ptr) ==
251 @intFromPtr(storage.bytes.ptr) + storage.capacity.header_bytes,
252 );
253 try std.testing.expect(
254 @intFromPtr(first.body.ptr) ==
255 @intFromPtr(storage.bytes.ptr) +
256 storage.capacity.header_bytes +
257 storage.capacity.head_bytes,
258 );
259 try std.testing.expectError(
260 error.ClientResponseCapacityExceeded,
261 storage.response(2),
262 );
263 }
264
265 test "Client response parsing remains allocation-free after storage seals" {
266 comptime {
267 @stardustClaim(
268 @import("alloc_phase").capacity.witness(Storage, "http_client_response_sealed"),
269 null,
270 null,
271 null,
272 null,
273 null,
274 null,
275 );
276 }
277
278 var phase_allocator = try alloc_phase.SealedPhaseAllocator.init(std.testing.allocator);
279 var storage = Storage.init(phase_allocator.initializationAllocator(), .{
280 .response_count = 1,
281 .header_count_per_response = 4,
282 .head_bytes_per_response = 128,
283 .body_bytes_per_response = 64,
284 }) catch |err| {
285 phase_allocator.abortInitialization();
286 phase_allocator.deinit();
287 return err;
288 };
289 errdefer {
290 if (phase_allocator.phase() == .initialization) phase_allocator.abortInitialization();
291 if (phase_allocator.phase() == .steady) phase_allocator.beginTeardown();
292 if (storage.phase != .teardown) {
293 storage.deinit(phase_allocator.teardownAllocator());
294 }
295 phase_allocator.deinit();
296 }
297
298 const pointer = storage.bytes.ptr;
299 const capacity = storage.capacity;
300 phase_allocator.seal();
301 storage.activate();
302 const scratch = try storage.response(0);
303 const fixed = try model.ClientResponse.parse(
304 scratch,
305 "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello",
306 );
307 try std.testing.expectEqualStrings("Hello", fixed.body);
308 const chunked = try model.ClientResponse.parse(
309 scratch,
310 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nHe\r\n3\r\nllo\r\n0\r\n\r\n",
311 );
312 try std.testing.expectEqualStrings("Hello", chunked.body);
313 try std.testing.expect(storage.bytes.ptr == pointer);
314 try std.testing.expectEqual(capacity, storage.capacity);
315 try std.testing.expectEqual(alloc_phase.PhaseViolations{}, phase_allocator.violations());
316
317 phase_allocator.beginTeardown();
318 storage.deinit(phase_allocator.teardownAllocator());
319 phase_allocator.deinit();
320 }