Skip to main content

lattice_ai/mcp/
lockfile.rs

1//! Discovery lockfile: `~/.claude/ide/<port>.lock`.
2//!
3//! An attaching `claude` CLI discovers a running IDE by scanning
4//! `~/.claude/ide/` for `<port>.lock` files and reading the connection
5//! details (port from the filename, auth token + workspace from the JSON
6//! body). The lockfile is RAII: written on `start`, unlinked on `Drop`,
7//! so a crashed or stopped server leaves no stale discovery entry.
8//!
9//! NOTE: the exact JSON field set mirrors the VS Code IDE-integration
10//! schema and is **provisional** — verified against a live `claude` CLI
11//! before the terminal-launch slice (I5, Risk 1).
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18use crate::mcp::error::Result;
19
20/// Body of a `<port>.lock` discovery file. `camelCase` on the wire to
21/// match the VS Code schema the `claude` CLI expects.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "camelCase")]
24pub struct LockfileContents {
25    /// PID of the editor process hosting the IDE server.
26    pub pid: u32,
27    /// Absolute paths of the open workspace folders.
28    pub workspace_folders: Vec<String>,
29    /// Human-readable IDE name shown by the agent.
30    pub ide_name: String,
31    /// Transport discriminator; always `"ws"` for the loopback WebSocket.
32    pub transport: String,
33    /// Per-session authorization token (also required in the handshake
34    /// header). See [`crate::mcp::auth`].
35    pub auth_token: String,
36    /// Whether the host runs on Windows (affects path handling on the
37    /// agent side). Defaults to `false` when absent.
38    #[serde(default)]
39    pub running_in_windows: bool,
40}
41
42/// The default discovery directory, `~/.claude/ide`. `None` when the home
43/// directory can't be resolved (the caller logs-and-skips).
44pub fn default_lock_dir() -> Option<PathBuf> {
45    dirs::home_dir().map(|h| h.join(".claude").join("ide"))
46}
47
48/// An RAII discovery lockfile. Created by [`write`](Self::write); the file
49/// is removed when this value is dropped.
50#[derive(Debug)]
51pub struct Lockfile {
52    path: PathBuf,
53}
54
55impl Lockfile {
56    /// Write `<port>.lock` under `dir` (creating `dir` if needed) with the
57    /// given contents, returning the RAII handle.
58    pub fn write(dir: &Path, port: u16, contents: &LockfileContents) -> Result<Self> {
59        fs::create_dir_all(dir)?;
60        let path = dir.join(format!("{port}.lock"));
61        let json = serde_json::to_vec_pretty(contents)?;
62        fs::write(&path, json)?;
63        Ok(Self { path })
64    }
65
66    /// Path of the written lockfile.
67    pub fn path(&self) -> &Path {
68        &self.path
69    }
70}
71
72impl Drop for Lockfile {
73    fn drop(&mut self) {
74        // Log-and-skip: a failed unlink (already removed, perms) must
75        // never panic on teardown.
76        if let Err(e) = fs::remove_file(&self.path) {
77            tracing::debug!(path = %self.path.display(), error = %e, "lockfile unlink failed");
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    #![allow(clippy::unwrap_used, clippy::panic)]
85    use super::*;
86    use std::sync::atomic::{AtomicU32, Ordering};
87
88    fn unique_dir(tag: &str) -> PathBuf {
89        static COUNTER: AtomicU32 = AtomicU32::new(0);
90        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
91        std::env::temp_dir().join(format!("lattice-cc-{}-{}-{}", tag, std::process::id(), n))
92    }
93
94    fn sample(port_token: &str) -> LockfileContents {
95        LockfileContents {
96            pid: 4242,
97            workspace_folders: vec!["/work/project".to_string()],
98            ide_name: "Lattice".to_string(),
99            transport: "ws".to_string(),
100            auth_token: port_token.to_string(),
101            running_in_windows: false,
102        }
103    }
104
105    #[test]
106    fn round_trips_through_disk() {
107        let dir = unique_dir("roundtrip");
108        let contents = sample("deadbeefcafef00ddeadbeefcafef00d");
109        let lock = Lockfile::write(&dir, 12345, &contents).expect("write");
110        assert!(lock.path().exists());
111        assert_eq!(lock.path().file_name().unwrap(), "12345.lock");
112
113        let raw = std::fs::read(lock.path()).expect("read");
114        let parsed: LockfileContents = serde_json::from_slice(&raw).expect("parse");
115        assert_eq!(parsed, contents);
116        // Camel-case key present on the wire.
117        let text = String::from_utf8(raw).unwrap();
118        assert!(text.contains("\"authToken\""));
119        assert!(text.contains("\"workspaceFolders\""));
120
121        drop(lock);
122        let _ = std::fs::remove_dir_all(&dir);
123    }
124
125    #[test]
126    fn unlinks_on_drop() {
127        let dir = unique_dir("unlink");
128        let path = {
129            let lock = Lockfile::write(&dir, 23456, &sample("token")).expect("write");
130            let p = lock.path().to_path_buf();
131            assert!(p.exists());
132            p
133            // lock dropped here
134        };
135        assert!(!path.exists(), "lockfile should be removed on Drop");
136        let _ = std::fs::remove_dir_all(&dir);
137    }
138}