R4.12 (user report). The copy holding the instance lock opens a local door guarded by a random token in its data folder; a second copy sends its file paths there, lets it take the foreground, and exits 0 instead of showing "already running". Files join the ely_ui::on_open queue every app already uses; the main window comes to the front. Also records the R4.10 round 1 verdict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
185 lines
7.2 KiB
Rust
185 lines
7.2 KiB
Rust
//! One running copy per app; two would wipe shared data.
|
|
|
|
mod door;
|
|
|
|
use std::{
|
|
fs::File,
|
|
io,
|
|
path::{Path, PathBuf},
|
|
sync::OnceLock,
|
|
time::Duration,
|
|
};
|
|
|
|
pub use door::Door;
|
|
|
|
use crate::AppId;
|
|
|
|
/// The lock this process holds while it runs.
|
|
static HELD: OnceLock<File> = OnceLock::new();
|
|
|
|
/// How long a second copy waits for the door.
|
|
const WAIT: Duration = Duration::from_secs(10);
|
|
|
|
/// What starting a copy found.
|
|
pub enum Start {
|
|
/// This copy runs; its door takes later copies' files.
|
|
First(Door),
|
|
/// A running copy took the files; this one ends.
|
|
Handed,
|
|
}
|
|
|
|
/// Takes `app`'s lock, or hands `files` to its holder.
|
|
pub fn hold(app: AppId, files: &[PathBuf]) -> io::Result<Start> {
|
|
let dir = crate::data_dir(app).map_err(io::Error::other)?;
|
|
std::fs::create_dir_all(&dir)?;
|
|
start(app, &dir, files)
|
|
}
|
|
|
|
fn start(app: AppId, dir: &Path, files: &[PathBuf]) -> io::Result<Start> {
|
|
match take(app, dir) {
|
|
Ok(()) => Ok(Start::First(Door::open(dir)?)),
|
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match door::hand(dir, files, WAIT) {
|
|
Ok(()) => Ok(Start::Handed),
|
|
Err(handing) => Err(io::Error::new(handing.kind(), format!("{error} and did not take the files: {handing}"))),
|
|
},
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
fn take(app: AppId, dir: &Path) -> io::Result<()> {
|
|
let file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(dir.join("instance.lock"))?;
|
|
match lock(&file)? {
|
|
None => HELD.set(file).map_err(|_| io::Error::other("this process took its instance lock twice")),
|
|
Some(holder) => Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} is already running ({holder})", app.name()))),
|
|
}
|
|
}
|
|
|
|
/// Locks `file`, or names its holder as the OS reports.
|
|
#[cfg(unix)]
|
|
fn lock(file: &File) -> io::Result<Option<String>> {
|
|
use std::os::unix::io::AsRawFd;
|
|
let fd = file.as_raw_fd();
|
|
loop {
|
|
let mut want: libc::flock = unsafe { std::mem::zeroed() };
|
|
want.l_type = libc::F_WRLCK as _;
|
|
want.l_whence = libc::SEEK_SET as _;
|
|
if unsafe { libc::fcntl(fd, libc::F_SETLK, &want) } == 0 {
|
|
return Ok(None);
|
|
}
|
|
let error = io::Error::last_os_error();
|
|
if !matches!(error.raw_os_error(), Some(libc::EACCES | libc::EAGAIN)) {
|
|
return Err(error);
|
|
}
|
|
let mut held = want;
|
|
if unsafe { libc::fcntl(fd, libc::F_GETLK, &mut held) } != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
// Let go between the two calls: try again.
|
|
if i64::from(held.l_type) != i64::from(libc::F_UNLCK) {
|
|
return Ok(Some(format!("process {}", held.l_pid)));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Windows names no holder; the lock alone refuses.
|
|
#[cfg(windows)]
|
|
fn lock(file: &File) -> io::Result<Option<String>> {
|
|
use std::os::windows::io::AsRawHandle;
|
|
use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx};
|
|
const ERROR_LOCK_VIOLATION: i32 = 33;
|
|
let flags = LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY;
|
|
let mut overlapped = unsafe { std::mem::zeroed() };
|
|
match unsafe { LockFileEx(file.as_raw_handle(), flags, 0, u32::MAX, u32::MAX, &mut overlapped) } {
|
|
0 if io::Error::last_os_error().raw_os_error() == Some(ERROR_LOCK_VIOLATION) => Ok(Some("another copy holds its lock".into())),
|
|
0 => Err(io::Error::last_os_error()),
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn copies_started_together_name_the_one_holder() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let exe = std::env::current_exe().expect("this test");
|
|
let copies: Vec<_> = (0..6)
|
|
.map(|_| {
|
|
std::process::Command::new(&exe)
|
|
.args(["--exact", "instance::tests::one_copy", "--nocapture"])
|
|
.env("ELY_INSTANCE_DIR", dir.path())
|
|
.stdout(std::process::Stdio::piped())
|
|
.spawn()
|
|
.expect("a copy")
|
|
})
|
|
.collect();
|
|
let said: Vec<String> = copies
|
|
.into_iter()
|
|
.map(|copy| String::from_utf8_lossy(©.wait_with_output().expect("ends").stdout).into_owned())
|
|
.collect();
|
|
let holders: Vec<&String> = said.iter().filter(|out| out.contains("HELD")).collect();
|
|
assert_eq!(holders.len(), 1, "{said:?}");
|
|
let holder = holders[0].split("HELD ").nth(1).and_then(|rest| rest.split_whitespace().next()).expect("its pid");
|
|
let refused: Vec<&String> = said.iter().filter(|out| out.contains("REFUSED")).collect();
|
|
assert_eq!(refused.len(), 5, "{said:?}");
|
|
// Unix names the holder; Windows cannot ask.
|
|
let named = if cfg!(unix) { format!("(process {holder})") } else { "(another copy holds its lock)".into() };
|
|
for out in refused {
|
|
assert!(out.contains(&named), "{out}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_copy_hands_its_files_to_the_first() {
|
|
use std::io::BufRead;
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let exe = std::env::current_exe().expect("this test");
|
|
let mut first = std::process::Command::new(&exe)
|
|
.args(["--exact", "instance::tests::first_copy", "--nocapture"])
|
|
.env("ELY_FIRST_DIR", dir.path())
|
|
.stdout(std::process::Stdio::piped())
|
|
.spawn()
|
|
.expect("the first copy");
|
|
let mut said = std::io::BufReader::new(first.stdout.take().expect("its output")).lines().map(|line| line.expect("a line"));
|
|
assert!(said.by_ref().any(|line| line.contains("HELD")), "the first copy did not start");
|
|
let file = std::path::PathBuf::from("C:/notes/a b.md");
|
|
let second = start(AppId::Doc, dir.path(), std::slice::from_ref(&file)).expect("handed over");
|
|
assert!(matches!(second, Start::Handed));
|
|
let got = said.find(|line| line.contains("GOT"));
|
|
assert_eq!(got.as_deref().and_then(|line| line.split("GOT ").nth(1)), Some(file.to_str().expect("text")));
|
|
assert!(first.wait().expect("ends").success());
|
|
}
|
|
|
|
/// The first copy: holds, then reports one hand-over.
|
|
#[test]
|
|
fn first_copy() {
|
|
let Ok(dir) = std::env::var("ELY_FIRST_DIR") else {
|
|
return;
|
|
};
|
|
let Ok(Start::First(door)) = start(AppId::Doc, Path::new(&dir), &[]) else {
|
|
panic!("the first copy did not hold");
|
|
};
|
|
let (sender, heard) = std::sync::mpsc::channel();
|
|
door.serve(move |files| sender.send(files).expect("heard")).expect("served");
|
|
println!("HELD");
|
|
let files = heard.recv_timeout(std::time::Duration::from_secs(20)).expect("a hand-over");
|
|
println!("GOT {}", files[0].display());
|
|
}
|
|
|
|
/// One copy, in a process of its own.
|
|
#[test]
|
|
fn one_copy() {
|
|
let Ok(dir) = std::env::var("ELY_INSTANCE_DIR") else {
|
|
return;
|
|
};
|
|
match take(AppId::Doc, Path::new(&dir)) {
|
|
Ok(()) => {
|
|
println!("HELD {} ", std::process::id());
|
|
std::thread::sleep(std::time::Duration::from_millis(1500));
|
|
}
|
|
Err(error) => println!("REFUSED {error}"),
|
|
}
|
|
}
|
|
}
|