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 }