lib/quic/src/sim/link.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const alloc_phase = @import("alloc_phase");
  3 const quic = @import("../root.zig");
  4 
  5 const Policy = quic.sim.Policy;
  6 const Stats = quic.sim.Stats;
  7 
  8 /// One of the link's two endpoints, `a` and `b`, so every call that sends, receives, or reads
  9 /// counters names the endpoint it speaks for. Naming an endpoint picks the queue: a send goes into
 10 /// the queue that endpoint owns, and a receive takes from the other one.
 11 pub const End = enum(u1) {
 12     a,
 13     b,
 14 };
 15 
 16 /// The two capacities a caller chooses to set how much traffic the link must hold and derive its
 17 /// storage size: the datagrams one direction may hold at once, and the bytes one datagram may
 18 /// carry. Both directions get the same capacities.
 19 pub const Limits = struct {
 20     queue_capacity: u32,
 21     datagram_capacity: u16,
 22 };
 23 
 24 pub const CapacityError = error{CapacityOverflow};
 25 
 26 const Entry = struct {
 27     delivery_at_ns: u64 = 0,
 28     sequence: u64 = 0,
 29     length: u16 = 0,
 30     occupied: bool = false,
 31 };
 32 
 33 /// The exact storage layout and byte total derived from one set of limits, so a caller learns the
 34 /// exact byte count its storage must have. The total covers queue records and payload bytes for
 35 /// both directions. Limits whose product overflows give `CapacityOverflow`.
 36 pub const Capacity = struct {
 37     queue_capacity: u32,
 38     datagram_capacity: u16,
 39     entry_count: usize,
 40     entry_bytes: usize,
 41     direction_payload_bytes: usize,
 42     payload_bytes: usize,
 43     storage_bytes: usize,
 44 
 45     pub const DeriveError: type = CapacityError;
 46 
 47     pub fn derive(limits: Limits) DeriveError!Capacity {
 48         const queue_count: usize = @intCast(limits.queue_capacity);
 49         const datagram_bytes: usize = @intCast(limits.datagram_capacity);
 50         const entry_count = try alloc_phase.capacity.mul(usize, queue_count, 2);
 51         const entry_bytes = try alloc_phase.capacity.mul(
 52             usize,
 53             entry_count,
 54             @sizeOf(Entry),
 55         );
 56         const direction_payload_bytes = try alloc_phase.capacity.mul(
 57             usize,
 58             queue_count,
 59             datagram_bytes,
 60         );
 61         const payload_bytes = try alloc_phase.capacity.mul(
 62             usize,
 63             direction_payload_bytes,
 64             2,
 65         );
 66         const storage_bytes = try alloc_phase.capacity.add(
 67             usize,
 68             entry_bytes,
 69             payload_bytes,
 70         );
 71         return .{
 72             .queue_capacity = limits.queue_capacity,
 73             .datagram_capacity = limits.datagram_capacity,
 74             .entry_count = entry_count,
 75             .entry_bytes = entry_bytes,
 76             .direction_payload_bytes = direction_payload_bytes,
 77             .payload_bytes = payload_bytes,
 78             .storage_bytes = storage_bytes,
 79         };
 80     }
 81 };
 82 
 83 const MemoryLimits = Limits;
 84 const MemoryCapacity = Capacity;
 85 
 86 const LinkInitSpecificError = error{
 87     LimitsMismatch,
 88     StorageNotReady,
 89 };
 90 
 91 const LinkInitError = Policy.ValidationError || LinkInitSpecificError;
 92 
 93 const LinkSendError = error{
 94     Oversize,
 95     QueueFull,
 96 };
 97 
 98 const Direction = struct {
 99     entries: []Entry,
100     payload: []u8,
101     used: u32 = 0,
102 };
103 
104 const EntryRef = struct {
105     index: usize,
106     entry: *Entry,
107     payload: []u8,
108 };
109 
110 /// The owner of the queue records and payload bytes a link runs on, so one aligned block handed
111 /// over by the caller becomes the only memory the link ever writes to. The owner takes one
112 /// caller-provided aligned byte block and partitions it into the two directions' records and
113 /// payload regions. A block whose length differs from the derived total gives
114 /// `StorageLengthMismatch`. The owner allocates nothing further, so the link's memory use is fixed
115 /// once the block is handed over. Teardown returns the block to the caller.
116 pub const Memory = struct {
117     phase: alloc_phase.capacity.Phase,
118     capacity: MemoryCapacity,
119     storage: Storage,
120     directions: [2]Direction,
121 
122     pub const storage_alignment: usize = @alignOf(Entry);
123     pub const Storage = []align(storage_alignment) u8;
124     pub const Limits: type = MemoryLimits;
125     pub const Capacity: type = MemoryCapacity;
126     pub const Exhaustion = error{QueueFull};
127     pub const InitError = MemoryCapacity.DeriveError || error{StorageLengthMismatch};
128     pub const work_limits: alloc_phase.capacity.WorkLimits = .{
129         .transition_steps_max = 1,
130         .cleanup_steps_per_call_max = 0,
131         .cleanup_calls_at_capacity_max = 0,
132     };
133 
134     pub const claim: alloc_phase.capacity.Declaration = .{
135         .source = .{
136             .id = "quic.sim_memory",
137             .kind = .phase_static,
138             .limit_source = .caller,
139             .storage = .{
140                 .covered = &.{
141                     .{
142                         .id = "two_direction_queue_metadata",
143                         .lifetime = .transferred,
144                         .detail = "caller storage for two bounded datagram metadata queues",
145                     },
146                     .{
147                         .id = "two_direction_datagram_payload_bytes",
148                         .lifetime = .transferred,
149                         .detail = "caller storage for two bounded datagram payload regions",
150                     },
151                 },
152                 .excluded = &.{
153                     "link policies, deterministic random state, statistics, and sequence counters",
154                     "per-direction drop patterns",
155                     "caller send slices and receive output slices",
156                     "socket, thread, clock, and operating-system state",
157                 },
158             },
159             .capacity = .{
160                 .inputs = &.{
161                     alloc_phase.capacity.bindInput(
162                         MemoryLimits,
163                         "queue_capacity",
164                         "queue_capacity",
165                     ),
166                     alloc_phase.capacity.bindInput(
167                         MemoryLimits,
168                         "datagram_capacity",
169                         "datagram_capacity",
170                     ),
171                 },
172                 .type_selectors = &.{
173                     alloc_phase.capacity.bindType(Entry, "entry"),
174                 },
175                 .nodes = &.{
176                     .{ .input = 0 },
177                     .{ .constant = 2 },
178                     .{ .product = .{ .left = 0, .right = 1 } },
179                     .{ .scale = .{
180                         .node = 2,
181                         .coefficient = .{ .size_of_concrete_type = 0 },
182                     } },
183                     .{ .input = 1 },
184                     .{ .product = .{ .left = 2, .right = 4 } },
185                     .{ .add = .{ .left = 3, .right = 5 } },
186                 },
187                 .assertions = &.{.{
188                     .scope = .closure_total,
189                     .measure = .retained,
190                     .relation = .exact,
191                     .expression = 6,
192                 }},
193             },
194             .overload = .{
195                 .kind = .reject_before_mutation,
196                 .detail = "full direction admission preserves queue metadata and payload bytes",
197             },
198             .risks = .{
199                 .transitive = .{
200                     .status = .witnessed,
201                     .detail = "link operations use only fixed scans and caller-owned byte slices",
202                 },
203                 .foreign = .{
204                     .status = .excluded,
205                     .detail = "simulator memory crosses no operating-system or foreign boundary",
206                 },
207             },
208             .work = .{
209                 .equation = "send and receive scan at most queue_capacity entries per direction",
210             },
211             .obligations = &.{
212                 .{ .key = "quic_sim_memory_capacity", .role = .capacity_model },
213                 .{ .key = "quic_sim_memory_overload", .role = .overload },
214                 .{ .key = "quic_sim_memory_transitive", .role = .transitive_risk },
215                 .{ .key = "quic_sim_memory_work", .role = .work_bound },
216             },
217         },
218         .bindings = .{
219             .owner = @This(),
220             .seal = .{
221                 .family = alloc_phase.capacity.selector(@This().activate),
222                 .premise = .{
223                     .class = .checked_semantic_fact,
224                     .authority = .checker,
225                 },
226             },
227             .teardown = .{
228                 .family = alloc_phase.capacity.selector(@This().deinit),
229                 .premise = .{
230                     .class = .checked_semantic_fact,
231                     .authority = .checker,
232                 },
233             },
234         },
235     };
236 
237     pub fn init(storage: Storage, limits: MemoryLimits) InitError!Memory {
238         const capacity = try MemoryCapacity.derive(limits);
239         if (storage.len != capacity.storage_bytes) return error.StorageLengthMismatch;
240         const entries = std.mem.bytesAsSlice(Entry, storage[0..capacity.entry_bytes]);
241         for (entries) |*entry| entry.* = .{};
242         const queue_count: usize = @intCast(capacity.queue_capacity);
243         const payload = storage[capacity.entry_bytes..];
244         return .{
245             .phase = .initialization,
246             .capacity = capacity,
247             .storage = storage,
248             .directions = .{
249                 .{
250                     .entries = entries[0..queue_count],
251                     .payload = payload[0..capacity.direction_payload_bytes],
252                 },
253                 .{
254                     .entries = entries[queue_count..],
255                     .payload = payload[capacity.direction_payload_bytes..],
256                 },
257             },
258         };
259     }
260 
261     pub fn activate(self: *Memory) void {
262         std.debug.assert(self.phase == .initialization);
263         std.debug.assert(self.storage.len == self.capacity.storage_bytes);
264         std.debug.assert(self.directions[0].used == 0);
265         std.debug.assert(self.directions[1].used == 0);
266         self.phase = .steady;
267     }
268 
269     fn freeCount(self: *const Memory, direction_index: usize) u32 {
270         std.debug.assert(self.phase == .steady);
271         std.debug.assert(direction_index < self.directions.len);
272         const direction = &self.directions[direction_index];
273         std.debug.assert(direction.used <= self.capacity.queue_capacity);
274         return self.capacity.queue_capacity - direction.used;
275     }
276 
277     /// Reports whether one direction has room for a given number of further datagrams before the
278     /// link queues anything, so a full direction is refused before any byte moves. Too little room
279     /// gives `QueueFull`. The check reads the queue and changes nothing, so a refusal leaves the
280     /// queued datagrams as they were.
281     pub fn requireFree(self: *const Memory, from: End, copies: u2) Exhaustion!void {
282         std.debug.assert(copies > 0);
283         if (self.freeCount(outgoingIndex(from)) < copies) return error.QueueFull;
284     }
285 
286     fn acquire(self: *Memory, direction_index: usize) Exhaustion!EntryRef {
287         std.debug.assert(self.phase == .steady);
288         std.debug.assert(direction_index < self.directions.len);
289         var direction = &self.directions[direction_index];
290         if (direction.used == self.capacity.queue_capacity) return error.QueueFull;
291         for (direction.entries, 0..) |*entry, index| {
292             if (entry.occupied) continue;
293             entry.occupied = true;
294             direction.used += 1;
295             return .{
296                 .index = index,
297                 .entry = entry,
298                 .payload = payloadAt(direction, index, self.capacity.datagram_capacity),
299             };
300         }
301         unreachable;
302     }
303 
304     fn release(self: *Memory, direction_index: usize, entry_index: usize) void {
305         std.debug.assert(self.phase == .steady);
306         std.debug.assert(direction_index < self.directions.len);
307         var direction = &self.directions[direction_index];
308         std.debug.assert(entry_index < direction.entries.len);
309         std.debug.assert(direction.entries[entry_index].occupied);
310         std.debug.assert(direction.used > 0);
311         direction.entries[entry_index].occupied = false;
312         direction.used -= 1;
313     }
314 
315     pub fn deinit(self: *Memory) Storage {
316         std.debug.assert(self.phase == .steady);
317         self.phase = .teardown;
318         const storage = self.storage;
319         self.* = undefined;
320         return storage;
321     }
322 };
323 
324 comptime {
325     alloc_phase.capacity.requireProvisionedRejectingOwnerShape(Memory);
326 }
327 
328 fn payloadAt(direction: *const Direction, index: usize, datagram_capacity: u16) []u8 {
329     std.debug.assert(index < direction.entries.len);
330     const width: usize = @intCast(datagram_capacity);
331     const start = index * width;
332     std.debug.assert(start <= direction.payload.len);
333     std.debug.assert(width <= direction.payload.len - start);
334     return direction.payload[start..][0..width];
335 }
336 
337 /// A two-ended path that carries opaque datagrams between its endpoints over caller-owned memory,
338 /// on a schedule the caller can reproduce, so a test holds one and drives both endpoints of a
339 /// simulated path through it. The link holds one policy per direction, a generator started from the
340 /// caller's seed, the counters, the send order, and the pending drop patterns. The same seed and
341 /// the same sequence of calls give the same outcome every run, so a failing test can be replayed.
342 /// The bytes the link carries are opaque to it, so QUIC packets remain outside its view.
343 pub const Link = struct {
344     memory: *Memory,
345     policies: [2]Policy,
346     prng: std.Random.DefaultPrng,
347     counts: [2]Stats = .{ .{}, .{} },
348     next_sequence: [2]u64 = .{ 0, 0 },
349     drops: [2]u64 = .{ 0, 0 },
350 
351     pub const InitError: type = LinkInitError;
352     pub const SendError: type = LinkSendError;
353 
354     /// Starts a link once over memory the caller has already built, with one policy for each
355     /// direction and a seed for the delays and the chances, so the caller receives the value it
356     /// drives. The call moves the memory from its initialization phase into its steady phase, after
357     /// which the partition is fixed. Memory already past initialization gives `StorageNotReady`.
358     /// Limits that differ from the ones the memory was built with give `LimitsMismatch`. Each
359     /// policy is checked against the datagram capacity before anything is sealed, so an invalid
360     /// policy fails here.
361     pub fn init(
362         limits: Limits,
363         storage: *Memory,
364         seed: u64,
365         policy_a_to_b: Policy,
366         policy_b_to_a: Policy,
367     ) InitError!Link {
368         if (storage.phase != .initialization) return error.StorageNotReady;
369         if (storage.capacity.queue_capacity != limits.queue_capacity) {
370             return error.LimitsMismatch;
371         }
372         if (storage.capacity.datagram_capacity != limits.datagram_capacity) {
373             return error.LimitsMismatch;
374         }
375         try policy_a_to_b.validate(limits.datagram_capacity);
376         try policy_b_to_a.validate(limits.datagram_capacity);
377         storage.activate();
378         return .{
379             .memory = storage,
380             .policies = .{ policy_a_to_b, policy_b_to_a },
381             .prng = std.Random.DefaultPrng.init(seed),
382         };
383     }
384 
385     /// Marks which of the next datagrams one endpoint sends are thrown away, by bit position,
386     /// starting from bit 0 and covering the next 64, so a test loses exactly the datagrams it
387     /// chooses at the moment it chooses. Only datagrams that pass the MTU check are counted against
388     /// the pattern, because the check comes first. A datagram dropped this way counts as sent and
389     /// lost. A marked datagram draws nothing from the seeded generator, so marking a drop leaves
390     /// the rest of the run unchanged. A subsequent call joins the new pattern with the bits still
391     /// pending, and both are counted from the next datagram.
392     pub fn dropNext(self: *Link, from: End, pattern: u64) void {
393         const direction_index = outgoingIndex(from);
394         std.debug.assert(direction_index < self.drops.len);
395         self.drops[direction_index] |= pattern;
396         std.debug.assert(self.drops[direction_index] & pattern == pattern);
397     }
398 
399     /// Takes one datagram from an endpoint at a given instant and applies that direction's policy
400     /// to it, so the link decides what becomes of the datagram. A datagram above the MTU is refused
401     /// with `Oversize` and counted. A datagram the drop pattern names, or that the loss chance
402     /// catches, is counted as sent and lost and stops there. A direction without room for the
403     /// copies is refused with `QueueFull` before anything is queued. Otherwise the bytes are copied
404     /// into the queue with a delivery time drawn from the policy's delay range, and the duplication
405     /// chance may add a second copy with its own delay. The reordering chance trades the new copy's
406     /// delivery time and send order with the most recently queued one.
407     pub fn send(self: *Link, from: End, bytes: []const u8, now_ns: u64) SendError!void {
408         const direction_index = outgoingIndex(from);
409         const policy = self.policies[direction_index];
410         const mtu: usize = policy.effectiveMtu(self.memory.capacity.datagram_capacity);
411         if (bytes.len > mtu) {
412             increment(&self.counts[direction_index].oversize);
413             return error.Oversize;
414         }
415         const dropped = self.takeDrop(direction_index);
416         if (dropped or self.event(policy.loss_permille)) {
417             increment(&self.counts[direction_index].sent);
418             increment(&self.counts[direction_index].lost);
419             return;
420         }
421         const duplicate = self.event(policy.duplicate_permille);
422         const copies: u2 = if (duplicate) 2 else 1;
423         self.memory.requireFree(from, copies) catch {
424             increment(&self.counts[direction_index].queue_full);
425             return error.QueueFull;
426         };
427         const reorder = self.event(policy.reorder_permille);
428         const previous = if (reorder) self.previousIndex(direction_index) else null;
429         const first_delay = self.delay(policy);
430         const second_delay = if (duplicate) self.delay(policy) else 0;
431         increment(&self.counts[direction_index].sent);
432         const first = self.enqueue(direction_index, bytes, deliveryAt(now_ns, first_delay));
433         if (duplicate) {
434             _ = self.enqueue(direction_index, bytes, deliveryAt(now_ns, second_delay));
435             increment(&self.counts[direction_index].duplicated);
436         }
437         if (previous) |previous_index| {
438             self.swapDelivery(direction_index, first.index, previous_index);
439             increment(&self.counts[direction_index].reordered);
440         }
441     }
442 
443     /// Copies the earliest datagram due at one endpoint by a given instant into the caller's buffer
444     /// and returns its length, so the destination endpoint receives whatever has arrived. The call
445     /// yields null before any datagram becomes due. Ties between equal delivery times go to the
446     /// earlier send order, so a reordered pair keeps a definite order. The caller's buffer holds at
447     /// least the datagram capacity, which debug builds check. The buffer lies outside the link's
448     /// own bytes, because the copy assumes they do not overlap. Taking a datagram frees its queue
449     /// slot for the next send.
450     pub fn receive(self: *Link, at: End, now_ns: u64, out: []u8) ?usize {
451         const direction_index = incomingIndex(at);
452         const entry_index = self.earliestIndex(direction_index) orelse return null;
453         const direction = &self.memory.directions[direction_index];
454         const entry = &direction.entries[entry_index];
455         if (entry.delivery_at_ns > now_ns) return null;
456         const length: usize = @intCast(entry.length);
457         std.debug.assert(out.len >= self.memory.capacity.datagram_capacity);
458         const payload = payloadAt(
459             direction,
460             entry_index,
461             self.memory.capacity.datagram_capacity,
462         );
463         @memcpy(out[0..length], payload[0..length]);
464         self.memory.release(direction_index, entry_index);
465         increment(&self.counts[direction_index].delivered);
466         return length;
467     }
468 
469     /// Returns the instant at which the earliest pending datagram becomes due at one endpoint, so a
470     /// test driving a manual clock learns how far time may move before something arrives. An empty
471     /// queue gives null.
472     pub fn nextDeliveryAt(self: *const Link, at: End) ?u64 {
473         const direction_index = incomingIndex(at);
474         const entry_index = self.earliestIndex(direction_index) orelse return null;
475         return self.memory.directions[direction_index].entries[entry_index].delivery_at_ns;
476     }
477 
478     /// Returns a copy of the counters for the datagrams one endpoint sent, so a test reads what the
479     /// path did to those datagrams after a run. The delivery count belongs to the sending direction
480     /// too, because the receiving endpoint's take is counted against the queue it came from.
481     pub fn stats(self: *const Link, from: End) Stats {
482         return self.counts[outgoingIndex(from)];
483     }
484 
485     fn event(self: *Link, permille: u16) bool {
486         std.debug.assert(permille <= 1000);
487         if (permille == 0) return false;
488         if (permille == 1000) return true;
489         return self.prng.random().uintLessThan(u16, 1000) < permille;
490     }
491 
492     fn takeDrop(self: *Link, direction_index: usize) bool {
493         std.debug.assert(direction_index < self.drops.len);
494         const pattern = self.drops[direction_index];
495         self.drops[direction_index] = pattern >> 1;
496         const dropped = pattern & 1 != 0;
497         const remaining = @popCount(self.drops[direction_index]);
498         std.debug.assert(remaining + @intFromBool(dropped) == @popCount(pattern));
499         return dropped;
500     }
501 
502     fn delay(self: *Link, policy: Policy) u64 {
503         std.debug.assert(policy.delay_min_ns <= policy.delay_max_ns);
504         if (policy.delay_min_ns == policy.delay_max_ns) return policy.delay_min_ns;
505         const span = policy.delay_max_ns - policy.delay_min_ns;
506         const offset = if (span == std.math.maxInt(u64))
507             self.prng.random().int(u64)
508         else
509             self.prng.random().uintLessThan(u64, span + 1);
510         return policy.delay_min_ns + offset;
511     }
512 
513     fn enqueue(
514         self: *Link,
515         direction_index: usize,
516         bytes: []const u8,
517         delivery_at_ns: u64,
518     ) EntryRef {
519         const acquired = self.memory.acquire(direction_index) catch unreachable;
520         std.debug.assert(bytes.len <= acquired.payload.len);
521         const sequence = self.takeSequence(direction_index);
522         @memcpy(acquired.payload[0..bytes.len], bytes);
523         acquired.entry.* = .{
524             .delivery_at_ns = delivery_at_ns,
525             .sequence = sequence,
526             .length = @intCast(bytes.len),
527             .occupied = true,
528         };
529         return acquired;
530     }
531 
532     fn takeSequence(self: *Link, direction_index: usize) u64 {
533         std.debug.assert(direction_index < self.next_sequence.len);
534         std.debug.assert(self.next_sequence[direction_index] < std.math.maxInt(u64));
535         const sequence = self.next_sequence[direction_index];
536         self.next_sequence[direction_index] += 1;
537         return sequence;
538     }
539 
540     fn previousIndex(self: *const Link, direction_index: usize) ?usize {
541         const entries = self.memory.directions[direction_index].entries;
542         var selected: ?usize = null;
543         for (entries, 0..) |entry, index| {
544             if (!entry.occupied) continue;
545             if (selected == null) selected = index;
546             if (selected) |current| {
547                 if (entry.sequence > entries[current].sequence) selected = index;
548             }
549         }
550         return selected;
551     }
552 
553     fn earliestIndex(self: *const Link, direction_index: usize) ?usize {
554         const entries = self.memory.directions[direction_index].entries;
555         var selected: ?usize = null;
556         for (entries, 0..) |entry, index| {
557             if (!entry.occupied) continue;
558             if (selected == null) selected = index;
559             if (selected) |current| {
560                 if (entry.delivery_at_ns < entries[current].delivery_at_ns) selected = index;
561                 if (entry.delivery_at_ns != entries[current].delivery_at_ns) continue;
562                 if (entry.sequence < entries[current].sequence) selected = index;
563             }
564         }
565         return selected;
566     }
567 
568     fn swapDelivery(self: *Link, direction_index: usize, first: usize, second: usize) void {
569         const entries = self.memory.directions[direction_index].entries;
570         std.debug.assert(first < entries.len);
571         std.debug.assert(second < entries.len);
572         std.mem.swap(u64, &entries[first].delivery_at_ns, &entries[second].delivery_at_ns);
573         std.mem.swap(u64, &entries[first].sequence, &entries[second].sequence);
574     }
575 };
576 
577 fn outgoingIndex(from: End) usize {
578     return @backingInt(from);
579 }
580 
581 fn incomingIndex(at: End) usize {
582     return switch (at) {
583         .a => outgoingIndex(.b),
584         .b => outgoingIndex(.a),
585     };
586 }
587 
588 fn deliveryAt(now_ns: u64, delay_ns: u64) u64 {
589     return std.math.add(u64, now_ns, delay_ns) catch std.math.maxInt(u64);
590 }
591 
592 fn increment(counter: *u64) void {
593     std.debug.assert(counter.* < std.math.maxInt(u64));
594     counter.* += 1;
595 }
596 
597 test "simulator memory capacity matches its independent byte model" {
598     comptime {
599         @stardustClaim(
600             alloc_phase.capacity.witness(Memory, "quic_sim_memory_capacity"),
601             null,
602             null,
603             null,
604             null,
605             null,
606             null,
607         );
608     }
609     const limits = Limits{ .queue_capacity = 3, .datagram_capacity = 7 };
610     const capacity = try Capacity.derive(limits);
611     try std.testing.expectEqual(@as(usize, 6), capacity.entry_count);
612     try std.testing.expectEqual(6 * @sizeOf(Entry), capacity.entry_bytes);
613     try std.testing.expectEqual(@as(usize, 21), capacity.direction_payload_bytes);
614     try std.testing.expectEqual(@as(usize, 42), capacity.payload_bytes);
615     try std.testing.expectEqual(
616         6 * @sizeOf(Entry) + 42,
617         capacity.storage_bytes,
618     );
619 }
620 
621 test "simulator memory requires the exact derived caller storage" {
622     comptime {
623         @stardustClaim(
624             alloc_phase.capacity.witness(Memory, "quic_sim_memory_transitive"),
625             null,
626             null,
627             null,
628             null,
629             null,
630             null,
631         );
632     }
633     const limits = Limits{ .queue_capacity = 2, .datagram_capacity = 4 };
634     const capacity = comptime Capacity.derive(limits) catch unreachable;
635     var short: [capacity.storage_bytes - 1]u8 align(Memory.storage_alignment) = undefined;
636     try std.testing.expectError(
637         error.StorageLengthMismatch,
638         Memory.init(&short, limits),
639     );
640     var exact: [capacity.storage_bytes]u8 align(Memory.storage_alignment) = undefined;
641     var memory = try Memory.init(&exact, limits);
642     memory.activate();
643     const returned = memory.deinit();
644     try std.testing.expectEqual(@as(usize, capacity.storage_bytes), returned.len);
645 }
646 
647 test "simulator memory admits its queue capacity and rejects maximum plus one" {
648     comptime {
649         @stardustClaim(
650             alloc_phase.capacity.witness(Memory, "quic_sim_memory_overload"),
651             null,
652             null,
653             null,
654             null,
655             null,
656             null,
657         );
658     }
659     comptime {
660         @stardustClaim(
661             alloc_phase.capacity.witness(Memory, "quic_sim_memory_work"),
662             null,
663             null,
664             null,
665             null,
666             null,
667             null,
668         );
669     }
670     const limits = Limits{ .queue_capacity = 2, .datagram_capacity = 1 };
671     const capacity = comptime Capacity.derive(limits) catch unreachable;
672     var bytes: [capacity.storage_bytes]u8 align(Memory.storage_alignment) = undefined;
673     var memory = try Memory.init(&bytes, limits);
674     memory.activate();
675     _ = try memory.acquire(0);
676     _ = try memory.acquire(0);
677     try std.testing.expectError(error.QueueFull, memory.acquire(0));
678     _ = memory.deinit();
679 }