Fix crash: enrichment held array indices across awaits #10

Merged
admin merged 1 commits from fix/enrichment-index-crash into master 2026-07-31 22:23:28 +00:00
2 changed files with 64 additions and 26 deletions
+48 -29
View File
@@ -225,52 +225,71 @@ final class BookmarksViewModel {
} }
func enrichAll() async { func enrichAll() async {
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil } // Track ids, not indices. Each iteration awaits the network, and
// `bookmarks` is replaced wholesale by loads, searches and filter
// toggles an index captured before the await can be out of range by
// the time it is used.
let toEnrich = bookmarks.filter { $0.aiSummary == nil }.map(\.id)
guard !toEnrich.isEmpty else { return } guard !toEnrich.isEmpty else { return }
enrichmentProgress = 0 enrichmentProgress = 0
for (done, i) in toEnrich.enumerated() { var completed = 0
for id in toEnrich {
guard let bookmark = bookmarks.first(where: { $0.id == id }) else { continue }
do { do {
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i]) let (summary, tags) = try await claude.enrich(bookmark: bookmark)
bookmarks[i].aiSummary = summary apply(summary: summary, tags: tags, toId: id)
bookmarks[i].aiTags = tags completed += 1
saveAIData(for: bookmarks[i]) enrichmentProgress = Double(completed) / Double(toEnrich.count)
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
} catch { } catch {
break break
} }
} }
let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count if completed > 0 { Analytics.track("enrich.completed", ["count": completed]) }
if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) }
enrichmentProgress = 0 enrichmentProgress = 0
} }
// Silently enrich new bookmarks that lack summaries (background, non-blocking) /// Write an enrichment result back by id, skipping it if the bookmark is no
private func startEnrichment() { /// longer loaded (deleted, archived, or filtered away mid-flight).
enrichTask?.cancel() private func apply(summary: String, tags: [String], toId id: Int) {
enrichTask = Task { [weak self] in guard let i = bookmarks.firstIndex(where: { $0.id == id }) else {
guard let self else { return } Log.ai.notice("enrich result dropped: id=\(id, privacy: .public) no longer loaded")
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) } return
Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich") }
for i in indices {
guard !Task.isCancelled else { return }
let claude = await MainActor.run(body: { self.claude })
do {
let bm = await MainActor.run { bookmarks[i] }
Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
let (summary, tags) = try await claude.enrich(bookmark: bm)
Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)")
await MainActor.run {
guard i < bookmarks.count else { return }
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i] let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) } Task { await SpotlightIndexer.index([enriched]) }
} }
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
//
// Everything here is keyed by bookmark id rather than array index. This loop
// awaits a network call per bookmark, and `bookmarks` gets replaced under it
// by loads, searches and the unread filter an index captured up front used
// to crash on the read (`bookmarks[i]`), which the write path already
// guarded against but the read did not.
private func startEnrichment() {
enrichTask?.cancel()
enrichTask = Task { [weak self] in
guard let self else { return }
let ids = await MainActor.run {
bookmarks.filter { $0.aiSummary == nil }.prefix(5).map(\.id)
}
Log.ai.info("startEnrichment: \(ids.count, privacy: .public) to enrich")
for id in ids {
guard !Task.isCancelled else { return }
let claude = await MainActor.run(body: { self.claude })
guard let bm = await MainActor.run(body: {
bookmarks.first { $0.id == id }
}) else { continue }
do {
Log.ai.debug("startEnrichment enriching id=\(id, privacy: .public)")
let (summary, tags) = try await claude.enrich(bookmark: bm)
Log.ai.debug("startEnrichment done id=\(id, privacy: .public)")
await MainActor.run { apply(summary: summary, tags: tags, toId: id) }
} catch { } catch {
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)") Log.ai.error("startEnrichment failed id=\(id, privacy: .public): \(error.localizedDescription, privacy: .public)")
break break
} }
} }
+19
View File
@@ -37,4 +37,23 @@ struct SpotlightRetrievalTests {
"url did not survive the round trip: \(hit.url)") "url did not survive the round trip: \(hit.url)")
#expect(hit.host == "github.com", "host was \(hit.host)") #expect(hit.host == "github.com", "host was \(hit.host)")
} }
@Test func indexedSourceIsRetrievable() async throws {
let source = IngestedSource(
kind: .text,
title: "Wqvbnmtest meeting notes",
bodyText: "A distinctive probe document about wqvbnmtest.",
tags: ["wqvbnmtest"]
)
await SourceSpotlightIndexer.index([source])
var hits: [RetrievedBookmark] = []
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(700))
hits = await SpotlightBookmarkSearch.run(query: "wqvbnmtest", limit: 5)
if !hits.isEmpty { break }
}
await SourceSpotlightIndexer.remove(ids: [source.id])
#expect(!hits.isEmpty, "Spotlight returned no hits for an indexed source")
#expect(hits.first?.title.contains("Wqvbnmtest") == true)
}
} }