Repository navigation
fix(launch): paint first Sessions snapshot within seconds of warm hydration #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -152,6 +152,11 @@ final class NavigationSnapshotStore: ObservableObject { | |
| (source: LineageSource, resolution: SessionLineageResolution)? | ||
| private var lineageInFlight: | ||
| (source: LineageSource, task: Task<SessionLineageResolution?, Never>)? | ||
| /// Newest completed resolution regardless of source — the | ||
| /// stale-while-revalidate forest. On a warm launch the hydrated corpus is | ||
| /// ≈ final, so serving it while batches revalidate is honest; the fresh | ||
| /// forest swaps in silently when its resolve completes. | ||
| private var lastLineageResolution: SessionLineageResolution? | ||
| private var corpusGeneration = 0 | ||
| private var fleetGeneration = 0 | ||
| private var deadlineGeneration = 0 | ||
|
|
@@ -237,10 +242,11 @@ final class NavigationSnapshotStore: ObservableObject { | |
| if lineageMemo?.source != lineageSource { | ||
| lineageMemo = nil | ||
| } | ||
| if let pending = lineageInFlight, pending.source != lineageSource { | ||
| pending.task.cancel() | ||
| lineageInFlight = nil | ||
| } | ||
| // An in-flight resolve for an older source is NOT cancelled: during a | ||
| // validation scan every batch bumps the revision, and cancel-restart | ||
| // starved the first snapshot for the whole scan. The old resolve | ||
| // completes as the stale-while-revalidate forest; a fresh resolve | ||
| // follows once it lands. | ||
| inputs = Inputs( | ||
| sessions: sessions, | ||
| attentionSignals: attentionSignals, | ||
|
|
@@ -385,6 +391,27 @@ final class NavigationSnapshotStore: ObservableObject { | |
| } | ||
| } | ||
|
|
||
| /// A lineage resolve finished. Record it as the freshest forest, memoize | ||
| /// when it still matches the live inputs, and re-schedule so a snapshot | ||
| /// that painted a stale forest swaps to this one silently. When inputs | ||
| /// moved on mid-resolve, the re-schedule starts the follow-up resolve. | ||
| private func lineageResolveCompleted( | ||
| source: LineageSource, | ||
| task: Task<SessionLineageResolution?, Never>, | ||
| resolution: SessionLineageResolution? | ||
| ) { | ||
| if lineageInFlight?.task == task { lineageInFlight = nil } | ||
| guard let resolution else { return } | ||
| lastLineageResolution = resolution | ||
| if let inputs, LineageSource( | ||
| sessionsRevision: inputs.sessionsRevision, | ||
| evidenceRevision: inputs.lineageEvidenceRevision, | ||
| includeHeuristics: inputs.showHeuristicLinks) == source { | ||
| lineageMemo = (source: source, resolution: resolution) | ||
| } | ||
| scheduleSessions() | ||
| } | ||
|
|
||
| private func scheduleSessions(debounce: Bool = false) { | ||
| guard let inputs else { return } | ||
| sessionGeneration += 1 | ||
|
|
@@ -397,19 +424,38 @@ final class NavigationSnapshotStore: ObservableObject { | |
| let cachedResolution = lineageMemo.flatMap { | ||
| $0.source == lineageSource ? $0.resolution : nil | ||
| } | ||
| let staleResolution = lastLineageResolution | ||
| let resolutionTask: Task<SessionLineageResolution?, Never>? = { | ||
| guard cachedResolution == nil else { return nil } | ||
| if let pending = lineageInFlight, | ||
| pending.source == lineageSource { | ||
| if let pending = lineageInFlight { | ||
| // One resolve at a time, never cancelled for a newer source — | ||
| // its completion re-schedules and the follow-up resolve picks | ||
| // up the newest inputs. | ||
| return pending.task | ||
| } | ||
| let task = Task.detached(priority: .userInitiated) { | ||
| try? SessionLineage.resolveWithIndexCancellable( | ||
| sessions: inputs.sessions, | ||
| evidence: inputs.lineageEvidence, | ||
| includeHeuristicLinks: inputs.showHeuristicLinks) | ||
| // Hop to GCD: the cooperative pool can be saturated by a | ||
| // corpus scan, and the first snapshot must not starve behind | ||
| // it. GCD over-commits, so this resolve always gets a thread. | ||
| await withCheckedContinuation { continuation in | ||
| DispatchQueue.global(qos: .userInitiated).async { | ||
| continuation.resume( | ||
| returning: try? SessionLineage.resolveWithIndexCancellable( | ||
| sessions: inputs.sessions, | ||
| evidence: inputs.lineageEvidence, | ||
| includeHeuristicLinks: inputs.showHeuristicLinks)) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
436
to
449
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -n -B3 -A20 'func resolveWithIndexCancellable|func buildResolution' Sources/TrifolaKit/SessionLineage.swiftRepository: ss251/trifola Length of output: 1650 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== NavigationSnapshotStore relevant methods ==\n'
ast-grep outline Sources/Trifola/NavigationSnapshotStore.swift --view expanded | sed -n '1,260p'
printf '\n== Focused slice around the detached task ==\n'
sed -n '400,480p' Sources/Trifola/NavigationSnapshotStore.swift
printf '\n== SessionLineage cancellation checks ==\n'
sed -n '405,470p' Sources/TrifolaKit/SessionLineage.swiftRepository: ss251/trifola Length of output: 8441 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== Search for lineageInFlight cancellation and task lifecycle ==\n'
rg -n -B3 -A10 'lineageInFlight|task.cancel\(|Task\.detached|withCheckedContinuation|DispatchQueue\.global' Sources/Trifola/NavigationSnapshotStore.swift Sources/TrifolaKit/SessionLineage.swiftRepository: ss251/trifola Length of output: 15989 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- NavigationSnapshotStore.swift: task lifecycle ---'
rg -n -B3 -A12 'lineageInFlight|task.cancel\(|Task\.detached|withCheckedContinuation|DispatchQueue\.global' Sources/Trifola/NavigationSnapshotStore.swift
echo
echo '--- NavigationSnapshotStore.swift: around detached task ---'
sed -n '420,470p' Sources/Trifola/NavigationSnapshotStore.swift
echo
echo '--- SessionLineage.swift: cancellation plumbing ---'
sed -n '393,460p' Sources/TrifolaKit/SessionLineage.swiftRepository: ss251/trifola Length of output: 15076 🌐 Web query:
💡 Result: Swift task cancellation does not automatically propagate into work dispatched via Grand Central Dispatch (GCD) [1][2]. Because Swift concurrency and GCD are separate systems with different execution models, there is no inherent linkage between a Swift Task's cancellation state and the execution of a block submitted to a DispatchQueue [3]. Key points regarding this behavior include: 1. System Separation: Structured concurrency relies on the Swift runtime to track parent-child task relationships and automatically propagate cancellation [4][5][6]. GCD operates independently of these task trees and does not monitor or respect the cancellation status of the surrounding Swift Task [1][3]. 2. Cooperative Cancellation: Cancellation in Swift is cooperative [2][6]. Functions such as Task.checkCancellation only function when called within an asynchronous Swift context where a task handle exists [7][8]. Since a block inside DispatchQueue.async executes on a different execution context (typically a system-managed thread pool not associated with a specific Swift Task), calling Task.checkCancellation inside it will generally not behave as expected or will fail to observe the parent task's state [2]. 3. Handling Cancellation Across Systems: If you need to stop work submitted to GCD based on a Task being cancelled, you must manually bridge the two systems [1][2]. Common approaches include: - Using a DispatchWorkItem: You can create a DispatchWorkItem and call its cancel method manually when the parent task is cancelled [9]. - Cancellation Handlers: Use withTaskCancellationHandler(operation:onCancel:) to perform custom logic, such as calling cancel on a DispatchWorkItem or a URLSessionTask, when the Swift Task is cancelled [7][2]. In summary, because GCD tasks are not part of the Swift structured concurrency tree, they are "invisible" to automatic propagation [1]. You must explicitly coordinate cancellation if you are mixing the two concurrency models [2]. Citations:
Cancellation doesn’t cross the GCD hop. 🤖 Prompt for AI Agents |
||
| lineageInFlight = (source: lineageSource, task: task) | ||
| Task { [weak self] in | ||
| let resolution = await task.value | ||
| await MainActor.run { | ||
| self?.lineageResolveCompleted( | ||
| source: lineageSource, task: task, | ||
| resolution: resolution) | ||
| } | ||
| } | ||
| return task | ||
| }() | ||
| sessionTask?.cancel() | ||
|
|
@@ -424,20 +470,15 @@ final class NavigationSnapshotStore: ObservableObject { | |
| let resolution: SessionLineageResolution | ||
| if let cachedResolution { | ||
| resolution = cachedResolution | ||
| } else if let staleResolution { | ||
| // Serve the newest completed forest NOW; the in-flight resolve | ||
| // re-schedules a silent swap when it lands. | ||
| resolution = staleResolution | ||
| } else if let resolutionTask { | ||
| // First-ever paint with nothing to show — the only case that | ||
| // waits on a resolve. | ||
| guard let completed = await resolutionTask.value else { return } | ||
| resolution = completed | ||
|
Comment on lines
+473
to
481
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Stale forest can transiently disagree with the live session count / Flat-mode membership. When 🤖 Prompt for AI Agents |
||
| guard let self else { return } | ||
| if self.inputs.map({ LineageSource( | ||
| sessionsRevision: $0.sessionsRevision, | ||
| evidenceRevision: $0.lineageEvidenceRevision, | ||
| includeHeuristics: $0.showHeuristicLinks) | ||
| }) == lineageSource { | ||
| self.lineageMemo = ( | ||
| source: lineageSource, | ||
| resolution: resolution) | ||
| self.lineageInFlight = nil | ||
| } | ||
| } else { | ||
| return | ||
| } | ||
|
|
@@ -522,6 +563,12 @@ final class NavigationSnapshotStore: ObservableObject { | |
| self.sessionGeneration == generation else { return } | ||
| var state = self.sessionSearch | ||
| guard state.publish(result, for: request) else { return } | ||
| if self.sessionSearch.displayed == nil, | ||
| ProcessInfo.processInfo.environment["TRIFOLA_LAUNCH_METRICS"] == "1" { | ||
| FileHandle.standardError.write(Data( | ||
| "[launch-metric] first sessions snapshot: \(result.rows.count) rows\n" | ||
| .utf8)) | ||
| } | ||
| self.sessionSearch = state | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
A failed resolve never retries itself.
When
resolutionis nil (thetry?in the detached task swallowed a throw),lineageInFlightis cleared butscheduleSessions()is never called — the earlyreturnat Line 404 skips it. Nothing else will re-arm a resolve attempt until some unrelated external event (a session/filter change) happens to callrebuild()/updateSessionFilter()again. On the very first launch, if this first-ever resolve fails and the corpus is otherwise idle, Sessions can stay stuck exactly the way this PR is fixing.🐛 Proposed fix: always re-schedule, even on failure
private func lineageResolveCompleted( source: LineageSource, task: Task<SessionLineageResolution?, Never>, resolution: SessionLineageResolution? ) { if lineageInFlight?.task == task { lineageInFlight = nil } - guard let resolution else { return } - lastLineageResolution = resolution - if let inputs, LineageSource( - sessionsRevision: inputs.sessionsRevision, - evidenceRevision: inputs.lineageEvidenceRevision, - includeHeuristics: inputs.showHeuristicLinks) == source { - lineageMemo = (source: source, resolution: resolution) + if let resolution { + lastLineageResolution = resolution + if let inputs, LineageSource( + sessionsRevision: inputs.sessionsRevision, + evidenceRevision: inputs.lineageEvidenceRevision, + includeHeuristics: inputs.showHeuristicLinks) == source { + lineageMemo = (source: source, resolution: resolution) + } } scheduleSessions() }📝 Committable suggestion
🤖 Prompt for AI Agents