authorgravatar for justus@klausecker.deJustus Klausecker <justus@klausecker.de> 2026-03-06 00:09:55+01:00
committergravatar for justus@klausecker.deJustus Klausecker <justus@klausecker.de> 2026-03-06 10:09:06+01:00
log2ba8c94df6b9e60d73c35bf3d1f80f143fc80cab
tree9fd0559975892f9dd3805df36d49c6da7dd9c3e1
parent73743ddbff5a7a0ffc8f7ecc780369cf5509795b

std.heap.ArenaAllocator: add fuzz test

The fuzz test consists of a planning phase where the fuzzing smith is used to generate a list of actions to be executed and an execution phase where the actions are all executed by multiple threads at the same time. Each action is only executed exactly once and is performed on an `ArenaAllocator` and on a `FixedBufferAllocator` (for reference). The arena is backed by a special allocator that purposely introduces spurious allocation failures. After all actions are executed, the contents of all allocation pairs are compared to each other.

1 files changed, 403 insertions(+), 0 deletions(-)

lib/std/heap/ArenaAllocator.zig+403
......@@ -656,3 +656,406 @@ test "reset while retaining a buffer" {
656656 try std.testing.expect(arena_allocator.state.used_list.?.next == null);
657657 try std.testing.expectEqual(2, arena_allocator.queryCapacity());
658658}
659
660test "fuzz" {
661 @disableInstrumentation();
662 if (@import("builtin").single_threaded) return error.SkipZigTest;
663
664 const gpa = std.heap.smp_allocator;
665
666 var arena_state: ArenaAllocator.State = .init;
667 // No need to deinit arena_state, all allocations are in `sample_buffer`!
668
669 const control_buffer = try gpa.alloc(u8, 64 << 10 << 10);
670 defer gpa.free(control_buffer);
671 var control_instance: std.heap.FixedBufferAllocator = .init(control_buffer);
672
673 const sample_buffer = try gpa.alloc(u8, 64 << 10 << 10);
674 defer gpa.free(sample_buffer);
675 var sample_instance: FuzzAllocator = .init(sample_buffer);
676
677 var allocs: FuzzContext.Allocs = try .initCapacity(gpa, FuzzContext.max_alloc_count);
678 defer allocs.deinit(gpa);
679
680 try std.testing.fuzz(FuzzContext.Init{
681 .gpa = gpa,
682 .allocs = &allocs,
683 .arena_state = &arena_state,
684 .control_instance = &control_instance,
685 .sample_instance = &sample_instance,
686 }, fuzzArenaAllocator, .{});
687}
688
689fn fuzzArenaAllocator(fuzz_init: FuzzContext.Init, smith: *std.testing.Smith) anyerror!void {
690 @disableInstrumentation();
691 const testing = std.testing;
692
693 // We use a 'fresh' `Threaded` instance every time to reset threadlocals to
694 // their default values.
695
696 var io_instance: std.Io.Threaded = .init(fuzz_init.gpa, .{});
697 defer io_instance.deinit();
698 const io = io_instance.io();
699
700 fuzz_init.sample_instance.prepareFailures(smith);
701
702 const control_allocator = fuzz_init.control_instance.threadSafeAllocator();
703 const sample_child_allocator = fuzz_init.sample_instance.allocator();
704
705 var arena_instance = fuzz_init.arena_state.*.promote(sample_child_allocator);
706 defer fuzz_init.arena_state.* = arena_instance.state;
707
708 var ctx: FuzzContext = .init(
709 io,
710 control_allocator,
711 arena_instance.allocator(),
712 fuzz_init.allocs,
713 );
714 defer ctx.deinit();
715
716 ctx.rwl.lockUncancelable(io);
717
718 var group: std.Io.Group = .init;
719 defer group.cancel(io);
720
721 var n_actions: usize = 0;
722 while (!smith.eosWeightedSimple(99, 1) and n_actions < FuzzContext.max_action_count) {
723 errdefer comptime unreachable;
724
725 const ActionTag = @typeInfo(FuzzContext.Action).@"union".tag_type.?;
726 const weights: []const testing.Smith.Weight = weights: {
727 if (ctx.allocs.len == ctx.allocs.capacity)
728 break :weights &.{
729 .value(ActionTag, .resize, 1),
730 .value(ActionTag, .remap, 1),
731 .value(ActionTag, .free, 1),
732 };
733 break :weights testing.Smith.baselineWeights(ActionTag) ++
734 .{testing.Smith.Weight.value(ActionTag, .alloc, 2)};
735 };
736 const action: FuzzContext.Action = switch (smith.valueWeighted(ActionTag, weights)) {
737 .alloc => action: {
738 const alloc_index = ctx.allocs.addOneBounded() catch continue;
739 ctx.allocs.items(.len)[alloc_index] = .free;
740 break :action .{ .alloc = .{
741 .len = nextLen(smith),
742 .alignment = smith.valueRangeAtMost(
743 Alignment,
744 .@"1",
745 .fromByteUnits(2 * std.heap.page_size_max),
746 ),
747 .index = alloc_index,
748 } };
749 },
750 .resize => .{ .resize = .{ .new_len = nextLen(smith) } },
751 .remap => .{ .remap = .{ .new_len = nextLen(smith) } },
752 .free => .free,
753 };
754 group.concurrent(io, FuzzContext.doOneAction, .{ &ctx, action }) catch break;
755 n_actions += 1;
756 }
757
758 ctx.rwl.unlock(io);
759
760 try group.await(io);
761 try ctx.check();
762
763 // This also covers the `deinit` logic since `free_all` uses it internally.
764
765 const old_capacity = arena_instance.queryCapacity();
766 const reset_mode: ResetMode = switch (smith.value(@typeInfo(ResetMode).@"union".tag_type.?)) {
767 .free_all => .free_all,
768 .retain_capacity => .retain_capacity,
769 .retain_with_limit => .{ .retain_with_limit = smith.value(usize) },
770 };
771 const ok = arena_instance.reset(reset_mode);
772 const new_capacity = arena_instance.queryCapacity();
773 switch (reset_mode) {
774 .free_all => {
775 try testing.expect(ok);
776 try testing.expectEqual(0, new_capacity);
777 fuzz_init.sample_instance.reset();
778 },
779 .retain_with_limit => |limit| if (ok) try testing.expect(new_capacity <= limit),
780 .retain_capacity => if (ok) try testing.expectEqual(old_capacity, new_capacity),
781 }
782
783 fuzz_init.control_instance.reset();
784 fuzz_init.allocs.clearRetainingCapacity();
785}
786fn nextLen(smith: *std.testing.Smith) usize {
787 @disableInstrumentation();
788 return usizeRange(smith, 1, 16 << 10 << 10);
789}
790fn usizeRange(smith: *std.testing.Smith, at_least: usize, at_most: usize) usize {
791 @disableInstrumentation();
792 const Int = @Int(.unsigned, @min(64, @bitSizeOf(usize)));
793 return smith.valueRangeAtMost(Int, @intCast(at_least), @intCast(at_most));
794}
795
796const FuzzContext = struct {
797 io: std.Io,
798 rwl: std.Io.RwLock,
799
800 control_allocator: Allocator,
801 sample_allocator: Allocator,
802
803 allocs: *Allocs,
804
805 const max_alloc_count = 4096;
806 const max_action_count = 2 * max_alloc_count;
807
808 const Allocs = std.MultiArrayList(struct {
809 control_ptr: [*]u8,
810 sample_ptr: [*]u8,
811 len: Len,
812 alignment: Alignment,
813 });
814
815 const Len = enum(usize) {
816 free = std.math.maxInt(usize),
817 _,
818 };
819
820 const Action = union(enum(u8)) {
821 alloc: struct { len: usize, alignment: Alignment, index: usize },
822 resize: struct { new_len: usize },
823 remap: struct { new_len: usize },
824 free,
825 };
826
827 threadlocal var tls_next: u8 = 0;
828 threadlocal var tls_last_index: ?usize = null;
829
830 const Init = struct {
831 gpa: Allocator,
832 allocs: *FuzzContext.Allocs,
833 arena_state: *ArenaAllocator.State,
834 control_instance: *std.heap.FixedBufferAllocator,
835 sample_instance: *FuzzAllocator,
836 };
837
838 fn init(
839 io: std.Io,
840 control_allocator: Allocator,
841 sample_allocator: Allocator,
842 allocs: *Allocs,
843 ) FuzzContext {
844 @disableInstrumentation();
845 return .{
846 .io = io,
847 .rwl = .init,
848 .control_allocator = control_allocator,
849 .sample_allocator = sample_allocator,
850 .allocs = allocs,
851 };
852 }
853
854 fn deinit(ctx: *FuzzContext) void {
855 @disableInstrumentation();
856 ctx.* = undefined;
857 }
858
859 fn check(ctx: *const FuzzContext) !void {
860 @disableInstrumentation();
861 for (0..ctx.allocs.len) |index| {
862 const len: usize = switch (ctx.allocs.items(.len)[index]) {
863 .free => continue,
864 _ => |len| @intFromEnum(len),
865 };
866 const control = ctx.allocs.items(.control_ptr)[index][0..len];
867 const sample = ctx.allocs.items(.sample_ptr)[index][0..len];
868 try std.testing.expectEqualSlices(u8, control, sample);
869 }
870 }
871
872 fn doOneAction(ctx: *FuzzContext, action: Action) std.Io.Cancelable!void {
873 @disableInstrumentation();
874 ctx.rwl.lockSharedUncancelable(ctx.io);
875 defer ctx.rwl.unlockShared(ctx.io);
876
877 switch (action) {
878 .alloc => |act| ctx.doOneAlloc(act.len, act.alignment, act.index),
879 .resize => |act| ctx.doOneResize(act.new_len),
880 .remap => |act| ctx.doOneRemap(act.new_len),
881 .free => ctx.doOneFree(),
882 }
883 }
884
885 fn doOneAlloc(ctx: *FuzzContext, len: usize, alignment: Alignment, index: usize) void {
886 @disableInstrumentation();
887 assert(ctx.allocs.items(.len)[index] == .free);
888
889 const control_ptr = ctx.control_allocator.rawAlloc(len, alignment, @returnAddress()) orelse
890 return;
891 const sample_ptr = ctx.sample_allocator.rawAlloc(len, alignment, @returnAddress()) orelse {
892 ctx.control_allocator.rawFree(control_ptr[0..len], alignment, @returnAddress());
893 return;
894 };
895
896 ctx.allocs.set(index, .{
897 .control_ptr = control_ptr,
898 .sample_ptr = sample_ptr,
899 .len = @enumFromInt(len),
900 .alignment = alignment,
901 });
902
903 for (control_ptr[0..len], sample_ptr[0..len]) |*control, *sample| {
904 control.* = tls_next;
905 sample.* = tls_next;
906 tls_next +%= 1;
907 }
908
909 tls_last_index = index;
910 }
911 fn doOneResize(ctx: *FuzzContext, new_len: usize) void {
912 @disableInstrumentation();
913 const index = tls_last_index orelse return;
914 const len = ctx.allocs.items(.len)[index];
915 assert(len != .free);
916 const memory = ctx.allocs.items(.sample_ptr)[index][0..@intFromEnum(len)];
917 const alignment = ctx.allocs.items(.alignment)[index];
918
919 assert(alignment.check(@intFromPtr(ctx.allocs.items(.control_ptr)[index])));
920 assert(alignment.check(@intFromPtr(ctx.allocs.items(.sample_ptr)[index])));
921
922 // Since `resize` is fallible, we have to ensure that `control_allocator`
923 // is always successful by reserving the memory we need beforehand.
924 const new_control_ptr = ctx.control_allocator.rawAlloc(new_len, alignment, @returnAddress()) orelse
925 return;
926 if (ctx.sample_allocator.rawResize(memory, alignment, new_len, @returnAddress())) {
927 const old_control = ctx.allocs.items(.control_ptr)[index][0..memory.len];
928 const overlap = @min(memory.len, new_len);
929 @memcpy(new_control_ptr[0..overlap], old_control[0..overlap]);
930 ctx.control_allocator.rawFree(old_control, alignment, @returnAddress());
931 } else {
932 ctx.control_allocator.rawFree(new_control_ptr[0..new_len], alignment, @returnAddress());
933 return;
934 }
935
936 ctx.allocs.set(index, .{
937 .control_ptr = new_control_ptr,
938 .sample_ptr = memory.ptr,
939 .len = @enumFromInt(new_len),
940 .alignment = alignment,
941 });
942
943 if (new_len > memory.len) {
944 for (
945 ctx.allocs.items(.control_ptr)[index][memory.len..new_len],
946 ctx.allocs.items(.sample_ptr)[index][memory.len..new_len],
947 ) |*control, *sample| {
948 control.* = tls_next;
949 sample.* = tls_next;
950 tls_next +%= 1;
951 }
952 }
953 }
954 fn doOneRemap(ctx: *FuzzContext, new_len: usize) void {
955 @disableInstrumentation();
956 return doOneResize(ctx, new_len);
957 }
958 fn doOneFree(ctx: *FuzzContext) void {
959 @disableInstrumentation();
960 const index = tls_last_index orelse return;
961 const len = ctx.allocs.items(.len)[index];
962 assert(len != .free);
963 const memory = ctx.allocs.items(.sample_ptr)[index][0..@intFromEnum(len)];
964 const alignment = ctx.allocs.items(.alignment)[index];
965
966 assert(alignment.check(@intFromPtr(ctx.allocs.items(.control_ptr)[index])));
967 assert(alignment.check(@intFromPtr(ctx.allocs.items(.sample_ptr)[index])));
968
969 ctx.control_allocator.rawFree(ctx.allocs.items(.control_ptr)[index][0..memory.len], alignment, @returnAddress());
970 ctx.sample_allocator.rawFree(ctx.allocs.items(.sample_ptr)[index][0..memory.len], alignment, @returnAddress());
971
972 ctx.allocs.set(index, .{
973 .control_ptr = undefined,
974 .sample_ptr = undefined,
975 .len = .free,
976 .alignment = undefined,
977 });
978
979 tls_last_index = null;
980 }
981};
982
983const FuzzAllocator = struct {
984 fba: std.heap.FixedBufferAllocator,
985 spurious_failures: [256]u8,
986 index: u8,
987
988 fn init(buffer: []u8) FuzzAllocator {
989 @disableInstrumentation();
990 return .{
991 .fba = .init(buffer),
992 .spurious_failures = undefined, // set with `preprepareFailures`
993 .index = 0,
994 };
995 }
996
997 fn prepareFailures(fa: *FuzzAllocator, smith: *std.testing.Smith) void {
998 @disableInstrumentation();
999 const bool_weights: []const std.testing.Smith.Weight = &.{
1000 .value(u8, 0, 10),
1001 .value(u8, 1, 1),
1002 };
1003 smith.bytesWeighted(&fa.spurious_failures, bool_weights);
1004 fa.index = 0;
1005 }
1006
1007 fn reset(fa: *FuzzAllocator) void {
1008 @disableInstrumentation();
1009 fa.fba.reset();
1010 }
1011
1012 fn allocator(fa: *FuzzAllocator) Allocator {
1013 @disableInstrumentation();
1014 return .{
1015 .ptr = fa,
1016 .vtable = &.{
1017 .alloc = FuzzAllocator.alloc,
1018 .resize = FuzzAllocator.resize,
1019 .remap = FuzzAllocator.remap,
1020 .free = FuzzAllocator.free,
1021 },
1022 };
1023 }
1024
1025 fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 {
1026 @disableInstrumentation();
1027 const fa: *FuzzAllocator = @ptrCast(@alignCast(ctx));
1028 _ = ret_addr;
1029
1030 const index = @atomicRmw(u8, &fa.index, .Add, 1, .monotonic);
1031 if (fa.spurious_failures[index] != 0) return null;
1032 return fa.fba.threadSafeAllocator().rawAlloc(len, alignment, @returnAddress());
1033 }
1034
1035 fn resize(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool {
1036 @disableInstrumentation();
1037 const fa: *FuzzAllocator = @ptrCast(@alignCast(ctx));
1038 _ = ret_addr;
1039
1040 const index = @atomicRmw(u8, &fa.index, .Add, 1, .monotonic);
1041 if (fa.spurious_failures[index] != 0) return false;
1042 return fa.fba.threadSafeAllocator().rawResize(memory, alignment, new_len, @returnAddress());
1043 }
1044
1045 fn remap(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
1046 @disableInstrumentation();
1047 const fa: *FuzzAllocator = @ptrCast(@alignCast(ctx));
1048 _ = ret_addr;
1049
1050 const index = @atomicRmw(u8, &fa.index, .Add, 1, .monotonic);
1051 if (fa.spurious_failures[index] != 0) return null;
1052 return fa.fba.threadSafeAllocator().rawRemap(memory, alignment, new_len, @returnAddress());
1053 }
1054
1055 fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void {
1056 @disableInstrumentation();
1057 const fa: *FuzzAllocator = @ptrCast(@alignCast(ctx));
1058 _ = ret_addr;
1059 return fa.fba.threadSafeAllocator().rawFree(memory, alignment, @returnAddress());
1060 }
1061};