Skip to content

Add macOS dashboard and persistent IP enrichment cache#11

Merged
linyows merged 4 commits into
mainfrom
add-dashboard-and-enrich-cache
Apr 16, 2026
Merged

Add macOS dashboard and persistent IP enrichment cache#11
linyows merged 4 commits into
mainfrom
add-dashboard-and-enrich-cache

Conversation

@linyows

@linyows linyows commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • macOS dashboard with DMARC/MTA-STS split columns, donut charts for report sources, per-domain DKIM/SPF/TLS pass-fail bars, and disposition/policy/failure distributions (Swift Charts)
  • Persistent IP enrichment cache at `{data_dir}/.enrich_cache.jsonl`: PTR/ASN/country results are cached for 30 days and resolved in parallel after every fetch, eliminating redundant DNS work
  • Responsive detail view: moved below the list (40% window height), loads async with a spinner, and enrichments populate progressively so row clicks feel instant

Why

  • The prior detail view called `enrichIP()` synchronously inside its `body`, blocking the main thread for every DNS lookup. Users reported row selection being slow with no spinner.
  • CLI and app both re-resolved the same IPs from scratch on every run. A shared persistent cache fixes this for both.
  • A dashboard gives operators an at-a-glance view of authentication health (by domain) and report coverage without clicking through individual reports.

Architecture

Enrichment cache (`src/enrichcache.zig`)

  • JSONL format: append-only writes keep per-update cost O(1)
  • Automatic compaction when duplicate lines exceed unique entries
  • Thread-safe via internal mutex; parallel DNS workers (same pool pattern as IMAP fetch) populate it concurrently
  • 30-day TTL per entry

Dashboard (`macos/Reports/Views/DashboardView.swift`)

  • Two-column layout: DMARC (left) / MTA-STS (right)
  • Aggregates loaded once on dashboard open, yielding every 25 reports to keep UI responsive
  • Uses Swift Charts `SectorMark` (donuts) and stacked `BarMark`

Test plan

  • `zig build test` passes (includes 5 new enrichcache tests)
  • `reports fetch` creates `~/.local/share/reports/.enrich_cache.jsonl` and subsequent runs report N cached / M new
  • macOS app dashboard renders with real data: donut charts, per-domain bars, legends
  • Clicking a row shows spinner immediately, detail renders, IP columns fill progressively
  • Closing detail with the X button clears selection without switching views
  • Sidebar Dashboard/All/DMARC/TLS-RPT icons and selection states look correct

🤖 Generated with Claude Code

linyows and others added 3 commits April 16, 2026 00:49
Cache PTR/ASN/country lookups to {data_dir}/.enrich_cache.jsonl so
repeated fetches and CLI/app views skip redundant DNS work. Entries
expire after 30 days.

- JSONL append-only writes keep per-update cost O(1)
- Automatic compaction when duplicate lines exceed unique entries
- Thread-safe via internal mutex; parallel DNS workers can update concurrently
- fetch command and C ABI reports_fetch run enrichment across all
  stored source IPs in parallel after IMAP fetch completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New DashboardView split into DMARC (left) and MTA-STS (right) columns
  with org donut charts, per-domain DKIM/SPF/TLS pass/fail bars, and
  disposition/policy/failure type distributions (Swift Charts)
- Sidebar reorganized: Dashboard / All / DMARC / TLS-RPT as a single
  group with matching SF Symbol icons
- Detail view moved below the list (40% of window height) with an inline
  close button; 3-column layout reduced to 2 columns
- Detail loading and IP enrichment now run asynchronously off the main
  thread with a progress spinner; enrichments populate progressively
  from the ViewModel-shared dictionary so row clicks feel instant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix TOCTOU race in reports_enrich_ip by holding g_cache_mu across cache
  access, preventing use-after-free if reports_deinit runs concurrently.
- Clean up orphaned .enrich_cache.jsonl.tmp files on Cache.init so a
  mid-compaction crash doesn't leave stale artifacts.
- Propagate appendLine errors from Cache.put and roll back the in-memory
  map entry on failure, keeping disk and memory consistent and surfacing
  disk-full / permission errors instead of silently dropping them.
- Replace fragile .task(id: entries.count) trigger in DashboardView with
  entriesVersion counter that increments on every loadReports, so the
  dashboard reloads even when contents change without affecting count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new macOS “Dashboard” view and introduces a persistent, shared IP enrichment cache to eliminate repeated DNS/ASN lookups and improve UI responsiveness during report detail viewing.

Changes:

  • Added a persistent JSONL-based IP enrichment cache with TTL, compaction, and parallel enrichment workers.
  • Updated CLI and C-ABI fetch paths to collect unique source IPs and enrich them in parallel post-fetch.
  • Added a macOS Dashboard screen and reworked the detail UI to load asynchronously with progressive enrichment.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/root.zig Exposes the new enrichcache module in the root imports.
src/main.zig After CLI fetch, collects unique IPs and runs parallel enrichment with progress output.
src/lib.zig Adds a lazily initialized global enrichment cache shared by reports_fetch and reports_enrich_ip.
src/fetch.zig Adds utilities to scan stored reports and collect unique source IPs.
src/enrichcache.zig Implements persistent JSONL cache, TTL logic, compaction, parallel enrichment, and tests.
macos/Reports/Views/SidebarView.swift Adds a Dashboard navigation option and updates selection logic when Dashboard is active.
macos/Reports/Views/ReportDetailView.swift Moves to async loading with spinner, uses view-model enrichments instead of synchronous enrichment in body.
macos/Reports/Views/DashboardView.swift New dashboard with aggregated DMARC/TLS-RPT charts and per-domain summaries.
macos/Reports/Views/ContentView.swift Shows Dashboard in detail column; when a row is selected, places detail below list with responsive sizing.
macos/Reports/ReportsViewModel.swift Adds dashboard state, async detail loading, and background enrichment publishing results progressively.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main.zig Outdated

// After all accounts fetched, enrich every unique source IP in parallel
// using the same worker-pool approach as IMAP fetch. Results are cached
// persistently at {data_dir}/.enrich_cache.json for future runs.

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says the cache is persisted at {data_dir}/.enrich_cache.json, but the implementation writes .enrich_cache.jsonl (JSON Lines). Update the comment to match the actual filename/format so users can find the cache file.

Suggested change
// persistently at {data_dir}/.enrich_cache.json for future runs.
// persistently at {data_dir}/.enrich_cache.jsonl (JSON Lines) for future runs.

Copilot uses AI. Check for mistakes.
Comment thread src/enrichcache.zig Outdated
Comment on lines +141 to +143
const file = std.fs.createFileAbsolute(self.path, .{ .truncate = false }) catch return;
defer file.close();
file.seekFromEnd(0) catch {};

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appendLine swallows file-open/seek errors (catch return / catch {}), so put() can report success while failing to persist to disk (and without rolling back the in-memory insert). Since put() relies on appendLine error propagation for consistency, these errors should be returned (use try for createFileAbsolute and seekFromEnd).

Suggested change
const file = std.fs.createFileAbsolute(self.path, .{ .truncate = false }) catch return;
defer file.close();
file.seekFromEnd(0) catch {};
const file = try std.fs.createFileAbsolute(self.path, .{ .truncate = false });
defer file.close();
try file.seekFromEnd(0);

Copilot uses AI. Check for mistakes.
Comment thread src/enrichcache.zig
Comment on lines +199 to +218
const entry = Entry{
.ptr = dupStrField(self.allocator, obj.get("ptr")) catch continue,
.asn = dupStrField(self.allocator, obj.get("asn")) catch continue,
.asn_org = dupStrField(self.allocator, obj.get("asn_org")) catch continue,
.country = dupStrField(self.allocator, obj.get("country")) catch continue,
.ts = tsField(obj.get("ts")),
};

// Later lines overwrite earlier ones (JSONL "last wins")
if (self.map.fetchRemove(ip)) |old| {
self.allocator.free(old.key);
freeEntryFields(self.allocator, old.value);
}
const ip_copy = self.allocator.dupe(u8, ip) catch {
freeEntryFields(self.allocator, entry);
continue;
};
self.map.put(ip_copy, entry) catch {
self.allocator.free(ip_copy);
freeEntryFields(self.allocator, entry);

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In loadFromDisk, constructing entry with multiple dupStrField(... ) catch continue can leak earlier-allocated fields if a later field allocation fails (e.g., OOM), because the partially built Entry is abandoned without freeing. Consider building the entry with errdefer (or allocating fields into temporaries and freeing on failure) before assigning into the map.

Suggested change
const entry = Entry{
.ptr = dupStrField(self.allocator, obj.get("ptr")) catch continue,
.asn = dupStrField(self.allocator, obj.get("asn")) catch continue,
.asn_org = dupStrField(self.allocator, obj.get("asn_org")) catch continue,
.country = dupStrField(self.allocator, obj.get("country")) catch continue,
.ts = tsField(obj.get("ts")),
};
// Later lines overwrite earlier ones (JSONL "last wins")
if (self.map.fetchRemove(ip)) |old| {
self.allocator.free(old.key);
freeEntryFields(self.allocator, old.value);
}
const ip_copy = self.allocator.dupe(u8, ip) catch {
freeEntryFields(self.allocator, entry);
continue;
};
self.map.put(ip_copy, entry) catch {
self.allocator.free(ip_copy);
freeEntryFields(self.allocator, entry);
var entry = Entry{
.ptr = "",
.asn = "",
.asn_org = "",
.country = "",
.ts = tsField(obj.get("ts")),
};
errdefer freeEntryFields(self.allocator, entry);
entry.ptr = dupStrField(self.allocator, obj.get("ptr")) catch continue;
entry.asn = dupStrField(self.allocator, obj.get("asn")) catch continue;
entry.asn_org = dupStrField(self.allocator, obj.get("asn_org")) catch continue;
entry.country = dupStrField(self.allocator, obj.get("country")) catch continue;
const final_entry = entry;
errdefer comptime unreachable;
// Later lines overwrite earlier ones (JSONL "last wins")
if (self.map.fetchRemove(ip)) |old| {
self.allocator.free(old.key);
freeEntryFields(self.allocator, old.value);
}
const ip_copy = self.allocator.dupe(u8, ip) catch {
freeEntryFields(self.allocator, final_entry);
continue;
};
self.map.put(ip_copy, final_entry) catch {
self.allocator.free(ip_copy);
freeEntryFields(self.allocator, final_entry);

Copilot uses AI. Check for mistakes.
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.background.secondary)

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.background(.background.secondary) is likely not a valid ShapeStyle/Color combination and may fail to compile (there’s no other usage of .background.secondary in the project). Use a concrete color/material for the header background (e.g., a system control/window background color or a material) that matches the app’s styling.

Suggested change
.background(.background.secondary)
.background(Color(nsColor: .controlBackgroundColor))

Copilot uses AI. Check for mistakes.
Comment on lines +77 to 84
func selectDashboard() {
filterType = nil
filterAccount = nil
filterDomain = nil
selectedEntryID = nil
detailJSON = nil
showDashboard = true
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectDashboard() clears selection and detailJSON but doesn’t cancel detailTask / enrichmentTask or reset isLoadingDetail. If the user switches to the Dashboard while a detail load/enrichment is running, background work will keep running and may mutate state unexpectedly. Consider cancelling the in-flight tasks (or calling closeDetail()) when entering Dashboard mode.

Copilot uses AI. Check for mistakes.
Comment thread src/main.zig
Comment on lines +144 to +152
const Spawner = struct {
fn run(c: *reports.enrichcache.Cache, a: std.mem.Allocator, batch: []const []const u8, p: *std.atomic.Value(usize)) void {
reports.enrichcache.enrichParallel(c, a, batch, p);
}
};

const thread = std.Thread.spawn(.{}, Spawner.run, .{ &cache, allocator, ips, &progress }) catch {
reports.enrichcache.enrichParallel(&cache, allocator, ips, &progress);
return;

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enrichParallel spawns multiple threads that call ipinfo.lookup(ctx.allocator, ...). In the CLI this allocator is a GeneralPurposeAllocator (see main()), which is not thread-safe; this can cause data races/UB during parallel enrichment. Use a thread-safe allocator for the worker lookups (e.g., std.heap.c_allocator or a std.heap.ThreadSafeAllocator wrapper) and keep the non-thread-safe allocator for the single-threaded parts (collecting IPs, etc.).

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +133
var dmarcOrgs: [String: Int] = [:]
var tlsrptOrgs: [String: Int] = [:]

// Per-domain DMARC aggregates
var dkimPass: [String: UInt64] = [:]
var dkimFail: [String: UInt64] = [:]
var spfPass: [String: UInt64] = [:]
var spfFail: [String: UInt64] = [:]
var dispositions: [String: UInt64] = [:]

// Per-domain TLS aggregates
var tlsSuccess: [String: UInt64] = [:]
var tlsFailure: [String: UInt64] = [:]
var tlsPolicyTypes: [String: UInt64] = [:]
var tlsFailureTypes: [String: UInt64] = [:]

let core = ReportsCore.shared
let decoder = JSONDecoder()

for (index, entry) in entries.enumerated() {
switch entry.type {
case .dmarc:
dmarcOrgs[entry.org, default: 0] += 1
if let json = try? core.show(type: .dmarc, account: entry.account, filename: entry.filename),
let data = json.data(using: .utf8),
let detail = try? decoder.decode(DmarcDetail.self, from: data) {
let domain = detail.policy.domain.isEmpty ? entry.domain : detail.policy.domain
for rec in detail.records {
if rec.dkim_eval == "pass" {
dkimPass[domain, default: 0] += rec.count
} else {
dkimFail[domain, default: 0] += rec.count
}
if rec.spf_eval == "pass" {
spfPass[domain, default: 0] += rec.count
} else {
spfFail[domain, default: 0] += rec.count
}
let disp = rec.disposition.isEmpty ? "none" : rec.disposition
dispositions[disp, default: 0] += rec.count
}
}
case .tlsrpt:
tlsrptOrgs[entry.org, default: 0] += 1
if let json = try? core.show(type: .tlsrpt, account: entry.account, filename: entry.filename),
let data = json.data(using: .utf8),
let detail = try? decoder.decode(TlsDetail.self, from: data) {
for policy in detail.policies {
let domain = policy.policy_domain.isEmpty ? entry.domain : policy.policy_domain
let type = policy.policy_type.isEmpty ? "unknown" : policy.policy_type
tlsPolicyTypes[type, default: 0] += policy.total_successful + policy.total_failure
tlsSuccess[domain, default: 0] += policy.total_successful
tlsFailure[domain, default: 0] += policy.total_failure
for f in policy.failures {
let ft = f.result_type.isEmpty ? "unknown" : f.result_type
tlsFailureTypes[ft, default: 0] += f.failed_session_count
}
}
}
}

if index % 25 == 0 {
await Task.yield()
}
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DashboardStatsLoader.load runs on @MainActor but does synchronous I/O/decoding per entry (core.show(...) + JSON decode) inside the loop. Even with Task.yield(), this can still block the main thread and make the dashboard feel janky on large datasets. Move the aggregation work off the main actor (e.g., compute in a detached task / background queue) and then publish stats back on MainActor.

Suggested change
var dmarcOrgs: [String: Int] = [:]
var tlsrptOrgs: [String: Int] = [:]
// Per-domain DMARC aggregates
var dkimPass: [String: UInt64] = [:]
var dkimFail: [String: UInt64] = [:]
var spfPass: [String: UInt64] = [:]
var spfFail: [String: UInt64] = [:]
var dispositions: [String: UInt64] = [:]
// Per-domain TLS aggregates
var tlsSuccess: [String: UInt64] = [:]
var tlsFailure: [String: UInt64] = [:]
var tlsPolicyTypes: [String: UInt64] = [:]
var tlsFailureTypes: [String: UInt64] = [:]
let core = ReportsCore.shared
let decoder = JSONDecoder()
for (index, entry) in entries.enumerated() {
switch entry.type {
case .dmarc:
dmarcOrgs[entry.org, default: 0] += 1
if let json = try? core.show(type: .dmarc, account: entry.account, filename: entry.filename),
let data = json.data(using: .utf8),
let detail = try? decoder.decode(DmarcDetail.self, from: data) {
let domain = detail.policy.domain.isEmpty ? entry.domain : detail.policy.domain
for rec in detail.records {
if rec.dkim_eval == "pass" {
dkimPass[domain, default: 0] += rec.count
} else {
dkimFail[domain, default: 0] += rec.count
}
if rec.spf_eval == "pass" {
spfPass[domain, default: 0] += rec.count
} else {
spfFail[domain, default: 0] += rec.count
}
let disp = rec.disposition.isEmpty ? "none" : rec.disposition
dispositions[disp, default: 0] += rec.count
}
}
case .tlsrpt:
tlsrptOrgs[entry.org, default: 0] += 1
if let json = try? core.show(type: .tlsrpt, account: entry.account, filename: entry.filename),
let data = json.data(using: .utf8),
let detail = try? decoder.decode(TlsDetail.self, from: data) {
for policy in detail.policies {
let domain = policy.policy_domain.isEmpty ? entry.domain : policy.policy_domain
let type = policy.policy_type.isEmpty ? "unknown" : policy.policy_type
tlsPolicyTypes[type, default: 0] += policy.total_successful + policy.total_failure
tlsSuccess[domain, default: 0] += policy.total_successful
tlsFailure[domain, default: 0] += policy.total_failure
for f in policy.failures {
let ft = f.result_type.isEmpty ? "unknown" : f.result_type
tlsFailureTypes[ft, default: 0] += f.failed_session_count
}
}
}
}
if index % 25 == 0 {
await Task.yield()
}
}
struct Aggregates {
let dmarcOrgs: [String: Int]
let tlsrptOrgs: [String: Int]
let dkimPass: [String: UInt64]
let dkimFail: [String: UInt64]
let spfPass: [String: UInt64]
let spfFail: [String: UInt64]
let dispositions: [String: UInt64]
let tlsSuccess: [String: UInt64]
let tlsFailure: [String: UInt64]
let tlsPolicyTypes: [String: UInt64]
let tlsFailureTypes: [String: UInt64]
}
let aggregates = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
var dmarcOrgs: [String: Int] = [:]
var tlsrptOrgs: [String: Int] = [:]
// Per-domain DMARC aggregates
var dkimPass: [String: UInt64] = [:]
var dkimFail: [String: UInt64] = [:]
var spfPass: [String: UInt64] = [:]
var spfFail: [String: UInt64] = [:]
var dispositions: [String: UInt64] = [:]
// Per-domain TLS aggregates
var tlsSuccess: [String: UInt64] = [:]
var tlsFailure: [String: UInt64] = [:]
var tlsPolicyTypes: [String: UInt64] = [:]
var tlsFailureTypes: [String: UInt64] = [:]
let core = ReportsCore.shared
let decoder = JSONDecoder()
for entry in entries {
switch entry.type {
case .dmarc:
dmarcOrgs[entry.org, default: 0] += 1
if let json = try? core.show(type: .dmarc, account: entry.account, filename: entry.filename),
let data = json.data(using: .utf8),
let detail = try? decoder.decode(DmarcDetail.self, from: data) {
let domain = detail.policy.domain.isEmpty ? entry.domain : detail.policy.domain
for rec in detail.records {
if rec.dkim_eval == "pass" {
dkimPass[domain, default: 0] += rec.count
} else {
dkimFail[domain, default: 0] += rec.count
}
if rec.spf_eval == "pass" {
spfPass[domain, default: 0] += rec.count
} else {
spfFail[domain, default: 0] += rec.count
}
let disp = rec.disposition.isEmpty ? "none" : rec.disposition
dispositions[disp, default: 0] += rec.count
}
}
case .tlsrpt:
tlsrptOrgs[entry.org, default: 0] += 1
if let json = try? core.show(type: .tlsrpt, account: entry.account, filename: entry.filename),
let data = json.data(using: .utf8),
let detail = try? decoder.decode(TlsDetail.self, from: data) {
for policy in detail.policies {
let domain = policy.policy_domain.isEmpty ? entry.domain : policy.policy_domain
let type = policy.policy_type.isEmpty ? "unknown" : policy.policy_type
tlsPolicyTypes[type, default: 0] += policy.total_successful + policy.total_failure
tlsSuccess[domain, default: 0] += policy.total_successful
tlsFailure[domain, default: 0] += policy.total_failure
for f in policy.failures {
let ft = f.result_type.isEmpty ? "unknown" : f.result_type
tlsFailureTypes[ft, default: 0] += f.failed_session_count
}
}
}
}
}
continuation.resume(
returning: Aggregates(
dmarcOrgs: dmarcOrgs,
tlsrptOrgs: tlsrptOrgs,
dkimPass: dkimPass,
dkimFail: dkimFail,
spfPass: spfPass,
spfFail: spfFail,
dispositions: dispositions,
tlsSuccess: tlsSuccess,
tlsFailure: tlsFailure,
tlsPolicyTypes: tlsPolicyTypes,
tlsFailureTypes: tlsFailureTypes
)
)
}
}
let dmarcOrgs = aggregates.dmarcOrgs
let tlsrptOrgs = aggregates.tlsrptOrgs
let dkimPass = aggregates.dkimPass
let dkimFail = aggregates.dkimFail
let spfPass = aggregates.spfPass
let spfFail = aggregates.spfFail
let dispositions = aggregates.dispositions
let tlsSuccess = aggregates.tlsSuccess
let tlsFailure = aggregates.tlsFailure
let tlsPolicyTypes = aggregates.tlsPolicyTypes
let tlsFailureTypes = aggregates.tlsFailureTypes

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +130
detailTask = Task { [weak self] in
guard let self else { return }
let json: String? = await Task.detached(priority: .userInitiated) { [core] in
try? core.show(type: entry.type, account: entry.account, filename: entry.filename)
}.value

if Task.isCancelled { return }

await MainActor.run {
self.detailJSON = json
self.isLoadingDetail = false
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadDetail converts core.show(...) into an optional with try?. If loading fails, json stays nil and isLoadingDetail is set to false, but the UI currently treats detailJSON == nil as “still loading”, so the spinner can remain forever and the error is never surfaced. Preserve the thrown error (set errorMessage, or store a distinct “failed” state) and update the detail view to show an error/empty state when loading completes with nil.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +171
enrichmentTask = Task { [weak self, core] in
for ip in pending {
if Task.isCancelled { return }
let result = await Task.detached(priority: .userInitiated) {
core.enrichIP(ip)
}.value

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startEnrichment calls core.enrichIP inside Task.detached. ReportsCore.enrichIP mutates an internal enrichCache dictionary, which is not synchronized; if a previous enrichment task is cancelled while a detached call is in-flight (or a new enrichment starts quickly), you can end up with concurrent enrichIP calls mutating the dictionary from multiple threads. Guard enrichCache with a lock/actor, or remove the Swift-side cache now that enrichment is cached persistently in Zig.

Copilot uses AI. Check for mistakes.
Comment thread src/lib.zig
Comment on lines +12 to +18
fn getCache(data_dir: []const u8) ?*reports.enrichcache.Cache {
g_cache_mu.lock();
defer g_cache_mu.unlock();
if (g_cache == null) {
g_cache = reports.enrichcache.Cache.init(allocator, data_dir) catch return null;
}
return &g_cache.?;

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCache(data_dir:) only uses data_dir the first time it’s called; subsequent calls will reuse the existing cache even if a different data_dir is passed (e.g., if the host app provides a different config JSON later). That can cause enrichment results to be read/written to the wrong cache file. Consider either keying the global cache by data_dir (map of caches) or detecting a data_dir change and reinitializing the cache accordingly.

Copilot uses AI. Check for mistakes.
- Fix comment: .enrich_cache.json → .enrich_cache.jsonl (src/main.zig)
- Use try for file open/seek in appendLine so I/O errors propagate
  consistently through put() (src/enrichcache.zig)
- Fix memory leak in loadFromDisk: free earlier-allocated fields when
  a later dupStrField fails (src/enrichcache.zig)
- Cancel in-flight detail/enrichment tasks in selectDashboard() to
  prevent stale background work (ReportsViewModel.swift)
- Surface error message when loadDetail fails instead of leaving
  spinner visible forever (ReportsViewModel.swift)
- Remove Swift-side enrichCache dictionary; Zig core now handles
  persistent caching, eliminating the unsynchronized concurrent
  dictionary access (ReportsCore.swift)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@linyows linyows merged commit 930cf0d into main Apr 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants