Skip to main content

deps_lsp/
progress.rs

1//! LSP Work Done Progress protocol support for loading indicators.
2//!
3//! Uses a channel-based architecture to decouple progress producers (fetch tasks)
4//! from the LSP transport consumer, preventing backpressure from blocking fetches.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌─────────────┐     mpsc channel     ┌──────────────┐     LSP transport
10//! │ fetch task 1 │──┐                   │              │──────────────────►
11//! │ fetch task 2 │──┼── ProgressUpdate ──► progress    │  send_notification
12//! │ fetch task N │──┘                   │   task       │──────────────────►
13//! └─────────────┘                       └──────────────┘
14//! ```
15//!
16//! # Protocol Flow
17//!
18//! 1. `window/workDoneProgress/create` - Request token creation
19//! 2. `$/progress` with `WorkDoneProgressBegin` - Start indicator
20//! 3. `$/progress` with `WorkDoneProgressReport` - Update progress (via channel)
21//! 4. `$/progress` with `WorkDoneProgressEnd` - Complete indicator
22
23use tokio::sync::mpsc;
24use tower_lsp_server::Client;
25use tower_lsp_server::jsonrpc::Result;
26use tower_lsp_server::ls_types::{
27    ProgressParams, ProgressParamsValue, ProgressToken, WorkDoneProgress, WorkDoneProgressBegin,
28    WorkDoneProgressEnd, WorkDoneProgressReport,
29};
30
31/// Channel capacity for progress updates.
32/// Small buffer is sufficient since updates are coalesced by the editor.
33const PROGRESS_CHANNEL_CAPACITY: usize = 8;
34
35/// Non-blocking sender for progress updates from fetch tasks.
36///
37/// Cheap to clone and safe to use from multiple concurrent futures.
38/// Dropped messages are acceptable — progress is best-effort UI feedback.
39#[derive(Clone)]
40pub struct ProgressSender {
41    tx: mpsc::Sender<ProgressUpdate>,
42    total: usize,
43}
44
45struct ProgressUpdate {
46    fetched: usize,
47    total: usize,
48}
49
50impl ProgressSender {
51    /// Send a progress update without blocking.
52    ///
53    /// Uses `try_send` — if the channel is full, the update is silently dropped.
54    /// This is intentional: progress is best-effort UI feedback, and dropping
55    /// updates is always preferable to blocking fetch tasks.
56    pub fn send(&self, fetched: usize) {
57        let _ = self.tx.try_send(ProgressUpdate {
58            fetched,
59            total: self.total,
60        });
61    }
62}
63
64/// Progress tracker for registry data fetching.
65///
66/// Owns the LSP progress lifecycle (begin → report → end).
67/// Creates a [`ProgressSender`] for non-blocking updates from fetch tasks.
68pub struct RegistryProgress {
69    client: Client,
70    token: ProgressToken,
71    active: bool,
72    /// Background task draining progress updates.
73    /// Dropped when `RegistryProgress` is dropped or `end()` is called.
74    _consumer_handle: tokio::task::JoinHandle<()>,
75}
76
77impl RegistryProgress {
78    /// Create and start a new progress indicator.
79    ///
80    /// Returns both the progress tracker and a [`ProgressSender`] for
81    /// non-blocking updates from fetch tasks.
82    ///
83    /// Callers wrap this in a short timeout so a slow/unresponsive client can't
84    /// stall a fetch. If the timeout fires while the `create` round-trip is
85    /// still pending after the request bytes already reached the client, the
86    /// client may register a token this call never learns about and therefore
87    /// never sends `begin`/`end` for. This is an accepted trade-off: no `begin`
88    /// means spec-compliant clients show no UI for it, so the only cost is a
89    /// harmless dangling token client-side.
90    pub async fn start(
91        client: Client,
92        uri: &str,
93        total_deps: usize,
94    ) -> Result<(Self, ProgressSender)> {
95        let token = ProgressToken::String(format!("deps-fetch-{}", uri));
96
97        // Request progress token creation (blocking request to client)
98        client
99            .send_request::<tower_lsp_server::ls_types::request::WorkDoneProgressCreate>(
100                tower_lsp_server::ls_types::WorkDoneProgressCreateParams {
101                    token: token.clone(),
102                },
103            )
104            .await?;
105
106        // Send begin notification
107        client
108            .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
109                ProgressParams {
110                    token: token.clone(),
111                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
112                        WorkDoneProgressBegin {
113                            title: "Fetching package versions".to_string(),
114                            message: Some(format!("Loading {} dependencies...", total_deps)),
115                            cancellable: Some(false),
116                            percentage: Some(0),
117                        },
118                    )),
119                },
120            )
121            .await;
122
123        let (tx, rx) = mpsc::channel(PROGRESS_CHANNEL_CAPACITY);
124
125        // Spawn consumer task that drains the channel and sends LSP notifications
126        let consumer_client = client.clone();
127        let consumer_token = token.clone();
128        let consumer_handle = tokio::spawn(async move {
129            consume_progress_updates(rx, consumer_client, consumer_token).await;
130        });
131
132        let sender = ProgressSender {
133            tx,
134            total: total_deps,
135        };
136
137        Ok((
138            Self {
139                client,
140                token,
141                active: true,
142                _consumer_handle: consumer_handle,
143            },
144            sender,
145        ))
146    }
147
148    /// End progress indicator.
149    pub async fn end(mut self, success: bool) {
150        if !self.active {
151            return;
152        }
153
154        self.active = false;
155
156        // Abort the consumer task — remaining updates are irrelevant after end
157        self._consumer_handle.abort();
158
159        let message = if success {
160            "Package versions loaded"
161        } else {
162            "Failed to fetch some versions"
163        };
164
165        self.client
166            .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
167                ProgressParams {
168                    token: self.token.clone(),
169                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
170                        WorkDoneProgressEnd {
171                            message: Some(message.to_string()),
172                        },
173                    )),
174                },
175            )
176            .await;
177    }
178}
179
180/// Drains progress updates from the channel and sends LSP notifications.
181async fn consume_progress_updates(
182    mut rx: mpsc::Receiver<ProgressUpdate>,
183    client: Client,
184    token: ProgressToken,
185) {
186    while let Some(update) = rx.recv().await {
187        let percentage = if update.total > 0 {
188            ((update.fetched as f64 / update.total as f64) * 100.0) as u32
189        } else {
190            0
191        };
192
193        client
194            .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
195                ProgressParams {
196                    token: token.clone(),
197                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(
198                        WorkDoneProgressReport {
199                            message: Some(format!(
200                                "Fetched {}/{} packages",
201                                update.fetched, update.total
202                            )),
203                            percentage: Some(percentage),
204                            cancellable: Some(false),
205                        },
206                    )),
207                },
208            )
209            .await;
210    }
211}
212
213/// Ensure progress is cleaned up on drop
214impl Drop for RegistryProgress {
215    fn drop(&mut self) {
216        if self.active {
217            tracing::warn!(
218                token = ?self.token,
219                "RegistryProgress dropped without explicit end() - spawning cleanup"
220            );
221            self._consumer_handle.abort();
222            let client = self.client.clone();
223            let token = self.token.clone();
224            tokio::spawn(async move {
225                client
226                    .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
227                        ProgressParams {
228                            token,
229                            value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
230                                WorkDoneProgressEnd { message: None },
231                            )),
232                        },
233                    )
234                    .await;
235            });
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    #[test]
243    fn test_progress_token_format() {
244        let uri = "file:///test/Cargo.toml";
245        let token = format!("deps-fetch-{}", uri);
246        assert_eq!(token, "deps-fetch-file:///test/Cargo.toml");
247    }
248
249    #[test]
250    fn test_percentage_calculation() {
251        let calculate = |fetched: usize, total: usize| -> u32 {
252            if total == 0 {
253                return 0;
254            }
255            ((fetched as f64 / total as f64) * 100.0) as u32
256        };
257
258        assert_eq!(calculate(0, 10), 0);
259        assert_eq!(calculate(5, 10), 50);
260        assert_eq!(calculate(10, 10), 100);
261        assert_eq!(calculate(7, 10), 70);
262        assert_eq!(calculate(0, 0), 0);
263    }
264
265    #[test]
266    fn test_progress_message_format() {
267        let format_message = |fetched: usize, total: usize| -> String {
268            format!("Fetched {}/{} packages", fetched, total)
269        };
270
271        assert_eq!(format_message(5, 10), "Fetched 5/10 packages");
272        assert_eq!(format_message(0, 15), "Fetched 0/15 packages");
273        assert_eq!(format_message(20, 20), "Fetched 20/20 packages");
274    }
275
276    #[tokio::test]
277    async fn test_progress_sender_try_send_on_closed_channel() {
278        use super::*;
279
280        let (tx, rx) = mpsc::channel(1);
281        let sender = ProgressSender { tx, total: 10 };
282
283        // Drop receiver — channel is closed
284        drop(rx);
285
286        // Should not panic
287        sender.send(5);
288    }
289
290    #[tokio::test]
291    async fn test_progress_sender_try_send_on_full_channel() {
292        use super::*;
293
294        let (tx, _rx) = mpsc::channel(1);
295        let sender = ProgressSender { tx, total: 10 };
296
297        // Fill the channel
298        sender.send(1);
299        // Should silently drop — channel is full
300        sender.send(2);
301        sender.send(3);
302    }
303}