defensive tests for double free seen in prod

This commit is contained in:
Emil Lerch 2026-06-01 12:09:28 -07:00
parent f54faf4732
commit 4639edd813
Signed by: lobo
GPG key ID: A7B62D657EF764F8

92
src/cache/store.zig vendored
View file

@ -1913,6 +1913,98 @@ test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon
try std.testing.expectEqual(DividendType.regular, result.data[0].type);
}
test "writeMerged Dividend: currency upgrade does not double-free" {
// Tiingo writes a sparse record (no currency). Polygon's later
// write supplies a heap-allocated currency string. The merge
// path must not let `existing.currency = incoming.currency`
// create two records that both believe they own the same
// buffer, otherwise std.testing.allocator's double-free
// detection trips when the caller's deinit runs later.
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
var s = Store.init(io, allocator, dir_path);
// Tiingo first: sparse no currency.
var tiingo_view = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
};
s.writeWithSource(Dividend, "TEST", tiingo_view[0..], .{ .seconds = Ttl.dividends }, "tiingo");
// Polygon second: same ex_date, but supplies currency. Caller
// owns the heap allocation and frees it after writeMerged
// returns (mirrors how Polygon's fetchDividends works in
// production: returns slice with heap-allocated currency
// strings, caller deinits).
var polygon_view = [_]Dividend{
.{
.ex_date = Date.fromYmd(2024, 5, 15),
.amount = 0.50,
.currency = try allocator.dupe(u8, "USD"),
},
};
defer for (polygon_view) |d| d.deinit(allocator);
s.writeWithSource(Dividend, "TEST", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon");
// Read back and verify the upgrade landed.
const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache;
defer allocator.free(result.data);
defer for (result.data) |d| d.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), result.data.len);
try std.testing.expect(result.data[0].currency != null);
try std.testing.expectEqualStrings("USD", result.data[0].currency.?);
}
test "writeMerged Dividend: existing currency preserved on second write with different currency" {
// Polygon writes USD first. A later write with a different
// currency (CAD) must NOT overwrite first non-null wins.
// This exercises the path where both existing and incoming
// have non-null currency strings, which is the trickiest
// shape for the merge primitive's lifetime management.
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
var s = Store.init(io, allocator, dir_path);
var first = [_]Dividend{
.{
.ex_date = Date.fromYmd(2024, 5, 15),
.amount = 0.50,
.currency = try allocator.dupe(u8, "USD"),
},
};
defer for (first) |d| d.deinit(allocator);
s.writeWithSource(Dividend, "TEST", first[0..], .{ .seconds = Ttl.dividends }, "polygon");
var second = [_]Dividend{
.{
.ex_date = Date.fromYmd(2024, 5, 15),
.amount = 0.50,
.currency = try allocator.dupe(u8, "CAD"),
},
};
defer for (second) |d| d.deinit(allocator);
s.writeWithSource(Dividend, "TEST", second[0..], .{ .seconds = Ttl.dividends }, "polygon");
const result = s.read(Dividend, "TEST", null, .any) orelse return error.NoCache;
defer allocator.free(result.data);
defer for (result.data) |d| d.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), result.data.len);
try std.testing.expect(result.data[0].currency != null);
// First write's currency wins.
try std.testing.expectEqualStrings("USD", result.data[0].currency.?);
}
test "writeMerged Dividend: type unknown counts as null and gets upgraded" {
// Tiingo's dividend records always carry type = .unknown. A
// later Polygon write with type = .regular must upgrade the