From 4639edd813ac48c57328435ab36b46a0e1db6733 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 1 Jun 2026 12:09:28 -0700 Subject: [PATCH] defensive tests for double free seen in prod --- src/cache/store.zig | 92 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/cache/store.zig b/src/cache/store.zig index 907b60d..dbee8a4 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -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