lib/http/src/client/operation/storage.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const model = @import("root.zig");
4
5 const Scratch = model.Scratch;
6
7 pub const StorageExhaustion = error{ClientOperationCapacityExceeded};
8
9 pub const Storage = struct {
10 phase: alloc_phase.capacity.Phase,
11 capacity: model.Capacity,
12 bytes: []u8,
13
14 pub const Limits: type = model.Limits;
15 pub const Capacity: type = model.Capacity;
16 pub const Exhaustion: type = StorageExhaustion;
17 pub const InitError = std.mem.Allocator.Error || error{CapacityOverflow};
18
19 pub const claim: alloc_phase.capacity.Declaration = .{
20 .source = .{
21 .id = "http.client_operation_storage",
22 .kind = .phase_static,
23 .limit_source = .caller,
24 .storage = .{
25 .covered = &.{
26 .{
27 .id = "fixed_decoded_host_bytes_for_every_concurrent_client_operation",
28 .lifetime = .steady,
29 .detail = "fixed decoded host bytes for every concurrent client operation",
30 },
31 .{
32 .id = "normalized_request_target_bytes_for_every_client_operation",
33 .lifetime = .steady,
34 .detail = "normalized request-target bytes for every client operation",
35 },
36 .{
37 .id = "plain_socket_windows_for_every_client_operation",
38 .lifetime = .steady,
39 .detail = "plain-socket windows for every client operation",
40 },
41 },
42 .excluded = &.{
43 "TLS buffers, certificate bundles, sockets, DNS state, and kernel queues",
44 "borrowed request inputs, response storage, handlers, and handler output",
45 },
46 },
47 .capacity = .{
48 .inputs = &.{
49 alloc_phase.capacity.bindInput(Limits, "host_bytes_per_operation", "host_bytes_per_operation"),
50 alloc_phase.capacity.bindInput(Limits, "target_bytes_per_operation", "target_bytes_per_operation"),
51 alloc_phase.capacity.bindInput(Limits, "plain_read_bytes_per_operation", "plain_read_bytes_per_operation"),
52 alloc_phase.capacity.bindInput(Limits, "plain_write_bytes_per_operation", "plain_write_bytes_per_operation"),
53 alloc_phase.capacity.bindInput(Limits, "operation_count", "operation_count"),
54 },
55 .type_selectors = &.{},
56 .nodes = &.{
57 .{ .input = 0 },
58 .{ .input = 1 },
59 .{ .input = 2 },
60 .{ .input = 3 },
61 .{ .add = .{ .left = 0, .right = 1 } },
62 .{ .add = .{ .left = 4, .right = 2 } },
63 .{ .add = .{ .left = 5, .right = 3 } },
64 .{ .input = 4 },
65 .{ .product = .{ .left = 7, .right = 6 } },
66 },
67 .assertions = &.{.{
68 .scope = .closure_total,
69 .measure = .retained,
70 .relation = .exact,
71 .expression = 8,
72 }},
73 },
74 .overload = .{
75 .kind = .terminal,
76 .detail = "capacity exhaustion rejects before connection or output publication",
77 },
78 .risks = .{
79 .transitive = .{
80 .status = .witnessed,
81 .detail = "URL preparation and request serialization allocate no storage",
82 },
83 .foreign = .{
84 .status = .excluded,
85 .detail = "TLS and operating-system network owners remain independent",
86 },
87 },
88 .obligations = &.{
89 .{ .key = "http_client_operation_capacity", .role = .capacity_model },
90 .{ .key = "http_client_operation_oom_retry", .role = .custom },
91 .{ .key = "http_client_operation_partition", .role = .custom },
92 .{ .key = "http_client_operation_sealed", .role = .transitive_risk },
93 .{ .key = "http_client_operation_boundary", .role = .custom },
94 .{ .key = "http_client_operation_atomic", .role = .overload },
95 .{ .key = "http_client_operation_network_overload", .role = .overload },
96 .{ .key = "http_client_operation_network_foreign_risk", .role = .foreign_risk },
97 },
98 },
99 .bindings = .{
100 .owner = @This(),
101 .seal = .{
102 .family = alloc_phase.capacity.selector(@This().activate),
103 .premise = .{
104 .class = .checked_semantic_fact,
105 .authority = .checker,
106 },
107 },
108 .teardown = .{
109 .family = alloc_phase.capacity.selector(@This().deinit),
110 .premise = .{
111 .class = .checked_semantic_fact,
112 .authority = .checker,
113 },
114 },
115 },
116 };
117
118 pub fn init(allocator: std.mem.Allocator, limits: Limits) InitError!Storage {
119 const capacity = try Capacity.derive(limits);
120 const bytes = if (capacity.storage_bytes == 0)
121 @as([]u8, &.{})
122 else
123 try allocator.alloc(u8, capacity.storage_bytes);
124 std.debug.assert(bytes.len == capacity.storage_bytes);
125 return .{
126 .phase = .initialization,
127 .capacity = capacity,
128 .bytes = bytes,
129 };
130 }
131
132 pub fn activate(self: *Storage) void {
133 std.debug.assert(self.phase == .initialization);
134 std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
135 self.phase = .steady;
136 }
137
138 pub fn operation(self: *Storage, index: usize) Exhaustion!Scratch {
139 std.debug.assert(self.phase == .steady);
140 if (index >= self.capacity.operation_count) {
141 return error.ClientOperationCapacityExceeded;
142 }
143 const host_start = index * self.capacity.host_bytes_per_operation;
144 const target_region_start = self.capacity.host_bytes;
145 const target_start = target_region_start +
146 index * self.capacity.target_bytes_per_operation;
147 const plain_read_region_start = target_region_start + self.capacity.target_bytes;
148 const plain_read_start = plain_read_region_start +
149 index * self.capacity.plain_read_bytes_per_operation;
150 const plain_write_region_start = plain_read_region_start + self.capacity.plain_read_bytes;
151 const plain_write_start = plain_write_region_start +
152 index * self.capacity.plain_write_bytes_per_operation;
153 const plain_read_region = self.bytes[plain_read_start..];
154 const plain_write_region = self.bytes[plain_write_start..];
155 std.debug.assert(host_start <= self.capacity.host_bytes);
156 std.debug.assert(target_start <= plain_read_region_start);
157 std.debug.assert(plain_read_start <= plain_write_region_start);
158 std.debug.assert(plain_write_start <= self.bytes.len);
159 return .{
160 .host = self.bytes[host_start..][0..self.capacity.host_bytes_per_operation],
161 .target = self.bytes[target_start..][0..self.capacity.target_bytes_per_operation],
162 .plain_read = plain_read_region[0..self.capacity.plain_read_bytes_per_operation],
163 .plain_write = plain_write_region[0..self.capacity.plain_write_bytes_per_operation],
164 };
165 }
166
167 pub fn deinit(self: *Storage, allocator: std.mem.Allocator) void {
168 std.debug.assert(self.phase != .teardown);
169 std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
170 self.phase = .teardown;
171 if (self.bytes.len != 0) allocator.free(self.bytes);
172 self.bytes = &.{};
173 }
174 };
175
176 comptime {
177 alloc_phase.capacity.requireAllocatorRejectingOwnerShape(Storage);
178 }
179
180 fn checkStorageInitFailures(allocator: std.mem.Allocator) !void {
181 var storage = try Storage.init(allocator, .{
182 .operation_count = 3,
183 .host_bytes_per_operation = 17,
184 .target_bytes_per_operation = 31,
185 .plain_read_bytes_per_operation = 47,
186 .plain_write_bytes_per_operation = 61,
187 });
188 storage.deinit(allocator);
189 }
190
191 test "Client operation storage retries after every allocation failure" {
192 comptime {
193 @stardustClaim(
194 @import("alloc_phase").capacity.witness(Storage, "http_client_operation_oom_retry"),
195 null,
196 null,
197 null,
198 null,
199 null,
200 null,
201 );
202 }
203
204 try std.testing.checkAllAllocationFailures(
205 std.testing.allocator,
206 checkStorageInitFailures,
207 .{},
208 );
209 }
210
211 test "Client operation storage partitions reusable slots" {
212 comptime {
213 @stardustClaim(
214 @import("alloc_phase").capacity.witness(Storage, "http_client_operation_partition"),
215 null,
216 null,
217 null,
218 null,
219 null,
220 null,
221 );
222 }
223
224 var storage = try Storage.init(std.testing.allocator, .{
225 .operation_count = 2,
226 .host_bytes_per_operation = 3,
227 .target_bytes_per_operation = 5,
228 .plain_read_bytes_per_operation = 7,
229 .plain_write_bytes_per_operation = 11,
230 });
231 defer storage.deinit(std.testing.allocator);
232 storage.activate();
233
234 const first = try storage.operation(0);
235 const second = try storage.operation(1);
236 try std.testing.expect(first.host.ptr + first.host.len == second.host.ptr);
237 try std.testing.expect(first.target.ptr + first.target.len == second.target.ptr);
238 try std.testing.expect(first.plain_read.ptr + first.plain_read.len == second.plain_read.ptr);
239 try std.testing.expect(first.plain_write.ptr + first.plain_write.len == second.plain_write.ptr);
240 try std.testing.expect(
241 @intFromPtr(first.target.ptr) ==
242 @intFromPtr(storage.bytes.ptr) + storage.capacity.host_bytes,
243 );
244 try std.testing.expect(
245 @intFromPtr(first.plain_read.ptr) ==
246 @intFromPtr(storage.bytes.ptr) +
247 storage.capacity.host_bytes + storage.capacity.target_bytes,
248 );
249 try std.testing.expect(
250 @intFromPtr(first.plain_write.ptr) ==
251 @intFromPtr(storage.bytes.ptr) + storage.capacity.host_bytes +
252 storage.capacity.target_bytes + storage.capacity.plain_read_bytes,
253 );
254 try std.testing.expectError(
255 error.ClientOperationCapacityExceeded,
256 storage.operation(2),
257 );
258 }
259
260 test "Client operation remains allocation-free after storage seals" {
261 comptime {
262 @stardustClaim(
263 @import("alloc_phase").capacity.witness(Storage, "http_client_operation_sealed"),
264 null,
265 null,
266 null,
267 null,
268 null,
269 null,
270 );
271 }
272
273 var phase_allocator = try alloc_phase.SealedPhaseAllocator.init(std.testing.allocator);
274 var storage = Storage.init(phase_allocator.initializationAllocator(), .{
275 .operation_count = 1,
276 .host_bytes_per_operation = 64,
277 .target_bytes_per_operation = 128,
278 .plain_read_bytes_per_operation = 16,
279 .plain_write_bytes_per_operation = 16,
280 }) catch |err| {
281 phase_allocator.abortInitialization();
282 phase_allocator.deinit();
283 return err;
284 };
285 errdefer {
286 if (phase_allocator.phase() == .initialization) phase_allocator.abortInitialization();
287 if (phase_allocator.phase() == .steady) phase_allocator.beginTeardown();
288 if (storage.phase != .teardown) {
289 storage.deinit(phase_allocator.teardownAllocator());
290 }
291 phase_allocator.deinit();
292 }
293
294 const pointer = storage.bytes.ptr;
295 const capacity = storage.capacity;
296 phase_allocator.seal();
297 storage.activate();
298 const scratch = try storage.operation(0);
299 const prepared = try model.ClientOperation.prepare(
300 scratch,
301 "https://api.example.test/v1/responses?mode=sealed",
302 );
303 var bytes: [512]u8 = undefined;
304 var writer = std.Io.Writer.fixed(&bytes);
305 try model.ClientOperation.writeRequest(
306 &writer,
307 "POST",
308 prepared.host,
309 prepared.target,
310 &.{.{ .name = "Content-Type", .value = "application/json" }},
311 "{}",
312 );
313 try std.testing.expect(writer.buffered().len != 0);
314 try std.testing.expect(storage.bytes.ptr == pointer);
315 try std.testing.expectEqual(capacity, storage.capacity);
316 try std.testing.expectEqual(alloc_phase.PhaseViolations{}, phase_allocator.violations());
317
318 phase_allocator.beginTeardown();
319 storage.deinit(phase_allocator.teardownAllocator());
320 phase_allocator.deinit();
321 }