shuyi_cli/mcp/aside.rs
1//! A running server's program moved aside, so the next build can write a
2//! fresh one (ticket 2545).
3//!
4//! Windows won't let a running program's file be removed or rewritten,
5//! and cargo must do both to relink; it does let the file be renamed. So
6//! on Windows `shuyi mcp` and `mcp-host` move their own program, and the
7//! hard-linked twin cargo wrote first (in `deps/`, or beside an example),
8//! into `<exe dir>/.shuyi-running/`, and run on from there. A copy of each,
9//! with its modified time, takes its place: no program runs from the copy,
10//! so the next build can replace it, and until then the program is still
11//! there to run (another session's `cargo run`, a test's
12//! `CARGO_BIN_EXE_shuyi`) and cargo sees it fresh.
13//!
14//! The program is never missing from its path, not even for a moment
15//! (ticket 2561): the running file is hard-linked into `.shuyi-running/`
16//! and the copy renamed over the old name, rather than the file renamed
17//! away and the copy renamed in after it. Windows lets a running
18//! program's name be replaced while another name keeps the file.
19//!
20//! That replacing is the one moment left, and it is the system's: on a busy
21//! machine a name being replaced can read as not there for tens of
22//! milliseconds (ticket 2564 saw it in about one move in 40,000, inside the
23//! call, with `fs::rename`, a POSIX-semantics rename and a replacing hard
24//! link alike). No order of the moves avoids it.
25//!
26//! Only a cargo build output has a twin: a `shuyi` installed with `cargo
27//! install` has none and is never moved.
28
29use std::fs;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, SystemTime};
32
33/// The folder, beside the program, its moved files go to.
34pub const RUNNING: &str = ".shuyi-running";
35
36/// The most files one process moves under its pid.
37const NAMES: u32 = 1000;
38
39/// How many times a refused step of a move is tried, and the wait between:
40/// two seconds at most, where a scan was seen to clear in about 30
41/// milliseconds.
42const TRIES: u32 = 200;
43const PAUSE: Duration = Duration::from_millis(10);
44
45/// `ERROR_ACCESS_DENIED` and `ERROR_SHARING_VIOLATION`: what Windows answers
46/// while something else, such as an antivirus scan of a new `.exe`, holds a
47/// file for a moment. Only Windows moves a program aside.
48const BUSY: [i32; 2] = [5, 32];
49
50/// `step`, tried again while the answer is [`BUSY`], at most `tries` times
51/// with `pause` between; any other error, and the last, is returned.
52fn retrying<T>(
53 tries: u32,
54 pause: Duration,
55 mut step: impl FnMut() -> std::io::Result<T>,
56) -> std::io::Result<T> {
57 let mut left = tries;
58 loop {
59 left = left.saturating_sub(1);
60 match step() {
61 Err(e) if left > 0 && e.raw_os_error().is_some_and(|c| BUSY.contains(&c)) => {
62 std::thread::sleep(pause);
63 }
64 answer => return answer,
65 }
66 }
67}
68
69/// The length and modified time of `path`: what a hard link shares.
70fn stamp(path: &Path) -> Option<(u64, SystemTime)> {
71 let meta = fs::metadata(path).ok()?;
72 Some((meta.len(), meta.modified().ok()?))
73}
74
75/// `exe`'s twins: each `<exe stem>-*` with `exe`'s extension, length and
76/// modified time, in `<exe dir>/deps/` (a binary) or beside it (an
77/// example).
78fn twins(exe: &Path, stem: &str) -> Vec<PathBuf> {
79 let (Some(dir), Some(stamp_of_exe)) = (exe.parent(), stamp(exe)) else {
80 return Vec::new();
81 };
82 let prefix = format!("{stem}-");
83 let mut twins: Vec<PathBuf> = [dir.join("deps"), dir.to_path_buf()]
84 .iter()
85 .filter_map(|dir| fs::read_dir(dir).ok())
86 .flatten()
87 .filter_map(Result::ok)
88 .map(|entry| entry.path())
89 .filter(|path| {
90 path.extension() == exe.extension()
91 && path
92 .file_stem()
93 .and_then(|s| s.to_str())
94 .is_some_and(|s| s.starts_with(&prefix))
95 && stamp(path) == Some(stamp_of_exe)
96 })
97 .collect();
98 twins.sort();
99 twins
100}
101
102/// Deletes every file in `dir` that can be deleted; a program still
103/// running from one refuses, and is left.
104pub fn sweep(dir: &Path) {
105 let Ok(entries) = fs::read_dir(dir) else {
106 return;
107 };
108 for entry in entries.filter_map(Result::ok) {
109 let _ = fs::remove_file(entry.path());
110 }
111}
112
113/// Moves `from` to `to`, and a copy of it, with its modified time, to
114/// `from`: the copy is no running program's, so a build can replace it,
115/// and until one does the program is still where cargo left it.
116///
117/// The order keeps a program at `from` at every moment (ticket 2561):
118/// the copy is made first, the running file gains its second name `to`
119/// by a hard link, and the copy is renamed over `from`, which replaces
120/// that name in one step. Renaming `from` away first would leave nothing
121/// there until the copy came, and a program started then (another test,
122/// a person's `cargo shuyi`) would fail to find it.
123///
124/// Each step after the copy is retried while the file is busy (ticket
125/// 2564): the copy is a new `.exe`, which a scan may hold just then, and a
126/// busy machine refused the rename with `Access is denied`. A refusal that
127/// lasts is still returned.
128fn put_aside(from: &Path, to: &Path) -> Result<(), String> {
129 let at = |path: &Path, e: std::io::Error| format!("{}: {e}", path.display());
130 let copy = to.with_extension("copy");
131 fs::copy(from, ©).map_err(|e| at(©, e))?;
132 let modified = fs::metadata(from)
133 .and_then(|meta| meta.modified())
134 .map_err(|e| at(from, e))?;
135 retrying(TRIES, PAUSE, || {
136 fs::File::options()
137 .write(true)
138 .open(©)
139 .and_then(|file| file.set_modified(modified))
140 })
141 .map_err(|e| at(©, e))?;
142 retrying(TRIES, PAUSE, || fs::hard_link(from, to)).map_err(|e| at(to, e))?;
143 retrying(TRIES, PAUSE, || fs::rename(©, from)).map_err(|e| at(from, e))
144}
145
146/// Moves `exe` and its twins into `<exe dir>/.shuyi-running/`,
147/// each as `<stem>-<pid>-<n>` with `exe`'s extension, after sweeping that
148/// folder, and leaves a copy of each in its place: the new paths, none if
149/// `exe` has no twin. Or why not.
150pub fn move_aside(exe: &Path, pid: u32) -> Result<Vec<PathBuf>, String> {
151 let stem = exe
152 .file_stem()
153 .and_then(|s| s.to_str())
154 .ok_or_else(|| format!("{}: no file name", exe.display()))?;
155 let dir = exe
156 .parent()
157 .ok_or_else(|| format!("{}: no folder", exe.display()))?;
158 let twins = twins(exe, stem);
159 if twins.is_empty() {
160 return Ok(Vec::new());
161 }
162 let running = dir.join(RUNNING);
163 sweep(&running);
164 fs::create_dir_all(&running).map_err(|e| format!("{}: {e}", running.display()))?;
165 // The names not taken, in order, each looked at only when it is due.
166 let ext = exe.extension().and_then(|e| e.to_str());
167 let mut names = (0..NAMES)
168 .map(|n| match ext {
169 Some(ext) => running.join(format!("{stem}-{pid}-{n}.{ext}")),
170 None => running.join(format!("{stem}-{pid}-{n}")),
171 })
172 .filter(|to| !to.exists());
173 let mut moved = Vec::new();
174 for from in std::iter::once(exe.to_path_buf()).chain(twins) {
175 let to = names
176 .next()
177 .ok_or_else(|| format!("{}: no free name for {pid}", running.display()))?;
178 put_aside(&from, &to)?;
179 moved.push(to);
180 }
181 Ok(moved)
182}
183
184/// Moves this process's own program aside, saying on stderr if it
185/// couldn't (the server serves either way).
186pub fn this_program() {
187 let moved = std::env::current_exe()
188 .map_err(|e| e.to_string())
189 .and_then(|exe| move_aside(&exe, std::process::id()));
190 if let Err(e) = moved {
191 eprintln!("mcp: couldn't move the program aside: {e}");
192 }
193}
194
195#[cfg(test)]
196mod tests;