lib/tracy/src/memory.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const pretty_json = @import("pretty").json;
   3 const capture_mod = @import("capture.zig");
   4 const report = @import("report.zig");
   5 const event = @import("event.zig");
   6 const record_mod = @import("record.zig");
   7 const transport = @import("transport.zig");
   8 
   9 pub const schema = "tracy.memory/v1";
  10 pub const CaptureIntegrity = capture_mod.Integrity;
  11 
  12 pub const Sort = enum {
  13     live,
  14     high_water,
  15     allocated,
  16     lifetime_tail,
  17     allocs,
  18     frees,
  19     name,
  20 
  21     pub fn fromName(text: []const u8) ?Sort {
  22         if (std.mem.eql(u8, text, "live")) return .live;
  23         if (std.mem.eql(u8, text, "high-water")) return .high_water;
  24         if (std.mem.eql(u8, text, "allocated")) return .allocated;
  25         if (std.mem.eql(u8, text, "lifetime-tail")) return .lifetime_tail;
  26         if (std.mem.eql(u8, text, "allocs")) return .allocs;
  27         if (std.mem.eql(u8, text, "frees")) return .frees;
  28         if (std.mem.eql(u8, text, "name")) return .name;
  29         return null;
  30     }
  31 
  32     fn tag(self: Sort) []const u8 {
  33         return switch (self) {
  34             .live => "live",
  35             .high_water => "high-water",
  36             .allocated => "allocated",
  37             .lifetime_tail => "lifetime-tail",
  38             .allocs => "allocs",
  39             .frees => "frees",
  40             .name => "name",
  41         };
  42     }
  43 };
  44 
  45 pub const Options = struct {
  46     top: usize = 20,
  47     occurrences: usize = 40,
  48     sort: Sort = .live,
  49     min_live_bytes: u64 = 0,
  50     name: ?[]const u8 = null,
  51 };
  52 
  53 pub const Counters = struct {
  54     events: u64 = 0,
  55     allocations: u64 = 0,
  56     frees: u64 = 0,
  57     unmatched_frees: u64 = 0,
  58     duplicate_allocations: u64 = 0,
  59     completed_lifetimes: u64 = 0,
  60     lifetime_samples: u64 = 0,
  61     timestamp_regressions: u64 = 0,
  62     untracked_allocations: u64 = 0,
  63     size_mismatches: u64 = 0,
  64     allocated_bytes: u64 = 0,
  65     freed_bytes: u64 = 0,
  66     live_bytes: u64 = 0,
  67     high_water_live_bytes: u64 = 0,
  68     high_water_time_ns: u64 = 0,
  69 };
  70 
  71 pub const Summary = struct {
  72     name: []const u8,
  73     allocations: u64 = 0,
  74     frees: u64 = 0,
  75     unmatched_frees: u64 = 0,
  76     duplicate_allocations: u64 = 0,
  77     active_allocations: u64 = 0,
  78     completed_lifetimes: u64 = 0,
  79     lifetime_samples: u64 = 0,
  80     timestamp_regressions: u64 = 0,
  81     untracked_allocations: u64 = 0,
  82     size_mismatches: u64 = 0,
  83     allocated_bytes: u64 = 0,
  84     freed_bytes: u64 = 0,
  85     live_bytes: u64 = 0,
  86     high_water_live_bytes: u64 = 0,
  87     high_water_time_ns: u64 = 0,
  88     max_alloc_bytes: u64 = 0,
  89     total_lifetime_ns: u64 = 0,
  90     min_lifetime_ns: u64 = 0,
  91     p50_lifetime_ns: u64 = 0,
  92     p90_lifetime_ns: u64 = 0,
  93     p99_lifetime_ns: u64 = 0,
  94     max_lifetime_ns: u64 = 0,
  95     last_event_ns: u64 = 0,
  96 
  97     pub fn meanAllocBytes(self: Summary) u64 {
  98         if (self.allocations == 0) return 0;
  99         return self.allocated_bytes / self.allocations;
 100     }
 101 
 102     pub fn meanLifetimeNs(self: Summary) u64 {
 103         if (self.lifetime_samples == 0) return 0;
 104         return self.total_lifetime_ns / self.lifetime_samples;
 105     }
 106 };
 107 
 108 const Record = struct {
 109     name: []const u8,
 110     size: u64,
 111     allocated_ns: u64,
 112     thread: u64,
 113 };
 114 
 115 const LiveView = struct {
 116     name: []const u8,
 117     address: u64,
 118     size: u64,
 119     allocated_ns: u64,
 120     age_ns: u64,
 121     thread: u64,
 122 };
 123 
 124 pub const Lifetime = struct {
 125     name: []const u8,
 126     address: u64,
 127     size: u64,
 128     reported_free_size: u64,
 129     allocation_thread: u64,
 130     free_thread: u64,
 131     allocated_ns: u64,
 132     freed_ns: u64,
 133     lifetime_ns: u64,
 134     size_match: bool,
 135     valid_time: bool,
 136 };
 137 
 138 const LifetimeSample = struct {
 139     row: usize,
 140     duration_ns: u64,
 141 };
 142 
 143 pub const Analyzer = struct {
 144     allocator: std.mem.Allocator,
 145     capture: capture_mod.Tracker = .{},
 146     summaries: std.StringHashMapUnmanaged(Summary) = .{},
 147     active: std.AutoHashMapUnmanaged(u64, Record) = .{},
 148     lifetimes: std.ArrayListUnmanaged(Lifetime) = .empty,
 149     counters: Counters = .{},
 150     start_ns: ?u64 = null,
 151     end_ns: ?u64 = null,
 152 
 153     pub fn init(allocator: std.mem.Allocator) Analyzer {
 154         return .{ .allocator = allocator };
 155     }
 156 
 157     pub fn deinit(self: *Analyzer) void {
 158         var iter = self.summaries.iterator();
 159         while (iter.next()) |entry| self.allocator.free(entry.key_ptr.*);
 160         self.summaries.deinit(self.allocator);
 161         self.active.deinit(self.allocator);
 162         self.lifetimes.deinit(self.allocator);
 163         self.* = undefined;
 164     }
 165 
 166     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
 167         var lines = std.mem.splitScalar(u8, bytes, '\n');
 168         while (lines.next()) |line| try self.ingestJsonLine(line);
 169     }
 170 
 171     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
 172         const text = std.mem.trim(u8, line, " \t\r\n");
 173         if (text.len == 0) return;
 174         var parsed = try record_mod.parseLine(self.allocator, text);
 175         defer parsed.deinit();
 176         switch (parsed) {
 177             .event => |value| try self.ingest(value),
 178             .flight => |report_value| self.recordFlightReport(report_value),
 179         }
 180     }
 181 
 182     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
 183         self.capture.record(parsed);
 184         self.counters.events += 1;
 185         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 186         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 187         switch (parsed.kind) {
 188             .start => {
 189                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 190             },
 191             .stop => {
 192                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 193             },
 194             .alloc => try self.recordAlloc(parsed),
 195             .free => try self.recordFree(parsed),
 196             else => {},
 197         }
 198     }
 199 
 200     pub fn collectSummaries(
 201         self: *Analyzer,
 202         allocator: std.mem.Allocator,
 203         options: Options,
 204     ) !std.ArrayListUnmanaged(Summary) {
 205         var rows: std.ArrayListUnmanaged(Summary) = .empty;
 206         errdefer rows.deinit(allocator);
 207         var iter = self.summaries.valueIterator();
 208         while (iter.next()) |summary| {
 209             if (options.name) |wanted| {
 210                 if (!std.mem.eql(u8, wanted, summary.name)) continue;
 211             }
 212             if (summary.live_bytes < options.min_live_bytes) continue;
 213             try rows.append(allocator, summary.*);
 214         }
 215         try applyLifetimeDistributions(allocator, rows.items, self.lifetimes.items);
 216         sortSummaries(rows.items, options.sort);
 217         return rows;
 218     }
 219 
 220     pub fn collectLive(
 221         self: *Analyzer,
 222         allocator: std.mem.Allocator,
 223         options: Options,
 224     ) !std.ArrayListUnmanaged(LiveView) {
 225         var rows: std.ArrayListUnmanaged(LiveView) = .empty;
 226         errdefer rows.deinit(allocator);
 227         const end_ns = self.end_ns orelse 0;
 228         var iter = self.active.iterator();
 229         while (iter.next()) |entry| {
 230             const record = entry.value_ptr.*;
 231             if (options.name) |wanted| {
 232                 if (!std.mem.eql(u8, wanted, record.name)) continue;
 233             }
 234             if (record.size < options.min_live_bytes) continue;
 235             try rows.append(allocator, .{
 236                 .name = record.name,
 237                 .address = entry.key_ptr.*,
 238                 .size = record.size,
 239                 .allocated_ns = record.allocated_ns,
 240                 .age_ns = if (end_ns >= record.allocated_ns) end_ns - record.allocated_ns else 0,
 241                 .thread = record.thread,
 242             });
 243         }
 244         std.mem.sort(LiveView, rows.items, {}, liveGreaterThan);
 245         return rows;
 246     }
 247 
 248     pub fn collectLifetimes(
 249         self: *Analyzer,
 250         allocator: std.mem.Allocator,
 251         options: Options,
 252     ) !std.ArrayListUnmanaged(Lifetime) {
 253         var rows: std.ArrayListUnmanaged(Lifetime) = .empty;
 254         errdefer rows.deinit(allocator);
 255         for (self.lifetimes.items) |lifetime| {
 256             if (options.name) |wanted| {
 257                 if (!std.mem.eql(u8, wanted, lifetime.name)) continue;
 258             }
 259             try rows.append(allocator, lifetime);
 260         }
 261         std.mem.sort(Lifetime, rows.items, {}, lifetimeGreaterThan);
 262         if (rows.items.len > options.occurrences) {
 263             rows.shrinkRetainingCapacity(options.occurrences);
 264         }
 265         return rows;
 266     }
 267 
 268     pub fn durationNs(self: Analyzer) u64 {
 269         const start_ns = self.start_ns orelse return 0;
 270         const end_ns = self.end_ns orelse return 0;
 271         if (end_ns <= start_ns) return 0;
 272         return end_ns - start_ns;
 273     }
 274 
 275     pub fn captureIntegrity(self: Analyzer) CaptureIntegrity {
 276         return self.capture.integrity(0);
 277     }
 278 
 279     pub fn lifetimeEvidence(self: Analyzer) []const u8 {
 280         if (!std.mem.eql(u8, self.captureIntegrity().status, "complete")) return "partial";
 281         if (self.counters.timestamp_regressions != 0) return "partial";
 282         if (self.counters.duplicate_allocations != 0) return "partial";
 283         if (self.counters.unmatched_frees != 0) return "partial";
 284         if (self.counters.untracked_allocations != 0) return "partial";
 285         if (self.counters.size_mismatches != 0) return "partial";
 286         if (self.active.count() != 0) return "partial";
 287         return "complete";
 288     }
 289 
 290     pub fn recordFlightReport(self: *Analyzer, report_value: transport.Report) void {
 291         self.capture.recordFlightReport(report_value);
 292     }
 293 
 294     fn recordAlloc(self: *Analyzer, parsed: event.Parsed) !void {
 295         const name = parsed.name orelse "default";
 296         const summary = try self.summaryFor(name);
 297         var tracked = false;
 298         if (parsed.address != 0) {
 299             const entry = try self.active.getOrPut(self.allocator, parsed.address);
 300             if (entry.found_existing) {
 301                 self.recordDuplicate(entry.value_ptr.*);
 302             } else {
 303                 entry.value_ptr.* = .{
 304                     .name = summary.name,
 305                     .size = parsed.size,
 306                     .allocated_ns = parsed.time_ns,
 307                     .thread = parsed.thread,
 308                 };
 309                 tracked = true;
 310             }
 311         } else {
 312             self.counters.untracked_allocations +|= 1;
 313             summary.untracked_allocations +|= 1;
 314         }
 315 
 316         self.counters.allocations +|= 1;
 317         self.counters.allocated_bytes +|= parsed.size;
 318         summary.allocations +|= 1;
 319         summary.allocated_bytes +|= parsed.size;
 320         summary.max_alloc_bytes = @max(summary.max_alloc_bytes, parsed.size);
 321         summary.last_event_ns = parsed.time_ns;
 322         if (tracked) self.addLive(summary, parsed.size, parsed.time_ns);
 323     }
 324 
 325     fn recordFree(self: *Analyzer, parsed: event.Parsed) !void {
 326         if (parsed.address != 0) {
 327             if (self.active.get(parsed.address)) |record| {
 328                 try self.recordMatchedFree(parsed, record);
 329                 return;
 330             }
 331         }
 332         try self.recordUnmatchedFree(parsed);
 333     }
 334 
 335     fn recordDuplicate(self: *Analyzer, record: Record) void {
 336         const summary = self.summaries.getPtr(record.name).?;
 337         self.counters.duplicate_allocations +|= 1;
 338         summary.duplicate_allocations +|= 1;
 339     }
 340 
 341     fn addLive(self: *Analyzer, summary: *Summary, size: u64, time_ns: u64) void {
 342         self.counters.live_bytes +|= size;
 343         summary.live_bytes +|= size;
 344         summary.active_allocations +|= 1;
 345         if (self.counters.live_bytes > self.counters.high_water_live_bytes) {
 346             self.counters.high_water_live_bytes = self.counters.live_bytes;
 347             self.counters.high_water_time_ns = time_ns;
 348         }
 349         if (summary.live_bytes > summary.high_water_live_bytes) {
 350             summary.high_water_live_bytes = summary.live_bytes;
 351             summary.high_water_time_ns = time_ns;
 352         }
 353     }
 354 
 355     fn removeLive(self: *Analyzer, summary: *Summary, size: u64) void {
 356         subtractCounterLive(&self.counters, size);
 357         _ = subtractSummaryLive(summary, size);
 358         if (summary.active_allocations != 0) summary.active_allocations -= 1;
 359     }
 360 
 361     fn recordMatchedFree(self: *Analyzer, parsed: event.Parsed, record: Record) !void {
 362         const valid_time = parsed.time_ns >= record.allocated_ns;
 363         try self.lifetimes.append(self.allocator, .{
 364             .name = record.name,
 365             .address = parsed.address,
 366             .size = record.size,
 367             .reported_free_size = parsed.size,
 368             .allocation_thread = record.thread,
 369             .free_thread = parsed.thread,
 370             .allocated_ns = record.allocated_ns,
 371             .freed_ns = parsed.time_ns,
 372             .lifetime_ns = duration(record.allocated_ns, parsed.time_ns),
 373             .size_match = parsed.size == 0 or parsed.size == record.size,
 374             .valid_time = valid_time,
 375         });
 376         _ = self.active.remove(parsed.address);
 377         const summary = self.summaries.getPtr(record.name).?;
 378         self.recordLifetime(summary, record, parsed, valid_time);
 379         self.removeLive(summary, record.size);
 380     }
 381 
 382     fn recordLifetime(
 383         self: *Analyzer,
 384         summary: *Summary,
 385         record: Record,
 386         parsed: event.Parsed,
 387         valid_time: bool,
 388     ) void {
 389         self.counters.frees +|= 1;
 390         self.counters.completed_lifetimes +|= 1;
 391         self.counters.freed_bytes +|= record.size;
 392         summary.frees +|= 1;
 393         summary.completed_lifetimes +|= 1;
 394         summary.freed_bytes +|= record.size;
 395         summary.last_event_ns = parsed.time_ns;
 396         if (parsed.size != 0 and parsed.size != record.size) self.recordSizeMismatch(summary);
 397         if (valid_time) {
 398             const lifetime_ns = duration(record.allocated_ns, parsed.time_ns);
 399             self.counters.lifetime_samples +|= 1;
 400             summary.lifetime_samples +|= 1;
 401             summary.total_lifetime_ns +|= lifetime_ns;
 402             summary.max_lifetime_ns = @max(summary.max_lifetime_ns, lifetime_ns);
 403         } else {
 404             self.counters.timestamp_regressions +|= 1;
 405             summary.timestamp_regressions +|= 1;
 406         }
 407     }
 408 
 409     fn recordSizeMismatch(self: *Analyzer, summary: *Summary) void {
 410         self.counters.size_mismatches +|= 1;
 411         summary.size_mismatches +|= 1;
 412     }
 413 
 414     fn recordUnmatchedFree(self: *Analyzer, parsed: event.Parsed) !void {
 415         const summary = try self.summaryFor(parsed.name orelse "default");
 416         self.counters.frees +|= 1;
 417         self.counters.freed_bytes +|= parsed.size;
 418         summary.frees +|= 1;
 419         summary.freed_bytes +|= parsed.size;
 420         summary.last_event_ns = parsed.time_ns;
 421         if (parsed.address == 0 and parsed.size == 0) return;
 422         self.counters.unmatched_frees +|= 1;
 423         summary.unmatched_frees +|= 1;
 424     }
 425 
 426     fn summaryFor(self: *Analyzer, name: []const u8) !*Summary {
 427         if (self.summaries.getPtr(name)) |summary| return summary;
 428         const owned_name = try self.allocator.dupe(u8, name);
 429         errdefer self.allocator.free(owned_name);
 430         const entry = try self.summaries.getOrPut(self.allocator, owned_name);
 431         std.debug.assert(!entry.found_existing);
 432         entry.key_ptr.* = owned_name;
 433         entry.value_ptr.* = .{ .name = owned_name };
 434         return entry.value_ptr;
 435     }
 436 };
 437 
 438 pub fn writeTextFromJsonlPath(
 439     allocator: std.mem.Allocator,
 440     path: []const u8,
 441     writer: *std.Io.Writer,
 442     options: Options,
 443 ) !void {
 444     return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
 445 }
 446 
 447 pub fn writeJsonlFromJsonlPath(
 448     allocator: std.mem.Allocator,
 449     path: []const u8,
 450     writer: *std.Io.Writer,
 451     options: Options,
 452 ) !void {
 453     return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
 454 }
 455 
 456 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
 457     return report.ingestJsonlPath(analyzer, path);
 458 }
 459 
 460 fn writeText(
 461     allocator: std.mem.Allocator,
 462     analyzer: *Analyzer,
 463     writer: *std.Io.Writer,
 464     options: Options,
 465 ) !void {
 466     var summaries = try analyzer.collectSummaries(allocator, options);
 467     defer summaries.deinit(allocator);
 468     var lifetimes = try analyzer.collectLifetimes(allocator, options);
 469     defer lifetimes.deinit(allocator);
 470     var live = try analyzer.collectLive(allocator, options);
 471     defer live.deinit(allocator);
 472     try writeTextHeader(writer, analyzer, options, summaries.items.len);
 473     try capture_mod.writeText(writer, analyzer.captureIntegrity());
 474     const summary_limit = @min(options.top, summaries.items.len);
 475     for (summaries.items[0..summary_limit]) |summary| try writeTextMemory(writer, summary);
 476     for (lifetimes.items) |lifetime| try writeTextLifetime(writer, lifetime);
 477     const live_limit = @min(options.top, live.items.len);
 478     for (live.items[0..live_limit]) |item| try writeTextLive(writer, item);
 479 }
 480 
 481 fn writeTextHeader(
 482     writer: *std.Io.Writer,
 483     analyzer: *Analyzer,
 484     options: Options,
 485     names: usize,
 486 ) !void {
 487     try writer.print(
 488         "tracy memory names={d} allocations={d} frees={d} completed_lifetimes={d} " ++
 489             "lifetime_samples={d} right_censored_allocations={d} live_bytes={d} " ++
 490             "high_water_live_bytes={d} high_water_time_ns={d} allocated_bytes={d} " ++
 491             "freed_bytes={d} unmatched_frees={d} duplicate_allocations={d} " ++
 492             "timestamp_regressions={d} untracked_allocations={d} size_mismatches={d} " ++
 493             "lifetime_population=completed lifetime_evidence={s} duration_ns={d} sort={s}\n",
 494         .{
 495             names,
 496             analyzer.counters.allocations,
 497             analyzer.counters.frees,
 498             analyzer.counters.completed_lifetimes,
 499             analyzer.counters.lifetime_samples,
 500             analyzer.active.count(),
 501             analyzer.counters.live_bytes,
 502             analyzer.counters.high_water_live_bytes,
 503             analyzer.counters.high_water_time_ns,
 504             analyzer.counters.allocated_bytes,
 505             analyzer.counters.freed_bytes,
 506             analyzer.counters.unmatched_frees,
 507             analyzer.counters.duplicate_allocations,
 508             analyzer.counters.timestamp_regressions,
 509             analyzer.counters.untracked_allocations,
 510             analyzer.counters.size_mismatches,
 511             analyzer.lifetimeEvidence(),
 512             analyzer.durationNs(),
 513             options.sort.tag(),
 514         },
 515     );
 516 }
 517 
 518 fn writeTextMemory(writer: *std.Io.Writer, summary: Summary) !void {
 519     try writer.writeAll("memory name=");
 520     try pretty_json.writeString(writer, summary.name);
 521     try writer.print(
 522         " allocations={d} frees={d} right_censored_allocations={d} live_bytes={d} " ++
 523             "high_water_live_bytes={d} high_water_time_ns={d} allocated_bytes={d} " ++
 524             "freed_bytes={d} mean_alloc_bytes={d} max_alloc_bytes={d}",
 525         .{
 526             summary.allocations,           summary.frees,
 527             summary.active_allocations,    summary.live_bytes,
 528             summary.high_water_live_bytes, summary.high_water_time_ns,
 529             summary.allocated_bytes,       summary.freed_bytes,
 530             summary.meanAllocBytes(),      summary.max_alloc_bytes,
 531         },
 532     );
 533     try writer.print(
 534         " completed_lifetimes={d} lifetime_samples={d} lifetime_total_ns={d} " ++
 535             "lifetime_mean_ns={d} lifetime_min_ns={d} lifetime_p50_ns={d} " ++
 536             "lifetime_p90_ns={d} lifetime_p99_ns={d} lifetime_max_ns={d} " ++
 537             "unmatched_frees={d} duplicate_allocations={d} timestamp_regressions={d} " ++
 538             "untracked_allocations={d} size_mismatches={d} last_event_ns={d}\n",
 539         .{
 540             summary.completed_lifetimes,   summary.lifetime_samples,
 541             summary.total_lifetime_ns,     summary.meanLifetimeNs(),
 542             summary.min_lifetime_ns,       summary.p50_lifetime_ns,
 543             summary.p90_lifetime_ns,       summary.p99_lifetime_ns,
 544             summary.max_lifetime_ns,       summary.unmatched_frees,
 545             summary.duplicate_allocations, summary.timestamp_regressions,
 546             summary.untracked_allocations, summary.size_mismatches,
 547             summary.last_event_ns,
 548         },
 549     );
 550 }
 551 
 552 fn writeTextLifetime(writer: *std.Io.Writer, lifetime: Lifetime) !void {
 553     try writer.writeAll("lifetime name=");
 554     try pretty_json.writeString(writer, lifetime.name);
 555     try writer.print(
 556         " address={d} size={d} reported_free_size={d} size_match={} " ++
 557             "allocation_thread={d} free_thread={d} allocated_ns={d} freed_ns={d} " ++
 558             "lifetime_ns={d}",
 559         .{
 560             lifetime.address,            lifetime.size,
 561             lifetime.reported_free_size, lifetime.size_match,
 562             lifetime.allocation_thread,  lifetime.free_thread,
 563             lifetime.allocated_ns,       lifetime.freed_ns,
 564             lifetime.lifetime_ns,
 565         },
 566     );
 567     if (!lifetime.valid_time) try writer.writeAll(" valid_time=false");
 568     try writer.writeByte('\n');
 569 }
 570 
 571 fn writeTextLive(writer: *std.Io.Writer, item: LiveView) !void {
 572     try writer.writeAll("live name=");
 573     try pretty_json.writeString(writer, item.name);
 574     try writer.print(
 575         " address={d} size={d} allocated_ns={d} age_ns={d} thread={d}\n",
 576         .{ item.address, item.size, item.allocated_ns, item.age_ns, item.thread },
 577     );
 578 }
 579 
 580 fn writeJsonl(
 581     allocator: std.mem.Allocator,
 582     analyzer: *Analyzer,
 583     writer: *std.Io.Writer,
 584     options: Options,
 585 ) !void {
 586     var summaries = try analyzer.collectSummaries(allocator, options);
 587     defer summaries.deinit(allocator);
 588     var lifetimes = try analyzer.collectLifetimes(allocator, options);
 589     defer lifetimes.deinit(allocator);
 590     var live = try analyzer.collectLive(allocator, options);
 591     defer live.deinit(allocator);
 592     try writeJsonSummary(writer, analyzer, options, summaries.items.len);
 593     const summary_limit = @min(options.top, summaries.items.len);
 594     for (summaries.items[0..summary_limit]) |summary| try writeJsonMemory(writer, summary);
 595     for (lifetimes.items) |lifetime| try writeJsonLifetime(writer, lifetime);
 596     const live_limit = @min(options.top, live.items.len);
 597     for (live.items[0..live_limit]) |item| try writeJsonLive(writer, item);
 598 }
 599 
 600 fn writeJsonSummary(
 601     writer: *std.Io.Writer,
 602     analyzer: *Analyzer,
 603     options: Options,
 604     names: usize,
 605 ) !void {
 606     var stream = pretty_json.Writer.init(writer, .minified);
 607     const object = try stream.object();
 608     try object.field("schema", schema);
 609     try object.field("kind", "summary");
 610     try object.field("names", names);
 611     try object.field("allocations", analyzer.counters.allocations);
 612     try object.field("frees", analyzer.counters.frees);
 613     try object.field("completed_lifetimes", analyzer.counters.completed_lifetimes);
 614     try object.field("lifetime_samples", analyzer.counters.lifetime_samples);
 615     try object.field("right_censored_allocations", analyzer.active.count());
 616     try object.field("live_bytes", analyzer.counters.live_bytes);
 617     try object.field("high_water_live_bytes", analyzer.counters.high_water_live_bytes);
 618     try object.field("high_water_time_ns", analyzer.counters.high_water_time_ns);
 619     try object.field("allocated_bytes", analyzer.counters.allocated_bytes);
 620     try object.field("freed_bytes", analyzer.counters.freed_bytes);
 621     try object.field("unmatched_frees", analyzer.counters.unmatched_frees);
 622     try object.field("duplicate_allocations", analyzer.counters.duplicate_allocations);
 623     try object.field("timestamp_regressions", analyzer.counters.timestamp_regressions);
 624     try object.field("untracked_allocations", analyzer.counters.untracked_allocations);
 625     try object.field("size_mismatches", analyzer.counters.size_mismatches);
 626     try object.field("duration_ns", analyzer.durationNs());
 627     try object.field("lifetime_population", "completed");
 628     try object.field("lifetime_evidence", analyzer.lifetimeEvidence());
 629     try object.field("sort", options.sort.tag());
 630     try capture_mod.writeFields(object, analyzer.captureIntegrity());
 631     try object.endLine();
 632 }
 633 
 634 fn writeJsonMemory(writer: *std.Io.Writer, summary: Summary) !void {
 635     var stream = pretty_json.Writer.init(writer, .minified);
 636     const object = try stream.object();
 637     try object.field("schema", schema);
 638     try object.field("kind", "memory");
 639     try object.field("name", summary.name);
 640     try object.field("allocations", summary.allocations);
 641     try object.field("frees", summary.frees);
 642     try object.field("right_censored_allocations", summary.active_allocations);
 643     try object.field("live_bytes", summary.live_bytes);
 644     try object.field("high_water_live_bytes", summary.high_water_live_bytes);
 645     try object.field("high_water_time_ns", summary.high_water_time_ns);
 646     try object.field("allocated_bytes", summary.allocated_bytes);
 647     try object.field("freed_bytes", summary.freed_bytes);
 648     try object.field("mean_alloc_bytes", summary.meanAllocBytes());
 649     try object.field("max_alloc_bytes", summary.max_alloc_bytes);
 650     try object.field("completed_lifetimes", summary.completed_lifetimes);
 651     try object.field("lifetime_samples", summary.lifetime_samples);
 652     try object.field("lifetime_total_ns", summary.total_lifetime_ns);
 653     try object.field("lifetime_mean_ns", summary.meanLifetimeNs());
 654     try object.field("lifetime_min_ns", summary.min_lifetime_ns);
 655     try object.field("lifetime_p50_ns", summary.p50_lifetime_ns);
 656     try object.field("lifetime_p90_ns", summary.p90_lifetime_ns);
 657     try object.field("lifetime_p99_ns", summary.p99_lifetime_ns);
 658     try object.field("lifetime_max_ns", summary.max_lifetime_ns);
 659     try object.field("unmatched_frees", summary.unmatched_frees);
 660     try object.field("duplicate_allocations", summary.duplicate_allocations);
 661     try object.field("timestamp_regressions", summary.timestamp_regressions);
 662     try object.field("untracked_allocations", summary.untracked_allocations);
 663     try object.field("size_mismatches", summary.size_mismatches);
 664     try object.field("last_event_ns", summary.last_event_ns);
 665     try object.endLine();
 666 }
 667 
 668 fn writeJsonLifetime(writer: *std.Io.Writer, lifetime: Lifetime) !void {
 669     var stream = pretty_json.Writer.init(writer, .minified);
 670     const object = try stream.object();
 671     try object.field("schema", schema);
 672     try object.field("kind", "lifetime");
 673     try object.field("name", lifetime.name);
 674     try object.field("address", lifetime.address);
 675     try object.field("size", lifetime.size);
 676     try object.field("reported_free_size", lifetime.reported_free_size);
 677     try object.field("size_match", lifetime.size_match);
 678     try object.field("allocation_thread", lifetime.allocation_thread);
 679     try object.field("free_thread", lifetime.free_thread);
 680     try object.field("allocated_ns", lifetime.allocated_ns);
 681     try object.field("freed_ns", lifetime.freed_ns);
 682     try object.field("lifetime_ns", lifetime.lifetime_ns);
 683     try object.field("valid_time", lifetime.valid_time);
 684     try object.endLine();
 685 }
 686 
 687 fn writeJsonLive(writer: *std.Io.Writer, item: LiveView) !void {
 688     var stream = pretty_json.Writer.init(writer, .minified);
 689     const object = try stream.object();
 690     try object.field("schema", schema);
 691     try object.field("kind", "live");
 692     try object.field("name", item.name);
 693     try object.field("address", item.address);
 694     try object.field("size", item.size);
 695     try object.field("allocated_ns", item.allocated_ns);
 696     try object.field("age_ns", item.age_ns);
 697     try object.field("thread", item.thread);
 698     try object.endLine();
 699 }
 700 
 701 fn subtractCounterLive(counters: *Counters, size: u64) void {
 702     if (counters.live_bytes >= size) {
 703         counters.live_bytes -= size;
 704     } else {
 705         counters.live_bytes = 0;
 706     }
 707 }
 708 
 709 fn subtractSummaryLive(summary: *Summary, size: u64) u64 {
 710     const removed = @min(summary.live_bytes, size);
 711     if (summary.live_bytes >= size) {
 712         summary.live_bytes -= size;
 713     } else {
 714         summary.live_bytes = 0;
 715     }
 716     return removed;
 717 }
 718 
 719 fn duration(start_ns: u64, end_ns: u64) u64 {
 720     if (end_ns <= start_ns) return 0;
 721     return end_ns - start_ns;
 722 }
 723 
 724 fn applyLifetimeDistributions(
 725     allocator: std.mem.Allocator,
 726     rows: []Summary,
 727     lifetimes: []const Lifetime,
 728 ) !void {
 729     var row_index: std.StringHashMapUnmanaged(usize) = .{};
 730     defer row_index.deinit(allocator);
 731     for (rows, 0..) |row, index| try row_index.put(allocator, row.name, index);
 732     var samples: std.ArrayListUnmanaged(LifetimeSample) = .empty;
 733     defer samples.deinit(allocator);
 734     for (lifetimes) |lifetime| {
 735         if (!lifetime.valid_time) continue;
 736         const row = row_index.get(lifetime.name) orelse continue;
 737         try samples.append(allocator, .{ .row = row, .duration_ns = lifetime.lifetime_ns });
 738     }
 739     std.mem.sort(LifetimeSample, samples.items, {}, lifetimeSampleLessThan);
 740     var start: usize = 0;
 741     while (start < samples.items.len) {
 742         var end = start + 1;
 743         while (end < samples.items.len and samples.items[end].row == samples.items[start].row) {
 744             end += 1;
 745         }
 746         applyLifetimeDistribution(&rows[samples.items[start].row], samples.items[start..end]);
 747         start = end;
 748     }
 749 }
 750 
 751 fn applyLifetimeDistribution(summary: *Summary, samples: []const LifetimeSample) void {
 752     std.debug.assert(samples.len > 0);
 753     std.debug.assert(summary.lifetime_samples == samples.len);
 754     summary.min_lifetime_ns = lifetimePercentile(samples, 0);
 755     summary.p50_lifetime_ns = lifetimePercentile(samples, 50);
 756     summary.p90_lifetime_ns = lifetimePercentile(samples, 90);
 757     summary.p99_lifetime_ns = lifetimePercentile(samples, 99);
 758     summary.max_lifetime_ns = lifetimePercentile(samples, 100);
 759 }
 760 
 761 fn lifetimePercentile(samples: []const LifetimeSample, percent: u64) u64 {
 762     std.debug.assert(samples.len > 0);
 763     const rank: usize = @intCast((@as(u128, @min(percent, 100)) * samples.len + 99) / 100);
 764     const index = @min(@max(rank, 1) - 1, samples.len - 1);
 765     return samples[index].duration_ns;
 766 }
 767 
 768 fn lifetimeSampleLessThan(_: void, left: LifetimeSample, right: LifetimeSample) bool {
 769     if (left.row != right.row) return left.row < right.row;
 770     return left.duration_ns < right.duration_ns;
 771 }
 772 
 773 fn sortSummaries(items: []Summary, sort: Sort) void {
 774     std.mem.sort(Summary, items, sort, summaryLessThan);
 775 }
 776 
 777 fn summaryLessThan(sort: Sort, left: Summary, right: Summary) bool {
 778     return switch (sort) {
 779         .live => summaryLiveGreaterThan({}, left, right),
 780         .high_water => summaryHighGreaterThan({}, left, right),
 781         .allocated => summaryAllocatedGreaterThan({}, left, right),
 782         .lifetime_tail => summaryLifetimeGreaterThan({}, left, right),
 783         .allocs => summaryAllocsGreaterThan({}, left, right),
 784         .frees => summaryFreesGreaterThan({}, left, right),
 785         .name => summaryNameLessThan({}, left, right),
 786     };
 787 }
 788 
 789 fn summaryLiveGreaterThan(_: void, left: Summary, right: Summary) bool {
 790     if (left.live_bytes != right.live_bytes) return left.live_bytes > right.live_bytes;
 791     return summaryHighGreaterThan({}, left, right);
 792 }
 793 
 794 fn summaryHighGreaterThan(_: void, left: Summary, right: Summary) bool {
 795     if (left.high_water_live_bytes != right.high_water_live_bytes) return left.high_water_live_bytes > right.high_water_live_bytes;
 796     return summaryNameLessThan({}, left, right);
 797 }
 798 
 799 fn summaryAllocatedGreaterThan(_: void, left: Summary, right: Summary) bool {
 800     if (left.allocated_bytes != right.allocated_bytes) return left.allocated_bytes > right.allocated_bytes;
 801     return summaryNameLessThan({}, left, right);
 802 }
 803 
 804 fn summaryLifetimeGreaterThan(_: void, left: Summary, right: Summary) bool {
 805     if (left.p99_lifetime_ns != right.p99_lifetime_ns) {
 806         return left.p99_lifetime_ns > right.p99_lifetime_ns;
 807     }
 808     if (left.max_lifetime_ns != right.max_lifetime_ns) {
 809         return left.max_lifetime_ns > right.max_lifetime_ns;
 810     }
 811     return summaryNameLessThan({}, left, right);
 812 }
 813 
 814 fn summaryAllocsGreaterThan(_: void, left: Summary, right: Summary) bool {
 815     if (left.allocations != right.allocations) return left.allocations > right.allocations;
 816     return summaryAllocatedGreaterThan({}, left, right);
 817 }
 818 
 819 fn summaryFreesGreaterThan(_: void, left: Summary, right: Summary) bool {
 820     if (left.frees != right.frees) return left.frees > right.frees;
 821     return summaryAllocatedGreaterThan({}, left, right);
 822 }
 823 
 824 fn summaryNameLessThan(_: void, left: Summary, right: Summary) bool {
 825     return std.mem.lessThan(u8, left.name, right.name);
 826 }
 827 
 828 fn liveGreaterThan(_: void, left: LiveView, right: LiveView) bool {
 829     if (left.size != right.size) return left.size > right.size;
 830     const name_cmp = std.mem.order(u8, left.name, right.name);
 831     if (name_cmp != .eq) return name_cmp == .lt;
 832     return left.address < right.address;
 833 }
 834 
 835 fn lifetimeGreaterThan(_: void, left: Lifetime, right: Lifetime) bool {
 836     if (left.lifetime_ns != right.lifetime_ns) return left.lifetime_ns > right.lifetime_ns;
 837     if (left.valid_time != right.valid_time) return left.valid_time;
 838     const name_cmp = std.mem.order(u8, left.name, right.name);
 839     if (name_cmp != .eq) return name_cmp == .lt;
 840     return left.address < right.address;
 841 }
 842 
 843 test "memory tracks live allocations and high water by name" {
 844     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 845     defer trace.deinit();
 846     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
 847     try (event.TraceEvent{ .seq = 2, .kind = .alloc, .time_ns = 100, .thread = 1, .name = "arena", .address = 4096, .size = 64 }).writeJsonLine(&trace.writer);
 848     try (event.TraceEvent{ .seq = 3, .kind = .alloc, .time_ns = 110, .thread = 2, .name = "arena", .address = 8192, .size = 32 }).writeJsonLine(&trace.writer);
 849     try (event.TraceEvent{ .seq = 4, .kind = .free, .time_ns = 120, .thread = 1, .name = "arena", .address = 4096 }).writeJsonLine(&trace.writer);
 850     try (event.TraceEvent{ .seq = 5, .kind = .free, .time_ns = 130, .thread = 1, .name = "external", .address = 12288, .size = 16 }).writeJsonLine(&trace.writer);
 851     try (event.TraceEvent{ .seq = 6, .kind = .free, .time_ns = 140, .thread = 1, .name = "arena", .address = 16384 }).writeJsonLine(&trace.writer);
 852     try (event.TraceEvent{ .seq = 7, .kind = .stop, .time_ns = 160, .thread = 1 }).writeJsonLine(&trace.writer);
 853 
 854     var analyzer = Analyzer.init(std.testing.allocator);
 855     defer analyzer.deinit();
 856     try analyzer.ingestJsonlBytes(trace.written());
 857     try std.testing.expectEqual(@as(u64, 2), analyzer.counters.allocations);
 858     try std.testing.expectEqual(@as(u64, 3), analyzer.counters.frees);
 859     try std.testing.expectEqual(@as(u64, 2), analyzer.counters.unmatched_frees);
 860     try std.testing.expectEqual(@as(u64, 96), analyzer.counters.allocated_bytes);
 861     try std.testing.expectEqual(@as(u64, 80), analyzer.counters.freed_bytes);
 862     try std.testing.expectEqual(@as(u64, 32), analyzer.counters.live_bytes);
 863     try std.testing.expectEqual(@as(u64, 96), analyzer.counters.high_water_live_bytes);
 864     try std.testing.expectEqual(@as(u64, 110), analyzer.counters.high_water_time_ns);
 865     try std.testing.expectEqual(@as(usize, 1), analyzer.active.count());
 866 
 867     const arena = analyzer.summaries.get("arena").?;
 868     try std.testing.expectEqual(@as(u64, 32), arena.live_bytes);
 869     try std.testing.expectEqual(@as(u64, 96), arena.high_water_live_bytes);
 870     try std.testing.expectEqual(@as(u64, 1), arena.unmatched_frees);
 871     try std.testing.expectEqual(@as(u64, 48), arena.meanAllocBytes());
 872 
 873     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 874     defer out.deinit();
 875     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4 });
 876     const text = out.written();
 877     try std.testing.expect(std.mem.indexOf(
 878         u8,
 879         text,
 880         "completed_lifetimes=1 lifetime_samples=1",
 881     ) != null);
 882     try std.testing.expect(std.mem.indexOf(
 883         u8,
 884         text,
 885         "right_censored_allocations=1 live_bytes=32",
 886     ) != null);
 887     try std.testing.expect(std.mem.indexOf(u8, text, "live name=\"arena\" address=8192 size=32 allocated_ns=110 age_ns=50 thread=2") != null);
 888 }
 889 
 890 test "memory jsonl filters names and live rows" {
 891     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 892     defer trace.deinit();
 893     try (event.TraceEvent{ .seq = 1, .kind = .alloc, .time_ns = 100, .thread = 1, .name = "arena", .address = 4096, .size = 64 }).writeJsonLine(&trace.writer);
 894     try (event.TraceEvent{ .seq = 2, .kind = .alloc, .time_ns = 110, .thread = 1, .name = "scratch", .address = 8192, .size = 8 }).writeJsonLine(&trace.writer);
 895     try (event.TraceEvent{ .seq = 3, .kind = .stop, .time_ns = 150, .thread = 1 }).writeJsonLine(&trace.writer);
 896 
 897     var analyzer = Analyzer.init(std.testing.allocator);
 898     defer analyzer.deinit();
 899     try analyzer.ingestJsonlBytes(trace.written());
 900 
 901     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 902     defer out.deinit();
 903     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .name = "arena", .min_live_bytes = 32 });
 904     const text = out.written();
 905     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.memory/v1\"") != null);
 906     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"memory\"") != null);
 907     try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"arena\"") != null);
 908     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"live\"") != null);
 909     try std.testing.expect(std.mem.indexOf(u8, text, "\"address\":4096") != null);
 910     try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"scratch\"") == null);
 911 }
 912 
 913 test "memory handles duplicate allocation addresses as anomalies" {
 914     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 915     defer trace.deinit();
 916     try (event.TraceEvent{ .seq = 1, .kind = .alloc, .time_ns = 100, .thread = 1, .name = "arena", .address = 4096, .size = 64 }).writeJsonLine(&trace.writer);
 917     try (event.TraceEvent{ .seq = 2, .kind = .alloc, .time_ns = 110, .thread = 1, .name = "arena", .address = 4096, .size = 16 }).writeJsonLine(&trace.writer);
 918 
 919     var analyzer = Analyzer.init(std.testing.allocator);
 920     defer analyzer.deinit();
 921     try analyzer.ingestJsonlBytes(trace.written());
 922     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.duplicate_allocations);
 923     try std.testing.expectEqual(@as(u64, 64), analyzer.counters.live_bytes);
 924     try std.testing.expectEqual(@as(usize, 1), analyzer.active.count());
 925 }
 926 
 927 test "memory preserves completed lifetime distributions and worst occurrences" {
 928     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 929     defer trace.deinit();
 930     var seq: u64 = 1;
 931     try (event.TraceEvent{ .seq = seq, .kind = .start, .time_ns = 1 })
 932         .writeJsonLine(&trace.writer);
 933     seq += 1;
 934     var time_ns: u64 = 100;
 935     for ([_]u64{ 10, 20, 30, 40, 100 }, 0..) |lifetime_ns, index| {
 936         try appendLifetimePair(
 937             &trace.writer,
 938             &seq,
 939             "arena",
 940             @intCast(index + 1),
 941             time_ns,
 942             lifetime_ns,
 943             16,
 944         );
 945         time_ns += lifetime_ns + 10;
 946     }
 947     try appendLifetimePair(&trace.writer, &seq, "cache", 20, time_ns, 200, 64);
 948     try (event.TraceEvent{ .seq = seq, .kind = .stop, .time_ns = time_ns + 210 })
 949         .writeJsonLine(&trace.writer);
 950 
 951     var analyzer = Analyzer.init(std.testing.allocator);
 952     defer analyzer.deinit();
 953     try analyzer.ingestJsonlBytes(trace.written());
 954     try std.testing.expectEqual(@as(u64, 6), analyzer.counters.completed_lifetimes);
 955     try std.testing.expectEqual(@as(u64, 6), analyzer.counters.lifetime_samples);
 956     try std.testing.expectEqualStrings("complete", analyzer.lifetimeEvidence());
 957 
 958     var rows = try analyzer.collectSummaries(
 959         std.testing.allocator,
 960         .{ .sort = .lifetime_tail },
 961     );
 962     defer rows.deinit(std.testing.allocator);
 963     try std.testing.expectEqualStrings("cache", rows.items[0].name);
 964     const arena = rows.items[1];
 965     try std.testing.expectEqual(@as(u64, 5), arena.lifetime_samples);
 966     try std.testing.expectEqual(@as(u64, 200), arena.total_lifetime_ns);
 967     try std.testing.expectEqual(@as(u64, 40), arena.meanLifetimeNs());
 968     try std.testing.expectEqual(@as(u64, 10), arena.min_lifetime_ns);
 969     try std.testing.expectEqual(@as(u64, 30), arena.p50_lifetime_ns);
 970     try std.testing.expectEqual(@as(u64, 100), arena.p90_lifetime_ns);
 971     try std.testing.expectEqual(@as(u64, 100), arena.p99_lifetime_ns);
 972     try std.testing.expectEqual(@as(u64, 100), arena.max_lifetime_ns);
 973 
 974     var lifetimes = try analyzer.collectLifetimes(
 975         std.testing.allocator,
 976         .{ .occurrences = 2 },
 977     );
 978     defer lifetimes.deinit(std.testing.allocator);
 979     try std.testing.expectEqual(@as(usize, 2), lifetimes.items.len);
 980     try std.testing.expectEqual(@as(u64, 200), lifetimes.items[0].lifetime_ns);
 981     try std.testing.expectEqual(@as(u64, 100), lifetimes.items[1].lifetime_ns);
 982 }
 983 
 984 test "memory separates censored allocations and invalid lifetime evidence" {
 985     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 986     defer trace.deinit();
 987     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 1 })
 988         .writeJsonLine(&trace.writer);
 989     try appendMemoryEvent(&trace.writer, 2, .alloc, 100, 1, 64);
 990     try appendMemoryEvent(&trace.writer, 3, .alloc, 110, 1, 16);
 991     try appendMemoryEvent(&trace.writer, 4, .free, 90, 1, 32);
 992     try appendMemoryEvent(&trace.writer, 5, .alloc, 120, 2, 32);
 993     try appendMemoryEvent(&trace.writer, 6, .alloc, 130, 0, 8);
 994     try appendMemoryEvent(&trace.writer, 7, .free, 140, 3, 4);
 995     try (event.TraceEvent{ .seq = 8, .kind = .stop, .time_ns = 150 })
 996         .writeJsonLine(&trace.writer);
 997 
 998     var analyzer = Analyzer.init(std.testing.allocator);
 999     defer analyzer.deinit();
1000     try analyzer.ingestJsonlBytes(trace.written());
1001     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.completed_lifetimes);
1002     try std.testing.expectEqual(@as(u64, 0), analyzer.counters.lifetime_samples);
1003     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.duplicate_allocations);
1004     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.timestamp_regressions);
1005     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.untracked_allocations);
1006     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.size_mismatches);
1007     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.unmatched_frees);
1008     try std.testing.expectEqual(@as(u64, 32), analyzer.counters.live_bytes);
1009     try std.testing.expectEqual(@as(usize, 1), analyzer.active.count());
1010     try std.testing.expectEqualStrings("partial", analyzer.lifetimeEvidence());
1011 
1012     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1013     defer out.deinit();
1014     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{});
1015     try expectMemoryJsonl(out.written());
1016     try expectMemoryContains(out.written(), "\"right_censored_allocations\":1");
1017     try expectMemoryContains(out.written(), "\"lifetime_evidence\":\"partial\"");
1018     try expectMemoryContains(out.written(), "\"size_match\":false");
1019     try expectMemoryContains(out.written(), "\"valid_time\":false");
1020 }
1021 
1022 test "memory retains flight reports and sequence gaps" {
1023     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1024     defer trace.deinit();
1025     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 1 })
1026         .writeJsonLine(&trace.writer);
1027     try (event.TraceEvent{ .seq = 3, .kind = .alloc, .time_ns = 10, .address = 1, .size = 8 })
1028         .writeJsonLine(&trace.writer);
1029     try (event.TraceEvent{ .seq = 4, .kind = .free, .time_ns = 20, .address = 1 })
1030         .writeJsonLine(&trace.writer);
1031     try (event.TraceEvent{ .seq = 5, .kind = .stop, .time_ns = 30 })
1032         .writeJsonLine(&trace.writer);
1033     const flight_report = memoryTestFlightReport();
1034     try flight_report.writeJsonl(&trace.writer);
1035 
1036     var analyzer = Analyzer.init(std.testing.allocator);
1037     defer analyzer.deinit();
1038     try analyzer.ingestJsonlBytes(trace.written());
1039     const integrity = analyzer.captureIntegrity();
1040     try std.testing.expectEqualStrings("sequence_gaps", integrity.status);
1041     try std.testing.expectEqualDeep(flight_report, integrity.flight_report.?);
1042     try std.testing.expectEqualStrings("partial", analyzer.lifetimeEvidence());
1043 }
1044 
1045 test "memory releases lifetime evidence on allocation failure" {
1046     try std.testing.checkAllAllocationFailures(
1047         std.testing.allocator,
1048         analyzeMemoryLifetimes,
1049         .{},
1050     );
1051 }
1052 
1053 fn analyzeMemoryLifetimes(allocator: std.mem.Allocator) !void {
1054     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1055     defer trace.deinit();
1056     var seq: u64 = 1;
1057     try (event.TraceEvent{ .seq = seq, .kind = .start, .time_ns = 1 })
1058         .writeJsonLine(&trace.writer);
1059     seq += 1;
1060     try appendLifetimePair(&trace.writer, &seq, "arena", 1, 10, 20, 64);
1061     try appendLifetimePair(&trace.writer, &seq, "arena", 2, 40, 30, 32);
1062     try (event.TraceEvent{ .seq = seq, .kind = .stop, .time_ns = 80 })
1063         .writeJsonLine(&trace.writer);
1064     try memoryTestFlightReport().writeJsonl(&trace.writer);
1065 
1066     var analyzer = Analyzer.init(allocator);
1067     defer analyzer.deinit();
1068     try analyzer.ingestJsonlBytes(trace.written());
1069     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1070     defer out.deinit();
1071     try writeJsonl(allocator, &analyzer, &out.writer, .{ .sort = .lifetime_tail });
1072 }
1073 
1074 fn appendLifetimePair(
1075     writer: *std.Io.Writer,
1076     seq: *u64,
1077     name: []const u8,
1078     address: u64,
1079     allocated_ns: u64,
1080     lifetime_ns: u64,
1081     size: u64,
1082 ) !void {
1083     try (event.TraceEvent{
1084         .seq = seq.*,
1085         .kind = .alloc,
1086         .time_ns = allocated_ns,
1087         .thread = 1,
1088         .name = name,
1089         .address = address,
1090         .size = size,
1091     }).writeJsonLine(writer);
1092     seq.* += 1;
1093     try (event.TraceEvent{
1094         .seq = seq.*,
1095         .kind = .free,
1096         .time_ns = allocated_ns + lifetime_ns,
1097         .thread = 2,
1098         .name = name,
1099         .address = address,
1100         .size = size,
1101     }).writeJsonLine(writer);
1102     seq.* += 1;
1103 }
1104 
1105 fn appendMemoryEvent(
1106     writer: *std.Io.Writer,
1107     seq: u64,
1108     kind: event.Kind,
1109     time_ns: u64,
1110     address: u64,
1111     size: u64,
1112 ) !void {
1113     try (event.TraceEvent{
1114         .seq = seq,
1115         .kind = kind,
1116         .time_ns = time_ns,
1117         .thread = if (kind == .alloc) 1 else 2,
1118         .name = "arena",
1119         .address = address,
1120         .size = size,
1121     }).writeJsonLine(writer);
1122 }
1123 
1124 fn memoryTestFlightReport() transport.Report {
1125     return .{
1126         .policy = .overwrite_oldest,
1127         .state = .accepting,
1128         .capacity_bytes = 64,
1129         .retained_bytes = 32,
1130         .event_capacity_bytes = 16,
1131         .writer_capacity_bytes = 8,
1132         .observed_events = 5,
1133         .stored_events = 5,
1134         .retained_events = 4,
1135         .overwritten_events = 1,
1136         .dropped_events = 0,
1137         .oversized_events = 0,
1138         .partial_event_bytes = 0,
1139         .discarding_oversized_event = false,
1140     };
1141 }
1142 
1143 fn expectMemoryContains(haystack: []const u8, needle: []const u8) !void {
1144     try std.testing.expect(std.mem.indexOf(u8, haystack, needle) != null);
1145 }
1146 
1147 fn expectMemoryJsonl(bytes: []const u8) !void {
1148     var lines = std.mem.splitScalar(u8, bytes, '\n');
1149     while (lines.next()) |line| {
1150         if (line.len == 0) continue;
1151         var parsed = try std.json.parseFromSlice(
1152             std.json.Value,
1153             std.testing.allocator,
1154             line,
1155             .{},
1156         );
1157         parsed.deinit();
1158     }
1159 }