Skip to main content

gid_core/
lsp_daemon.rs

1//! LSP Daemon — Persistent language server process manager.
2//!
3//! Keeps rust-analyzer (and other LSP servers) alive between `gid extract` calls
4//! so the expensive cold-start analysis (~5-10 min for large projects) only happens once.
5//!
6//! Architecture:
7//! - First `gid extract --lsp` spawns LSP processes and saves their PIDs to `.gid/lsp-daemon/`
8//! - Subsequent calls reuse the running processes via a proxy that multiplexes stdio
9//! - `gid lsp stop` or timeout (1 hour idle) shuts them down
10//!
11//! Since LSP servers use stdio transport (not sockets), we can't share a running process's
12//! stdin/stdout across invocations. Instead we keep the LspClient in-process and expose
13//! a file-based lock so only one `gid extract` runs LSP at a time, while the client
14//! persists via a long-running background process.
15//!
16//! Simpler approach chosen: **socket-based daemon process**.
17//! A background process owns the LSP servers and accepts commands over a Unix socket.
18//! Each `gid extract` connects to the daemon socket and sends definition/reference queries.
19
20use std::collections::HashMap;
21use std::io::{BufRead, BufReader, Write};
22use std::os::unix::net::{UnixListener, UnixStream};
23use std::path::{Path, PathBuf};
24use std::time::{Duration, Instant};
25
26use anyhow::{bail, Context, Result};
27use serde::{Deserialize, Serialize};
28
29use crate::code_graph::FileDelta;
30use crate::lsp_client::{LspClient, LspLocation, LspServerConfig, extension_to_language_id};
31
32// ═══════════════════════════════════════════════════════════════════════════════
33// Protocol: messages between client and daemon over Unix socket
34// ═══════════════════════════════════════════════════════════════════════════════
35
36#[derive(Debug, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "snake_case")]
38enum DaemonRequest {
39    /// Check if daemon is alive and has a ready LSP for this language.
40    Ping { lang_id: String },
41    /// Open a file in the LSP server.
42    OpenFile {
43        lang_id: String,
44        rel_path: String,
45        content: String,
46    },
47    /// Get definition at position.
48    GetDefinition {
49        lang_id: String,
50        rel_path: String,
51        line: u32,
52        character: u32,
53    },
54    /// Get references at position.
55    GetReferences {
56        lang_id: String,
57        rel_path: String,
58        line: u32,
59        character: u32,
60        include_declaration: bool,
61    },
62    /// Get implementations at position.
63    GetImplementations {
64        lang_id: String,
65        rel_path: String,
66        line: u32,
67        character: u32,
68    },
69    /// Close a file.
70    CloseFile {
71        lang_id: String,
72        rel_path: String,
73    },
74    /// Shut down a specific language server.
75    ShutdownLang { lang_id: String },
76    /// Shut down all servers and exit daemon.
77    ShutdownAll,
78    /// Get status of all managed servers.
79    Status,
80    /// Incrementally refine: notify LSP of added/modified/deleted files.
81    RefineIncremental {
82        added: Vec<String>,
83        modified: Vec<String>,
84        deleted: Vec<String>,
85        root_dir: String,
86    },
87}
88
89#[derive(Debug, Serialize, Deserialize)]
90#[serde(tag = "type", rename_all = "snake_case")]
91enum DaemonResponse {
92    Ok,
93    Pong {
94        ready: bool,
95        uptime_secs: u64,
96    },
97    Definition {
98        location: Option<LocationDto>,
99    },
100    References {
101        locations: Vec<LocationDto>,
102    },
103    Implementations {
104        locations: Vec<LocationDto>,
105    },
106    Status {
107        servers: Vec<ServerStatus>,
108    },
109    Error {
110        message: String,
111    },
112    Refined {
113        files_processed: usize,
114    },
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118struct LocationDto {
119    file_path: String,
120    line: u32,
121    character: u32,
122}
123
124impl From<LspLocation> for LocationDto {
125    fn from(loc: LspLocation) -> Self {
126        Self {
127            file_path: loc.file_path,
128            line: loc.line,
129            character: loc.character,
130        }
131    }
132}
133
134impl From<LocationDto> for LspLocation {
135    fn from(dto: LocationDto) -> Self {
136        Self {
137            file_path: dto.file_path,
138            line: dto.line,
139            character: dto.character,
140        }
141    }
142}
143
144#[derive(Debug, Serialize, Deserialize)]
145struct ServerStatus {
146    lang_id: String,
147    ready: bool,
148    uptime_secs: u64,
149    files_opened: usize,
150}
151
152// ═══════════════════════════════════════════════════════════════════════════════
153// Daemon server — runs as a background process
154// ═══════════════════════════════════════════════════════════════════════════════
155
156struct ManagedServer {
157    client: LspClient,
158    started_at: Instant,
159    last_used: Instant,
160    files_opened: usize,
161}
162
163/// The daemon process that owns LSP servers.
164pub struct LspDaemon {
165    servers: HashMap<String, ManagedServer>,
166    project_root: PathBuf,
167    socket_path: PathBuf,
168    idle_timeout: Duration,
169}
170
171/// Get the daemon socket path for a project.
172pub fn daemon_socket_path(project_root: &Path) -> PathBuf {
173    project_root.join(".gid").join("lsp-daemon.sock")
174}
175
176/// Get the daemon PID file path.
177pub fn daemon_pid_path(project_root: &Path) -> PathBuf {
178    project_root.join(".gid").join("lsp-daemon.pid")
179}
180
181impl LspDaemon {
182    /// Create a new daemon for the given project root.
183    pub fn new(project_root: &Path) -> Self {
184        let socket_path = daemon_socket_path(project_root);
185        Self {
186            servers: HashMap::new(),
187            project_root: project_root.to_path_buf(),
188            socket_path,
189            idle_timeout: Duration::from_secs(3600), // 1 hour idle timeout
190        }
191    }
192
193    /// Start a language server if not already running.
194    pub fn ensure_server(&mut self, lang_id: &str) -> Result<()> {
195        if self.servers.contains_key(lang_id) {
196            return Ok(());
197        }
198
199        let configs = LspServerConfig::detect_available();
200        let config = configs
201            .iter()
202            .find(|c| c.language_id == lang_id)
203            .ok_or_else(|| anyhow::anyhow!("No LSP server available for {}", lang_id))?;
204
205        eprintln!("[LSP daemon] Starting {} server for {}...", lang_id, self.project_root.display());
206        let client = LspClient::start(config, &self.project_root)?;
207
208        self.servers.insert(
209            lang_id.to_string(),
210            ManagedServer {
211                client,
212                started_at: Instant::now(),
213                last_used: Instant::now(),
214                files_opened: 0,
215            },
216        );
217
218        Ok(())
219    }
220
221    /// Wait for a specific server to be ready.
222    pub fn wait_ready(&mut self, lang_id: &str) -> Result<()> {
223        let server = self.servers.get_mut(lang_id)
224            .ok_or_else(|| anyhow::anyhow!("No server for {}", lang_id))?;
225        server.client.wait_until_ready(Duration::from_secs(600))
226    }
227
228    /// Handle a single request.
229    fn handle_request(&mut self, req: DaemonRequest) -> DaemonResponse {
230        match req {
231            DaemonRequest::Ping { lang_id } => {
232                let ready = self.servers.get(&lang_id).is_some();
233                let uptime = self.servers.get(&lang_id)
234                    .map(|s| s.started_at.elapsed().as_secs())
235                    .unwrap_or(0);
236                DaemonResponse::Pong { ready, uptime_secs: uptime }
237            }
238
239            DaemonRequest::OpenFile { lang_id, rel_path, content } => {
240                if let Err(e) = self.ensure_server(&lang_id) {
241                    return DaemonResponse::Error { message: e.to_string() };
242                }
243                let server = self.servers.get_mut(&lang_id).unwrap();
244                server.last_used = Instant::now();
245                match server.client.open_file(&rel_path, &content, &lang_id) {
246                    Ok(()) => {
247                        server.files_opened += 1;
248                        DaemonResponse::Ok
249                    }
250                    Err(e) => DaemonResponse::Error { message: e.to_string() },
251                }
252            }
253
254            DaemonRequest::GetDefinition { lang_id, rel_path, line, character } => {
255                let server = match self.servers.get_mut(&lang_id) {
256                    Some(s) => s,
257                    None => return DaemonResponse::Error {
258                        message: format!("No server for {}", lang_id),
259                    },
260                };
261                server.last_used = Instant::now();
262                match server.client.get_definition(&rel_path, line, character) {
263                    Ok(loc) => DaemonResponse::Definition {
264                        location: loc.map(LocationDto::from),
265                    },
266                    Err(e) => DaemonResponse::Error { message: e.to_string() },
267                }
268            }
269
270            DaemonRequest::GetReferences { lang_id, rel_path, line, character, include_declaration } => {
271                let server = match self.servers.get_mut(&lang_id) {
272                    Some(s) => s,
273                    None => return DaemonResponse::Error {
274                        message: format!("No server for {}", lang_id),
275                    },
276                };
277                server.last_used = Instant::now();
278                match server.client.get_references(&rel_path, line, character, include_declaration) {
279                    Ok(locs) => DaemonResponse::References {
280                        locations: locs.into_iter().map(LocationDto::from).collect(),
281                    },
282                    Err(e) => DaemonResponse::Error { message: e.to_string() },
283                }
284            }
285
286            DaemonRequest::GetImplementations { lang_id, rel_path, line, character } => {
287                let server = match self.servers.get_mut(&lang_id) {
288                    Some(s) => s,
289                    None => return DaemonResponse::Error {
290                        message: format!("No server for {}", lang_id),
291                    },
292                };
293                server.last_used = Instant::now();
294                match server.client.get_implementations(&rel_path, line, character) {
295                    Ok(locs) => DaemonResponse::Implementations {
296                        locations: locs.into_iter().map(LocationDto::from).collect(),
297                    },
298                    Err(e) => DaemonResponse::Error { message: e.to_string() },
299                }
300            }
301
302            DaemonRequest::CloseFile { lang_id, rel_path } => {
303                if let Some(server) = self.servers.get_mut(&lang_id) {
304                    server.last_used = Instant::now();
305                    let _ = server.client.close_file(&rel_path);
306                }
307                DaemonResponse::Ok
308            }
309
310            DaemonRequest::ShutdownLang { lang_id } => {
311                if let Some(server) = self.servers.remove(&lang_id) {
312                    let _ = server.client.shutdown();
313                }
314                DaemonResponse::Ok
315            }
316
317            DaemonRequest::ShutdownAll => {
318                let keys: Vec<String> = self.servers.keys().cloned().collect();
319                for key in keys {
320                    if let Some(server) = self.servers.remove(&key) {
321                        let _ = server.client.shutdown();
322                    }
323                }
324                DaemonResponse::Ok
325            }
326
327            DaemonRequest::Status => {
328                let servers = self.servers.iter().map(|(lang_id, server)| {
329                    ServerStatus {
330                        lang_id: lang_id.clone(),
331                        ready: true,
332                        uptime_secs: server.started_at.elapsed().as_secs(),
333                        files_opened: server.files_opened,
334                    }
335                }).collect();
336                DaemonResponse::Status { servers }
337            }
338
339            DaemonRequest::RefineIncremental { added, modified, deleted, root_dir } => {
340                let root = PathBuf::from(&root_dir);
341                let mut processed: usize = 0;
342
343                // For deleted files: close in all servers that might have them open
344                for rel_path in &deleted {
345                    for server in self.servers.values_mut() {
346                        server.last_used = Instant::now();
347                        let _ = server.client.close_file(rel_path);
348                    }
349                    processed += 1;
350                }
351
352                // For modified files: close then re-open with new content
353                for rel_path in &modified {
354                    let abs_path = root.join(rel_path);
355                    let content = match std::fs::read_to_string(&abs_path) {
356                        Ok(c) => c,
357                        Err(e) => {
358                            eprintln!("[LSP daemon] Failed to read modified file {}: {}", rel_path, e);
359                            continue;
360                        }
361                    };
362                    let ext = Path::new(rel_path)
363                        .extension()
364                        .and_then(|e| e.to_str())
365                        .unwrap_or("");
366                    let lang_id = extension_to_language_id(ext);
367
368                    if let Some(server) = self.servers.get_mut(lang_id) {
369                        server.last_used = Instant::now();
370                        let _ = server.client.close_file(rel_path);
371                        match server.client.open_file(rel_path, &content, lang_id) {
372                            Ok(()) => { server.files_opened += 1; }
373                            Err(e) => {
374                                eprintln!("[LSP daemon] Failed to re-open modified file {}: {}", rel_path, e);
375                                continue;
376                            }
377                        }
378                    }
379                    processed += 1;
380                }
381
382                // For added files: open with content
383                for rel_path in &added {
384                    let abs_path = root.join(rel_path);
385                    let content = match std::fs::read_to_string(&abs_path) {
386                        Ok(c) => c,
387                        Err(e) => {
388                            eprintln!("[LSP daemon] Failed to read added file {}: {}", rel_path, e);
389                            continue;
390                        }
391                    };
392                    let ext = Path::new(rel_path)
393                        .extension()
394                        .and_then(|e| e.to_str())
395                        .unwrap_or("");
396                    let lang_id = extension_to_language_id(ext);
397
398                    if let Err(e) = self.ensure_server(lang_id) {
399                        eprintln!("[LSP daemon] Failed to ensure server for {}: {}", lang_id, e);
400                        continue;
401                    }
402
403                    if let Some(server) = self.servers.get_mut(lang_id) {
404                        server.last_used = Instant::now();
405                        match server.client.open_file(rel_path, &content, lang_id) {
406                            Ok(()) => { server.files_opened += 1; }
407                            Err(e) => {
408                                eprintln!("[LSP daemon] Failed to open added file {}: {}", rel_path, e);
409                                continue;
410                            }
411                        }
412                    }
413                    processed += 1;
414                }
415
416                DaemonResponse::Refined { files_processed: processed }
417            }
418        }
419    }
420
421    /// Run the daemon, listening on a Unix socket.
422    pub fn run(&mut self) -> Result<()> {
423        // Clean up stale socket
424        if self.socket_path.exists() {
425            std::fs::remove_file(&self.socket_path)?;
426        }
427
428        // Ensure .gid directory exists
429        if let Some(parent) = self.socket_path.parent() {
430            std::fs::create_dir_all(parent)?;
431        }
432
433        // Write PID file
434        let pid_path = daemon_pid_path(&self.project_root);
435        std::fs::write(&pid_path, std::process::id().to_string())?;
436
437        let listener = UnixListener::bind(&self.socket_path)
438            .with_context(|| format!("bind socket: {}", self.socket_path.display()))?;
439
440        // Set non-blocking so we can check idle timeout
441        listener.set_nonblocking(true)?;
442
443        eprintln!("[LSP daemon] Listening on {}", self.socket_path.display());
444
445        let mut last_activity = Instant::now();
446
447        loop {
448            // Check idle timeout
449            if !self.servers.is_empty() {
450                let all_idle = self.servers.values()
451                    .all(|s| s.last_used.elapsed() > self.idle_timeout);
452                if all_idle {
453                    eprintln!("[LSP daemon] All servers idle for {}s, shutting down",
454                        self.idle_timeout.as_secs());
455                    break;
456                }
457            } else if last_activity.elapsed() > Duration::from_secs(300) {
458                // No servers and no activity for 5 min
459                eprintln!("[LSP daemon] No servers and idle for 5 min, shutting down");
460                break;
461            }
462
463            match listener.accept() {
464                Ok((stream, _)) => {
465                    last_activity = Instant::now();
466                    if let Err(e) = self.handle_connection(stream) {
467                        eprintln!("[LSP daemon] Connection error: {}", e);
468                    }
469                }
470                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
471                    // No connection pending, sleep briefly
472                    std::thread::sleep(Duration::from_millis(100));
473                }
474                Err(e) => {
475                    eprintln!("[LSP daemon] Accept error: {}", e);
476                    break;
477                }
478            }
479        }
480
481        // Cleanup
482        self.handle_request(DaemonRequest::ShutdownAll);
483        let _ = std::fs::remove_file(&self.socket_path);
484        let _ = std::fs::remove_file(&pid_path);
485
486        eprintln!("[LSP daemon] Stopped.");
487        Ok(())
488    }
489
490    fn handle_connection(&mut self, stream: UnixStream) -> Result<()> {
491        stream.set_read_timeout(Some(Duration::from_secs(60)))?;
492        stream.set_write_timeout(Some(Duration::from_secs(60)))?;
493
494        let mut reader = BufReader::new(stream.try_clone()?);
495        let mut writer = stream;
496
497        // Read requests until connection closes
498        loop {
499            let mut line = String::new();
500            match reader.read_line(&mut line) {
501                Ok(0) => break, // Connection closed
502                Ok(_) => {}
503                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock
504                    || e.kind() == std::io::ErrorKind::TimedOut => break,
505                Err(e) => return Err(e.into()),
506            }
507
508            let line = line.trim();
509            if line.is_empty() {
510                continue;
511            }
512
513            let req: DaemonRequest = match serde_json::from_str(line) {
514                Ok(r) => r,
515                Err(e) => {
516                    let resp = DaemonResponse::Error {
517                        message: format!("Invalid request: {}", e),
518                    };
519                    let mut resp_line = serde_json::to_string(&resp)?;
520                    resp_line.push('\n');
521                    writer.write_all(resp_line.as_bytes())?;
522                    writer.flush()?;
523                    continue;
524                }
525            };
526
527            let is_shutdown_all = matches!(req, DaemonRequest::ShutdownAll);
528            let resp = self.handle_request(req);
529
530            let mut resp_line = serde_json::to_string(&resp)?;
531            resp_line.push('\n');
532            writer.write_all(resp_line.as_bytes())?;
533            writer.flush()?;
534
535            if is_shutdown_all {
536                return Ok(());
537            }
538        }
539
540        Ok(())
541    }
542}
543
544// ═══════════════════════════════════════════════════════════════════════════════
545// Daemon client — used by `gid extract` to talk to the daemon
546// ═══════════════════════════════════════════════════════════════════════════════
547
548/// Client that connects to a running LSP daemon via Unix socket.
549/// Implements the same interface as LspClient for drop-in use.
550pub struct DaemonLspClient {
551    stream: BufReader<UnixStream>,
552    writer: UnixStream,
553    lang_id: String,
554}
555
556impl DaemonLspClient {
557    /// Connect to a running daemon.
558    pub fn connect(project_root: &Path, lang_id: &str) -> Result<Self> {
559        let socket_path = daemon_socket_path(project_root);
560        let stream = UnixStream::connect(&socket_path)
561            .with_context(|| format!("connect to LSP daemon at {}", socket_path.display()))?;
562        stream.set_read_timeout(Some(Duration::from_secs(120)))?;
563        stream.set_write_timeout(Some(Duration::from_secs(30)))?;
564
565        Ok(Self {
566            stream: BufReader::new(stream.try_clone()?),
567            writer: stream,
568            lang_id: lang_id.to_string(),
569        })
570    }
571
572    fn send_recv(&mut self, req: DaemonRequest) -> Result<DaemonResponse> {
573        let mut line = serde_json::to_string(&req)?;
574        line.push('\n');
575        self.writer.write_all(line.as_bytes())?;
576        self.writer.flush()?;
577
578        let mut resp_line = String::new();
579        self.stream.read_line(&mut resp_line)?;
580
581        let resp: DaemonResponse = serde_json::from_str(resp_line.trim())?;
582        Ok(resp)
583    }
584
585    /// Check if the daemon has a ready server for our language.
586    pub fn ping(&mut self) -> Result<bool> {
587        match self.send_recv(DaemonRequest::Ping { lang_id: self.lang_id.clone() })? {
588            DaemonResponse::Pong { ready, .. } => Ok(ready),
589            DaemonResponse::Error { message } => bail!("Daemon error: {}", message),
590            _ => bail!("Unexpected response to ping"),
591        }
592    }
593
594    /// Open a file.
595    pub fn open_file(&mut self, rel_path: &str, content: &str) -> Result<()> {
596        match self.send_recv(DaemonRequest::OpenFile {
597            lang_id: self.lang_id.clone(),
598            rel_path: rel_path.to_string(),
599            content: content.to_string(),
600        })? {
601            DaemonResponse::Ok => Ok(()),
602            DaemonResponse::Error { message } => bail!("{}", message),
603            _ => bail!("Unexpected response"),
604        }
605    }
606
607    /// Get definition.
608    pub fn get_definition(&mut self, rel_path: &str, line: u32, character: u32) -> Result<Option<LspLocation>> {
609        match self.send_recv(DaemonRequest::GetDefinition {
610            lang_id: self.lang_id.clone(),
611            rel_path: rel_path.to_string(),
612            line,
613            character,
614        })? {
615            DaemonResponse::Definition { location } => Ok(location.map(LspLocation::from)),
616            DaemonResponse::Error { message } => bail!("{}", message),
617            _ => bail!("Unexpected response"),
618        }
619    }
620
621    /// Get references.
622    pub fn get_references(
623        &mut self,
624        rel_path: &str,
625        line: u32,
626        character: u32,
627        include_declaration: bool,
628    ) -> Result<Vec<LspLocation>> {
629        match self.send_recv(DaemonRequest::GetReferences {
630            lang_id: self.lang_id.clone(),
631            rel_path: rel_path.to_string(),
632            line,
633            character,
634            include_declaration,
635        })? {
636            DaemonResponse::References { locations } => {
637                Ok(locations.into_iter().map(LspLocation::from).collect())
638            }
639            DaemonResponse::Error { message } => bail!("{}", message),
640            _ => bail!("Unexpected response"),
641        }
642    }
643
644    /// Get implementations.
645    pub fn get_implementations(&mut self, rel_path: &str, line: u32, character: u32) -> Result<Vec<LspLocation>> {
646        match self.send_recv(DaemonRequest::GetImplementations {
647            lang_id: self.lang_id.clone(),
648            rel_path: rel_path.to_string(),
649            line,
650            character,
651        })? {
652            DaemonResponse::Implementations { locations } => {
653                Ok(locations.into_iter().map(LspLocation::from).collect())
654            }
655            DaemonResponse::Error { message } => bail!("{}", message),
656            _ => bail!("Unexpected response"),
657        }
658    }
659
660    /// Close a file.
661    pub fn close_file(&mut self, rel_path: &str) -> Result<()> {
662        let _ = self.send_recv(DaemonRequest::CloseFile {
663            lang_id: self.lang_id.clone(),
664            rel_path: rel_path.to_string(),
665        });
666        Ok(())
667    }
668
669    /// Incrementally refine: notify the daemon of file changes from a FileDelta.
670    /// The daemon will close deleted files, re-open modified files, and open added files
671    /// in the appropriate LSP servers.
672    /// Returns the number of files processed.
673    pub fn refine_incremental(&mut self, delta: &FileDelta, root_dir: &Path) -> Result<usize> {
674        match self.send_recv(DaemonRequest::RefineIncremental {
675            added: delta.added.clone(),
676            modified: delta.modified.clone(),
677            deleted: delta.deleted.clone(),
678            root_dir: root_dir.to_string_lossy().to_string(),
679        })? {
680            DaemonResponse::Refined { files_processed } => Ok(files_processed),
681            DaemonResponse::Error { message } => bail!("Refine incremental error: {}", message),
682            _ => bail!("Unexpected response to refine_incremental"),
683        }
684    }
685}
686
687// ═══════════════════════════════════════════════════════════════════════════════
688// Helpers for gid extract integration
689// ═══════════════════════════════════════════════════════════════════════════════
690
691/// Check if a daemon is already running for this project.
692pub fn is_daemon_running(project_root: &Path) -> bool {
693    let socket_path = daemon_socket_path(project_root);
694
695    if !socket_path.exists() {
696        return false;
697    }
698
699    // Try connecting — if it works, daemon is alive
700    if UnixStream::connect(&socket_path).is_ok() {
701        return true;
702    }
703
704    // Stale socket, clean up
705    let _ = std::fs::remove_file(&socket_path);
706    let _ = std::fs::remove_file(&daemon_pid_path(project_root));
707    false
708}
709
710/// Start daemon as a background process (fork).
711/// Returns Ok(true) if we started a new daemon, Ok(false) if one was already running.
712pub fn ensure_daemon(project_root: &Path) -> Result<bool> {
713    if is_daemon_running(project_root) {
714        return Ok(false);
715    }
716
717    eprintln!("[LSP] Starting daemon for {}...", project_root.display());
718
719    // Fork a background process
720    // We use std::process::Command to spawn ourselves with a special flag
721    // But since we can't easily re-exec, we'll use the simpler approach:
722    // spawn a thread that runs the daemon in the background
723    let root = project_root.to_path_buf();
724    std::thread::spawn(move || {
725        let mut daemon = LspDaemon::new(&root);
726        if let Err(e) = daemon.run() {
727            eprintln!("[LSP daemon] Error: {}", e);
728        }
729    });
730
731    // Wait for socket to appear
732    let socket_path = daemon_socket_path(project_root);
733    let deadline = Instant::now() + Duration::from_secs(5);
734    while Instant::now() < deadline {
735        if socket_path.exists() {
736            return Ok(true);
737        }
738        std::thread::sleep(Duration::from_millis(100));
739    }
740
741    bail!("Daemon did not start within 5 seconds")
742}
743
744/// Start the daemon, wait for LSP server to be ready, then return a client.
745/// This is the main entry point for `gid extract --lsp`.
746pub fn get_or_start_daemon_client(
747    project_root: &Path,
748    lang_id: &str,
749) -> Result<DaemonLspClient> {
750    ensure_daemon(project_root)?;
751    DaemonLspClient::connect(project_root, lang_id)
752}
753
754/// Stop the daemon for a project.
755pub fn stop_daemon(project_root: &Path) -> Result<()> {
756    if !is_daemon_running(project_root) {
757        return Ok(());
758    }
759
760    let socket_path = daemon_socket_path(project_root);
761    if let Ok(stream) = UnixStream::connect(&socket_path) {
762        stream.set_write_timeout(Some(Duration::from_secs(5)))?;
763        let mut writer = stream;
764        let req = serde_json::to_string(&DaemonRequest::ShutdownAll)?;
765        let _ = writer.write_all(format!("{}\n", req).as_bytes());
766        let _ = writer.flush();
767    }
768
769    // Wait for cleanup
770    std::thread::sleep(Duration::from_millis(500));
771
772    // Force cleanup if needed
773    let _ = std::fs::remove_file(&socket_path);
774    let _ = std::fs::remove_file(&daemon_pid_path(project_root));
775
776    Ok(())
777}