Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Agent 笔记:LSP 设置摘要的取消与快照输入

状态:已实现

## 先说结论

LSP 设置页离开或切换文件快照时,页面应立即结束等待,下一份摘要直接开始计算。
旧扫描收到取消后可能还在处理最后一批文件;摘要模型继续持有它,但旧结果不能更新页面。
快照标识和文件清单必须一起捕获,避免把较新的文件清单算进较旧的缓存键。

## 问题

取消 Swift 任务是协作式操作,等待任务的 `value` 不会因为等待方被取消而立即返回。
让新扫描先等待旧扫描完成,会把旧扫描的退出延迟带到下一次切页和工作区切换。
只取消扫描、仍让页面等待其 `value`,也不能保证离页后的页面任务及时释放。

## 决策

- `LSPProjectUsageModel` 用 continuation(可由所有者主动结束的等待句柄)管理每个页面消费方。
同请求共享一个扫描;取消一个消费方只结束它自己的等待,最后一个退出时才取消扫描。
- 请求替换和工作区重置先解除旧消费方,再取消扫描。模型保存尚未退出的任务,完成后移除;
完成回调用唯一工作 ID 校验当前所有权,旧结果和旧取消回调都不能修改新请求。
- 后台任务只弱引用模型,模型释放时取消全部任务。生产扫描每 256 个 URL 检查取消并释放
临时对象;取消后的批次可短暂重叠,不承诺任意忽略取消的自定义扫描器能被强制终止。
- `LanguageToolingFeatureModel` 从同一份已应用快照生成不可变输入。文件数组按写时复制语义捕获,
任务身份只包含工作区、快照 UUID 和规范化规则,不遍历或哈希整个文件数组。
- LSP 叶子视图继续直接观察 `AppModel`。工作区通知已会经 `objectWillChange` 到达叶子;
`affectsSettings: false` 只抑制设置根树刷新,不代表叶子收不到变化。
不额外向 `settingsChanges` 转发文件快照,保持设置窗口的低频刷新边界。

正确做法:旧快照 A 扫描暂停时提交 B,B 直接完成;A 随后退出只清理自己的任务。
不要让 B 先 `await A.value`,也不要在 B 的任务开始时再单独读取可变的 `projectFiles`。

## 考虑过的备选方案

- 串行等待旧扫描:峰值只运行一份扫描,但不合作的旧扫描会阻塞新页面,因此不采用。
- 丢弃取消任务的句柄:新请求可以启动,但所有权和清理无法核验,因此保留到任务完成。
- 给设置根树增加快照通知:现有叶子订阅已覆盖更新,再增加通知会扩大重绘范围。

## 后果

- 页面任务的生命周期不再取决于旧扫描的退出速度,热缓存仍只保存一份摘要。
- 需要管理消费方等待和退出中的扫描两种资源;取消边界允许旧批次短暂重叠。
- 此功能只读取已有 URL 清单,不访问文件内容、不启动语言服务器、不新增磁盘缓存,
不写发行目录,因此不改变代码签名或 Sparkle 增量更新所依赖的发行基线。

## 验证

- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`
- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'LSPProjectUsageTests|AppModelSettingsChangesTests|WorkspaceLanguageServerPreferencesTests'`
- `./scripts/architecture/verify-service-boundaries.sh`
- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`
- 用有界 gate 暂停并忽略旧扫描取消,验证新请求和旧消费方都能在释放 gate 前结束。
原生挂载测试保持同一个 LSP 页面,依次应用新增、重命名和删除后的快照。

## 适用范围

- `macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift`
- `macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift`
- `macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift`
- `macos/Tests/LitheTests/LSPProjectUsageTests.swift`
- `macos/Tests/LitheTests/AppModelSettingsChangesTests.swift`
226 changes: 226 additions & 0 deletions macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import Combine
import Foundation

/// Matching facts only: localization, plugin readiness and session state do not
/// change which files use a language, and must not invalidate this cache.
struct LSPProjectUsageRequest: Hashable, Sendable {
enum Target: Hashable, Sendable {
case provider(String)
case plugin(String)

fileprivate var sortKey: String {
switch self {
case .provider(let id): return "provider:" + id
case .plugin(let id): return "plugin:" + id
}
}
}

struct Rule: Hashable, Sendable {
let target: Target
let fileExtensions: Set<String>
let fileNames: Set<String>
let fileNamePrefixes: Set<String>

init(target: Target, fileExtensions: Set<String>, fileNames: Set<String> = [],
fileNamePrefixes: Set<String> = []) {
self.target = target
self.fileExtensions = Set(fileExtensions.map { $0.lowercased() })
self.fileNames = Set(fileNames.map { $0.lowercased() })
self.fileNamePrefixes = Set(fileNamePrefixes.map { $0.lowercased() })
}
}

let workspaceURL: URL?
let snapshotID: UUID?
let rules: [Rule]

init(workspaceURL: URL?, snapshotID: UUID?, rules: [Rule]) {
self.workspaceURL = workspaceURL
self.snapshotID = snapshotID
self.rules = rules.sorted { $0.target.sortKey < $1.target.sortKey }
}
}

struct LSPProjectUsageSummary: Equatable, Sendable {
var providerIDs: Set<String> = []
var pluginIDs: Set<String> = []
}

/// Capture the identity and copy-on-write inventory from one applied snapshot.
/// Only the request participates in task identity; files are never hashed.
struct LSPProjectUsageInput: Sendable {
let request: LSPProjectUsageRequest
let files: [URL]
}

enum LSPProjectUsageScanner {
/// The caller runs this on its owned worker. Inspect each URL at most once
/// and retain only matched IDs, not an additional inventory of file names.
static func scan(files: [URL], rules: [LSPProjectUsageRequest.Rule]) -> LSPProjectUsageSummary? {
typealias Target = LSPProjectUsageRequest.Target
var extensions: [String: Set<Target>] = [:]
var names: [String: Set<Target>] = [:]
var prefixes: [String: Set<Target>] = [:]
for rule in rules {
for value in rule.fileExtensions { extensions[value, default: []].insert(rule.target) }
for value in rule.fileNames { names[value, default: []].insert(rule.target) }
for value in rule.fileNamePrefixes { prefixes[value, default: []].insert(rule.target) }
}
let allTargets = Set(rules.map(\.target))
var matched: Set<Target> = []
for start in stride(from: 0, to: files.count, by: 256) {
guard !Task.isCancelled else { return nil }
if matched == allTargets { break }
// Foundation's URL accessors can bridge through autoreleased NSURL
// strings. Drain them within a batch, not at the end of 300k files.
autoreleasepool {
for index in start..<min(start + 256, files.count) {
let url = files[index]
if !extensions.isEmpty, let targets = extensions[url.pathExtension.lowercased()] {
matched.formUnion(targets)
}
if !names.isEmpty || !prefixes.isEmpty {
let name = url.lastPathComponent.lowercased()
if let targets = names[name] { matched.formUnion(targets) }
for (prefix, targets) in prefixes where name.hasPrefix(prefix) {
matched.formUnion(targets)
}
}
}
}
}
guard !Task.isCancelled else { return nil }
var summary = LSPProjectUsageSummary()
for target in matched {
switch target {
case .provider(let id): summary.providerIDs.insert(id)
case .plugin(let id): summary.pluginIDs.insert(id)
}
}
return summary
}
}

/// One workspace-owned cache survives Settings window recreation. A page owns
/// a cancellable lease on the worker, rather than a detached unowned scan.
// Note: .agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md
@MainActor
final class LSPProjectUsageModel: ObservableObject {
enum State: Equatable { case idle, loading, ready }
typealias Scanner = @Sendable ([URL], [LSPProjectUsageRequest.Rule]) async -> LSPProjectUsageSummary?

@Published private(set) var state: State = .idle
private let scanner: Scanner
private var cached: (request: LSPProjectUsageRequest, summary: LSPProjectUsageSummary)?
private var work: Work?
private var retiring: [UUID: Task<Void, Never>] = [:]

private final class Work {
let id: UUID
let request: LSPProjectUsageRequest
let task: Task<Void, Never>
var consumers: [UUID: (cancellation: Cancellation, continuation: CheckedContinuation<Void, Never>)] = [:]

init(id: UUID, request: LSPProjectUsageRequest, task: Task<Void, Never>) {
self.id = id
self.request = request
self.task = task
}
}

/// The cancellation handler runs outside the main actor. Mark the lease
/// synchronously so a queued completion cannot publish for cancelled pages.
private final class Cancellation: @unchecked Sendable {
private let lock = NSLock()
private var cancelled = false
var isCancelled: Bool { lock.withLock { cancelled } }
func cancel() { lock.withLock { cancelled = true } }
}

init(scanner: @escaping Scanner = { files, rules in
LSPProjectUsageScanner.scan(files: files, rules: rules)
}) {
self.scanner = scanner
}

func summary(for request: LSPProjectUsageRequest) -> LSPProjectUsageSummary? {
cached?.request == request ? cached?.summary : nil
}

func load(_ request: LSPProjectUsageRequest, files: [URL]) async {
let consumer = UUID()
let cancellation = Cancellation()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard !cancellation.isCancelled, !Task.isCancelled else { continuation.resume(); return }
if summary(for: request) != nil {
retireWork()
// @Published emits even for equal values. Warm loads must
// not schedule another SwiftUI update.
if state != .ready { state = .ready }
continuation.resume()
return
}
if work?.request != request {
retireWork()
let id = UUID()
let scanner = self.scanner
let task = Task.detached(priority: .utility) { [weak self] in
let value = Task.isCancelled ? nil : await scanner(files, request.rules)
await self?.complete(id: id, value: value)
}
work = Work(id: id, request: request, task: task)
}
work?.consumers[consumer] = (cancellation, continuation)
if state != .loading { state = .loading }
}
} onCancel: { [weak self] in
cancellation.cancel()
Task { @MainActor in self?.cancel(consumer: consumer) }
}
}

func reset() {
retireWork()
retiring.values.forEach { $0.cancel() }
cached = nil
state = .idle
}

private func cancel(consumer: UUID) {
guard let work, let lease = work.consumers.removeValue(forKey: consumer) else { return }
lease.continuation.resume()
if work.consumers.isEmpty {
retireWork()
state = cached == nil ? .idle : .ready
}
}

private func complete(id: UUID, value: LSPProjectUsageSummary?) {
retiring[id] = nil
guard let active = work, active.id == id else { return }
work = nil
if let value, active.consumers.values.contains(where: { !$0.cancellation.isCancelled }) {
cached = (active.request, value)
}
state = cached == nil ? .idle : .ready
active.consumers.values.forEach { $0.continuation.resume() }
}

private func retireWork() {
guard let active = work else { return }
work = nil
// Cancellation is cooperative: own the worker until its next batch
// exits, but never put that completion on the next page's critical path.
retiring[active.id] = active.task
active.task.cancel()
active.consumers.values.forEach { $0.continuation.resume() }
}

deinit {
work?.task.cancel()
work?.consumers.values.forEach { $0.continuation.resume() }
retiring.values.forEach { $0.cancel() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class LanguageToolingFeatureModel: ObservableObject {
@Published private(set) var catalogSnapshot: LanguageProviderCatalogSnapshot
@Published private(set) var disabledProviderIDs: Set<String> = []
@Published private(set) var projectPreparation: ProjectPreparationSnapshot?
let projectUsage = LSPProjectUsageModel()
private var preparationObservation: AnyCancellable?
private(set) var startupFailures: [String: String] = [:]

Expand Down Expand Up @@ -64,7 +65,32 @@ final class LanguageToolingFeatureModel: ObservableObject {
startupFailures.removeAll()
}

func projectUsageRequest(workspaceURL: URL?, snapshotID: UUID?) -> LSPProjectUsageRequest {
let providers = catalog.descriptors
.filter { $0.capabilities.contains(.languageServer) && $0.languageServerLaunch != nil }
.map {
LSPProjectUsageRequest.Rule(target: .provider($0.id), fileExtensions: $0.fileExtensions,
fileNames: $0.fileNames, fileNamePrefixes: $0.fileNamePrefixes)
}
let plugins = OfficialPluginCatalog.entries.map {
LSPProjectUsageRequest.Rule(target: .plugin($0.id.rawValue), fileExtensions: Set($0.fileExtensions),
fileNames: Set($0.projectFileNames))
}
return LSPProjectUsageRequest(workspaceURL: workspaceURL, snapshotID: snapshotID, rules: providers + plugins)
}

func projectUsageInput(workspaceURL: URL?, snapshot: WorkspaceSnapshot?) -> LSPProjectUsageInput {
LSPProjectUsageInput(
request: projectUsageRequest(workspaceURL: workspaceURL, snapshotID: snapshot?.id),
files: snapshot?.files ?? []
)
}

func reloadCatalog(for workspaceURL: URL?) {
// Runtime/index resets also call resetWorkspaceState while Settings is
// still visible. Only a workspace boundary invalidates the usage cache;
// otherwise an unchanged .task(id:) would not reload the cleared value.
projectUsage.reset()
self.workspaceURL = workspaceURL?.standardizedFileURL
// Restore preferences before workspace restoration can activate documents
// or prewarm Java. Runtime resets must not erase explicit user choices.
Expand Down
2 changes: 1 addition & 1 deletion macos/Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ struct LitheApp: App {
exit(1)
}
let store = MacUserDefaultsStore()
if LithePerformanceBaseline.isEnabled {
if LithePerformanceBaseline.isEnabled || LitheSignpost.settingsLatencyTracingEnabled {
LitheSignpost.configureBaselineOutput { line in
FileHandle.standardError.write(Data(line.utf8))
}
Expand Down
13 changes: 13 additions & 0 deletions macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@ enum LitheSignpost {
private static let baselineEnabled =
ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1"
private static var baselineOutput: ((String) -> Void)?
static let settingsLatencyTracingEnabled =
ProcessInfo.processInfo.environment["LITHE_SETTINGS_LATENCY_TRACE"] == "1"

static func configureBaselineOutput(_ output: @escaping (String) -> Void) {
baselineOutput = output
}

/// Opt-in native timestamps, independent of the external AX observer and
/// of the baseline mode that disables the normal frame-rate monitor.
static func settingsLatencyEvent(
_ phase: String, category: String, eventID: Int64,
input: UInt64, received: UInt64, selected: UInt64
) {
guard settingsLatencyTracingEnabled else { return }
let time = DispatchTime.now().uptimeNanoseconds
baselineOutput?("LITHE_SETTINGS_LATENCY phase=\(phase) category=\(category) event_id=\(eventID) time_ns=\(time) input_ns=\(input) received_ns=\(received) selected_ns=\(selected)\n")
}

static func begin(_ name: StaticString) -> State {
State(
osState: signposter.beginInterval(name),
Expand Down
Loading
Loading