Skip to content

fix(plugins,lsp): preserve concurrent rejections and resolve LSP cancellation on transport failure#1158

Merged
datlechin merged 1 commit into
mainfrom
fix/lsp-and-plugin-stale-state-bugs
May 9, 2026
Merged

fix(plugins,lsp): preserve concurrent rejections and resolve LSP cancellation on transport failure#1158
datlechin merged 1 commit into
mainfrom
fix/lsp-and-plugin-stale-state-bugs

Conversation

@datlechin

Copy link
Copy Markdown
Member

Summary

Two unrelated MED-severity bugs from the audit, both in the same family of "silent failure leaks state":

B7 — LSPTransport.cancelRequest swallows transport-write errors

cancelRequest(id:) previously did try? writeMessage(data), so a failed write (transport mid-shutdown, broken pipe, etc.) caused the LSP server to never receive the cancellation. The local pendingRequests[id] entry stayed live, leaking a CheckedContinuation that would only resolve when the LSP process terminated.

The fix logs the failure and resolves the pending entry with CancellationError so the local handler unwinds.

func cancelRequest(id: Int) {
    let params: [String: Int] = ["id": id]
    do {
        let data = try JSONEncoder().encode(LSPJSONRPCNotification(method: "$/cancelRequest", params: params))
        try writeMessage(data)
    } catch {
        Self.logger.warning("LSP cancelRequest \(id) failed to send: \(error.localizedDescription); resolving local pending entry")
        if let continuation = pendingRequests.removeValue(forKey: id) {
            continuation.resume(throwing: CancellationError())
        }
    }
}

R7 — PluginManager.autoUpdateRejectedPlugins discards concurrent rejections

The old code snapshotted rejectedPlugins.filter { !$0.isOutdated } at entry, looped through await updateFromRegistry(...) calls that could yield to other main-actor work, then unconditionally assigned rejectedPlugins = stillRejected. Any new rejection entries that arrived during the awaits (e.g. a concurrent manual install failing validateBundleVersions) were dropped.

The fix tracks only the entries the loop processed and merges its outcome with the live rejectedPlugins:

let processedURLs = Set(outdated.map(\.url))
rejectedPlugins = rejectedPlugins.filter { !processedURLs.contains($0.url) } + stillFailed

stillRejected (initial-not-outdated entries plus failed-outdated outcomes) collapses into a focused stillFailed (only the failed-outdated outcomes), since the merge-by-URL filter handles the rest.

Test plan

  • Trigger an AI inline-suggestion request, then immediately cancel before the LSP server replies (set request rate so the cancel races with shutdown). Confirm Console shows the new "LSP cancelRequest ... failed to send" log only when the transport actually fails, and the suggestion does not hang.
  • Auto-update a rejected outdated plugin while concurrently sideloading a different plugin that fails validateBundleVersions. Confirm the new failed sideload remains in the rejected list after auto-update completes.
  • swiftlint --strict clean.

@datlechin datlechin merged commit c9379d3 into main May 9, 2026
2 checks passed
@datlechin datlechin deleted the fix/lsp-and-plugin-stale-state-bugs branch May 9, 2026 17:34
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.

1 participant